From 39e6e52228a97c4eceed0962fac8975cb23d6ce8 Mon Sep 17 00:00:00 2001 From: Satsh Date: Sat, 22 Aug 2026 16:08:25 +0200 Subject: [PATCH 1/8] feat(webapp): redesign navigation, global panel and settings First step of the dashboard redesign. Remove the mobile header and its drawer. Implement a single navigation component that presents as a vertical rail beside the content on desktop and as a bottom bar on mobile, with the account menu at its end. Rework the org global panel around a consumed- energy gauge and an extended date range picker, and add a settings panel wired into the router. Add the Disket Mono display face, a Figma-derived icon set, and the supporting Tailwind and global CSS type/color tokens the new surfaces use. Extend the API schemas and mock data/handlers to back them, with tests. Also ignore the Vite cache directory (.vite/). --- .gitignore | 3 + webapp/index.html | 4 +- webapp/public/fonts/Disket-mono_EULA.pdf | Bin 0 -> 110901 bytes webapp/public/fonts/DisketMono-Bold.ttf | Bin 0 -> 90660 bytes webapp/public/fonts/DisketMono-Regular.ttf | Bin 0 -> 84204 bytes webapp/src/api/mock/data.ts | 112 ++++++- webapp/src/api/mock/handlers.ts | 19 +- webapp/src/api/schemas.ts | 14 + webapp/src/components/account-menu.tsx | 316 ++++++++++++++++++ .../src/components/consumed-energy-gauge.tsx | 118 +++++++ webapp/src/components/date-range-picker.tsx | 71 +++- webapp/src/components/icons/account-icon.tsx | 69 ++++ webapp/src/components/icons/figma-icons.tsx | 259 ++++++++++++++ webapp/src/components/icons/members-icon.tsx | 84 +++++ .../components/icons/organization-icon.tsx | 84 +++++ webapp/src/components/icons/plus-icon.tsx | 34 ++ webapp/src/components/mobile-header.tsx | 53 --- webapp/src/components/sidebar-rail.tsx | 237 +++++++++++++ webapp/src/components/ui/sheet.tsx | 20 +- webapp/src/globals.css | 203 +++++++++++ webapp/src/layouts/DashboardLayout.tsx | 90 +++-- webapp/src/pages/OrgDashboardPage.tsx | 266 ++++++++++----- webapp/src/pages/SettingsPage.tsx | 167 +++++++++ webapp/src/router.tsx | 16 + webapp/tailwind.config.ts | 53 +++ webapp/tests/api/mock/handlers.test.ts | 60 ++++ 26 files changed, 2130 insertions(+), 222 deletions(-) create mode 100644 webapp/public/fonts/Disket-mono_EULA.pdf create mode 100644 webapp/public/fonts/DisketMono-Bold.ttf create mode 100644 webapp/public/fonts/DisketMono-Regular.ttf create mode 100644 webapp/src/components/account-menu.tsx create mode 100644 webapp/src/components/consumed-energy-gauge.tsx create mode 100644 webapp/src/components/icons/account-icon.tsx create mode 100644 webapp/src/components/icons/figma-icons.tsx create mode 100644 webapp/src/components/icons/members-icon.tsx create mode 100644 webapp/src/components/icons/organization-icon.tsx create mode 100644 webapp/src/components/icons/plus-icon.tsx delete mode 100644 webapp/src/components/mobile-header.tsx create mode 100644 webapp/src/components/sidebar-rail.tsx create mode 100644 webapp/src/pages/SettingsPage.tsx diff --git a/.gitignore b/.gitignore index 16e9cbed7..28cf5727f 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ tests/test_data/rapl/* credentials* .codecarbon.config* scripts/agent-vm.personal.config.sh + +# Vite cache +.vite/ diff --git a/webapp/index.html b/webapp/index.html index 2f6b0bae9..4d8da11fb 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -4,8 +4,10 @@ + + CodeCarbon diff --git a/webapp/public/fonts/Disket-mono_EULA.pdf b/webapp/public/fonts/Disket-mono_EULA.pdf new file mode 100644 index 0000000000000000000000000000000000000000..026af39e006b7294eccc2646c966515adb119e64 GIT binary patch literal 110901 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 YjaN0}^ih=j>tFuI|6sQ4U;pxd00w}lY5)KL literal 0 HcmV?d00001 diff --git a/webapp/public/fonts/DisketMono-Bold.ttf b/webapp/public/fonts/DisketMono-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a33bfccb336054f663de8a0605fc4a5bd6fdc0f9 GIT binary patch literal 90660 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 TF8>m#rQzFH)aW}|bi{uEKvmU% literal 0 HcmV?d00001 diff --git a/webapp/public/fonts/DisketMono-Regular.ttf b/webapp/public/fonts/DisketMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b564c60ac3c09d7d97cf7a5828f05ff388cec5cf GIT binary patch literal 84204 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 { + 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/schemas.ts b/webapp/src/api/schemas.ts index f7fe8772d..8eb6fbd1f 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. diff --git a/webapp/src/components/account-menu.tsx b/webapp/src/components/account-menu.tsx new file mode 100644 index 000000000..b33f3b663 --- /dev/null +++ b/webapp/src/components/account-menu.tsx @@ -0,0 +1,316 @@ +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 { ExitToAppIcon, SettingsIcon } from "./icons/figma-icons"; +import { OrganizationIcon } from "./icons/organization-icon"; +import { PlusIcon } from "./icons/plus-icon"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +/* + * The account menu from Figma 218:14541 ("menu"), the subject of frame + * 218:7838 — "Changing organization from side bar". + * + * The panel is one of the few places where fixed dimensions are the right answer: + * its width and row height are the component's own size, not a position, so they + * live in the `menu` and `control` spacing tokens. Everything inside is a flex + * column whose spacing comes from padding and gaps, and the panel's height is + * whatever its rows add up to — it grows and shrinks with the real organization + * list rather than being pinned to the frame's 338px. + * + * Figma values: + * panel 203px wide, #181818, 2px #a0c55b border, 4px radius, + * drop-shadow 0 4px 10px rgba(0,0,0,0.25) + * heading Inter Medium 10px, centred, 15px vertical padding (218:14542) + * row 46px tall, 15px/8px padding, 4px gap, 24px avatar, + * IBM Plex Mono Medium 12px label + * rule 1px #949494 with 10px either side (218:14547) + * + * Deviation from the frame: the design's rows carry a 2px #a0c55b edge on both + * the left and the right when highlighted; only the right edge is kept here. + * + * Anchoring is expressed as a relationship to the trigger rather than as frame + * coordinates. In the design the panel occupies x 49..252, y 621..959 and the + * "Account" item occupies x 9..93, y 959..1009, which means: + * - the panel's bottom edge sits on the trigger's top edge -> sideOffset 0 + * - the panel's left edge sits at the trigger's centre -> align start, + * shifted by half the trigger's width + * The second is taken from Radix's own trigger-width variable, so it holds if the + * rail's width or padding ever change. + * + * State: the menu is drawn open in Figma only to document its open appearance. + * It is closed by default here and driven by real `open` state. + * + * The highlighted "Mozilla" row in the frame is an instance override rather than + * a component variant — the Figma "menu item" component has a single "Default" + * variant, so the design defines exactly one highlight treatment and does not + * distinguish hover from selected. That one treatment is used for both here: it + * persistently marks the organization currently being viewed, and it is also the + * hover/focus state. + */ + +/* + * The row label fills the remaining width instead of carrying Figma's fixed + * 121px text box, so long organization names wrap rather than overflow. + */ +const ROW = + "min-h-control w-full flex items-center gap-1 px-4 py-2 " + + "cursor-pointer select-none outline-none rounded-none " + + "border-y-0 border-l-0 border-r-0 border-transparent " + + // Reaches only the icons: every label sets its own colour below. + "text-cc-gray"; + +/* + * The highlight treatment, from Figma 218:14545: a #2b2b2b fill with a 2px + * #a0c55b right edge and a #a0c55b label. The design also draws a matching left + * edge; that one is dropped here by request. It serves two purposes + * — it marks the organization you are currently viewing, and it is the + * hover/focus state for any row. Focus is bound as well as hover so the menu is + * fully operable from the keyboard; Radix moves focus with the arrow keys and + * also sets it on hover. + */ +const ROW_HIGHLIGHT = + "bg-cc-darkest-gray border-cc-button-hover text-cc-button-hover"; +const ROW_HOVER = + "focus:bg-cc-darkest-gray focus:border-cc-button-hover focus:text-cc-button-hover " + + "data-[highlighted]:bg-cc-darkest-gray data-[highlighted]:border-cc-button-hover " + + "data-[highlighted]:text-cc-button-hover"; + +const ROW_LABEL = + "type-mono-medium type-menu-item min-w-0 flex-1 break-words " + + "group-focus:text-cc-button-hover " + + "group-data-[highlighted]:text-cc-button-hover"; + +function MenuRow({ + onSelect, + children, + icon, + /** Marks the organization currently being viewed. */ + isCurrent, +}: Readonly<{ + onSelect: () => void; + children: React.ReactNode; + icon: React.ReactNode; + isCurrent?: boolean; +}>) { + return ( + + {icon} + + {children} + + + ); +} + +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); + const navigate = useNavigate(); + const newOrgModal = useModal(); + /* + * Holds the list only after creating an organization, so the new one shows up + * without a reload. It deliberately starts undefined rather than mirroring + * `orgs`: `orgs` arrives empty on the first render while SWR is still + * fetching, and seeding state from it would freeze the menu on that empty + * array. Until a refresh happens the live prop is used. + */ + const [organizationList, setOrganizationList] = useState< + Organization[] | undefined + >(undefined); + + // The user's real name for the profile row. Figma shows a person's name and + // photograph here; "Inimaz" is demo content and is not hardcoded. This uses + // the endpoint AuthGuard already relies on, so no new API surface is added. + const { data: auth } = useSWR<{ user?: User }>("/auth/check", fetcher, { + revalidateOnFocus: false, + }); + + const list = organizationList ?? orgs; + + /* + * Dashboards the user administers go below the rule; the rest are ones they + * were invited to. Admin rights live on the membership, and the API exposes + * them only per organization via its member list (`is_admin` on + * `GET /organizations/{id}/users`), so this asks each organization in turn. + * + * Deferred until the menu is opened, to keep it off the page-load path, and + * cached by SWR after that. Until it resolves every dashboard sits in the + * invited section, which is how the menu behaved before the split. + */ + const userId = auth?.user?.id; + const { data: adminOrgIds } = useSWR( + open && 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 ( + <> + + {children} + + {invited.length > 0 && ( +

+ Dashboards you've been invited to +

+ )} + + {invited.map((org) => ( + onSelectOrg(org.id)} + icon={ + + } + > + {org.name} + + ))} + + {/* + * Not present in the Figma frame, but the organization + * switcher it replaces owned the only entry point to + * creating an organization. Removing it would delete + * working functionality, so it is kept here using the + * design's own row styling. + */} + newOrgModal.open()} + icon={} + > + Add new organization + + + {/* Figma 218:14547 — a rule with 10px of space either side. */} +
+ + {/* + * The dashboards the user administers — their own. Figma + * 218:14548 shows a person's name here. They select like any + * other dashboard and highlight when one is being viewed. + */} + {owned.map((org) => ( + onSelectOrg(org.id)} + icon={ + + } + > + {org.name} + + ))} + + {/* Figma 218:14549 — sits between the profile row and + "Log out", as the design orders them. */} + navigate("/settings")} + icon={} + > + Settings + + + { + window.location.href = `${import.meta.env.VITE_API_URL}/auth/logout`; + }} + icon={} + > + Log out + + + + + + + ); +} diff --git a/webapp/src/components/consumed-energy-gauge.tsx b/webapp/src/components/consumed-energy-gauge.tsx new file mode 100644 index 000000000..036306af4 --- /dev/null +++ b/webapp/src/components/consumed-energy-gauge.tsx @@ -0,0 +1,118 @@ +import { cn } from "@/helpers/utils"; + +/* + * The "Consumed energy" gauge from Figma 218:14517 / 218:14522 / 218:14527. + * + * The whole gauge is one SVG, so its geometry lives in the viewBox rather than + * in CSS offsets: the ring, the value and the caption are all placed in the + * design's own coordinate space, exactly as Figma specifies them. That keeps the + * numbers where they are meaningful (vector coordinates) instead of turning them + * into layout margins, and — because the SVG scales to its box rather than + * carrying a fixed pixel size — it lets the parent shrink the gauge on narrow + * screens. + * + * Figma values, identical for all three gauges: + * viewBox 199.23 x 199.23 + * ring cx = cy = 99.6152, r = 87.0511, 25.1281 stroke + * track #3B4041 (218:14518) + * value arc #BFFB4F, butt caps, sweeping anti-clockwise from 12 o'clock + * value Disket Mono Bold 26, #BFFB4F, at 40.384 / 75.384 + * caption IBM Plex Mono Medium 13.343, #8A8A8A, at 43.974 / 115.769 + * + * The arc is a fixed decorative sweep, not a proportion of the value. That is + * deliberate and matches what this dashboard has always drawn: the previous + * recharts gauges swept a constant 100 degrees regardless of their data, and + * Figma gives the three gauges different arbitrary sweeps even where two show the + * same number. 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. + * + * What changed from the old gauges is presentation only: the arc was rendering + * black (its fill never resolved), and it started at 3 o'clock. It is now the + * design's lime and starts at 12 o'clock. + */ + +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 ( + + + + {/* + * Figma left-aligns both labels from their own x offsets, with the + * value's baseline box starting at y 75.384 and the caption's at + * 115.769. `dominant-baseline: text-before-edge` makes those the top + * edges, matching how Figma positions the text frames. + */} + + {value} + + + {label} + + + ); +} diff --git a/webapp/src/components/date-range-picker.tsx b/webapp/src/components/date-range-picker.tsx index 8f39839d6..b5b90c3b7 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 Figma "Input" component (218:14532): 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}
+
Skip to main content - -
-
- - - -
- {initialLoad ? ( -
- -
- ) : ( - - )} -
-
+ {/* Figma "Side Menu closed" (218:14364) — the vertical rail from + `md` up, a bottom navigation bar below it. */} + + +
+
+ {initialLoad ? ( +
+ +
+ ) : ( + + )} +
); diff --git a/webapp/src/pages/OrgDashboardPage.tsx b/webapp/src/pages/OrgDashboardPage.tsx index dd3c7f607..f98ac3ea9 100644 --- a/webapp/src/pages/OrgDashboardPage.tsx +++ b/webapp/src/pages/OrgDashboardPage.tsx @@ -1,12 +1,11 @@ -import { lazy, Suspense, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import ErrorMessage from "@/components/error-message"; import Loader from "@/components/loader"; -import { Card, CardContent } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; - -const RadialChart = lazy(() => import("@/components/radial-chart")); +import { AddAPhotoIcon } from "@/components/icons/figma-icons"; +import { DateRangePicker } from "@/components/date-range-picker"; +import ConsumedEnergyGauge from "@/components/consumed-energy-gauge"; import { getEquivalentCarKm, @@ -20,6 +19,25 @@ import { Organization, OrganizationReport } from "@/api/schemas"; import { DateRange } from "react-day-picker"; import useSWR from "swr"; +/* + * The Global dashboard from Figma frame 218:7838. + * + * The page is one fluid content column: a breadcrumb, a header, then two + * sections below a rule. Its structure is expressed as flex regions with the + * spacing on their parents, so it holds together at any width rather than only + * at the frame's 1440x1024. + * + * Where the design's own measurements were internally inconsistent they have + * been normalised to a single value, since they clearly describe one gutter: + * - the breadcrumb, rule and sections sit at x 178 / 170 / 163 in the frame; + * all three now share the container's horizontal padding + * - the three equivalence items have 26 / 22 / 21px icon gaps; all now use one + * + * Text metrics are untouched: the exact Figma families, weights and sizes live in + * the `type-*` classes in globals.css, at line-height "normal" as the design + * specifies. Vertical rhythm comes from gaps, so nothing depends on a pinned line + * box and the layout holds as the display face loads. + */ export default function OrgDashboardPage() { const { organizationId } = useParams<{ organizationId: string }>(); const { @@ -29,7 +47,7 @@ export default function OrgDashboardPage() { } = useSWR(`/organizations/${organizationId}`, fetcher); const today = new Date(); - const [date] = useState({ + const [date, setDate] = useState({ from: new Date(today.getTime() - THIRTY_DAYS_MS), to: today, }); @@ -62,6 +80,10 @@ export default function OrgDashboardPage() { return ; } + if (!organization) { + return ; + } + const RadialChartData = { energy: { label: "kWh", @@ -70,7 +92,7 @@ export default function OrgDashboardPage() { : 0, }, emissions: { - label: "kg eq CO2", + label: "Kg. Eq. CO2", value: organizationReport?.emissions ? parseFloat(organizationReport.emissions.toFixed(2)) : 0, @@ -87,92 +109,158 @@ export default function OrgDashboardPage() { }, }; - const citizen_converted_value = getEquivalentCitizenPercentage( - RadialChartData.emissions.value, - ).toFixed(2); - const transportation_converted_value = getEquivalentCarKm( - RadialChartData.emissions.value, - ).toFixed(2); - const tv_time_converted_value = getEquivalentTvTime( - RadialChartData.energy.value, - ).toFixed(2); - - const radialFallback = ( - - - - - - ); + const equivalences = [ + { + icon: "/icons/household_consumption.svg", + alt: "Household consumption icon", + value: `${getEquivalentCitizenPercentage( + RadialChartData.emissions.value, + ).toFixed(2)}%`, + caption: "Of an american household weekly energy consumption", + }, + { + icon: "/icons/transportation.svg", + alt: "Transportation icon", + value: `${getEquivalentCarKm( + RadialChartData.emissions.value, + ).toFixed(2)} km`, + caption: "Kilometers ridden", + }, + { + icon: "/icons/tv.svg", + alt: "TV icon", + value: `${getEquivalentTvTime(RadialChartData.energy.value).toFixed( + 2, + )} days`, + caption: "Of watching TV", + }, + ]; + + const gauges = [ + RadialChartData.energy, + RadialChartData.emissions, + RadialChartData.duration, + ]; return ( -
- {!organization && } - {organization && ( -
-
-
- Household consumption icon -

- {citizen_converted_value} % -

-

- Of a U.S citizen weekly energy emissions -

-
-
- Transportation icon -

- {transportation_converted_value} -

-

- Kilometers ridden -

-
-
- TV icon -

- {tv_time_converted_value} days -

-

- Of watching TV -

-

- Of watching TV -

-
+ /* + * One scrolling content column. All horizontal placement comes from this + * container's padding, and all vertical rhythm from the gaps between its + * regions — so the rule, the header and the sections share a single + * gutter instead of carrying three different Figma left offsets + * (163 / 170 / 178px in the frame, normalised to one here). + */ +
+ {/* Breadcrumb and header, with the space below them separating the + group from the rule. */} +
+ {/* Breadcrumb — Figma 218:14433 */} + + + {/* Header — Figma 218:14437 / 218:14436 / 218:14435 / 218:14532 */} +
+
+

+ {organization.name} Global +

+

+ This section has the impact of all projects combined +

-
- - - - - - - - - + + {/* Bounded so the field keeps the design's proportions on wide + screens instead of stretching with the viewport. */} +
+
-
- )} +
+
+ + {/* Sections, separated from the header by the design's rule + (Figma 218:14434). */} +
+ {/* Equal to — Figma 218:14441 */} +
+
+

+ Equal to +

+ {/* + * Figma 218:14444. A plain vector frame in the design, + * not a component instance, with no prototype + * interaction — so there is no defined action behind it + * and the app has no matching feature. Rendered as drawn, + * non-interactive, rather than wired to a guess. + */} + +
+ + {/* + * Figma distributes the three equivalences with + * space-between; they wrap instead of overflowing once the + * column is too narrow to hold them. + */} +
    + {equivalences.map((item) => ( +
  • + {item.alt} +
    +

    + {item.value} +

    + {/* Measure from the design, which wraps this caption to two lines. */} +

    + {item.caption} +

    +
    +
  • + ))} +
+
+ + {/* Consumed energy — Figma 218:14513 */} +
+

+ Consumed energy +

+ {/* Fixed-size gauges in a wrapping row, as the design lays + them out (a gap, not space-between). */} +
    + {gauges.map((gauge) => ( +
  • + +
  • + ))} +
+
+
); } diff --git a/webapp/src/pages/SettingsPage.tsx b/webapp/src/pages/SettingsPage.tsx new file mode 100644 index 000000000..0b5e90e13 --- /dev/null +++ b/webapp/src/pages/SettingsPage.tsx @@ -0,0 +1,167 @@ +import { useNavigate } from "react-router-dom"; +import useSWR from "swr"; + +import { User } from "@/api/schemas"; +import { fetcher } from "@/api/swr"; +import { cn } from "@/helpers/utils"; +import { + AccountCircleIcon, + ArrowBackIosIcon, + LockIcon, +} from "@/components/icons/figma-icons"; + +const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; + +/* + * The Settings page from Figma frame 77:762. + * + * The frame has no sidebar rail and carries its own "Go back" control, so this is + * a standalone page rather than a child of DashboardLayout — it is routed under + * AuthGuard only. + * + * Layout: a hugging sub-nav beside a bounded content column, both starting at the + * same top edge (the design aligns "Go back" with the "Profile" title). Figma + * positions everything absolutely; here the design's measurements map onto + * container padding and gaps: + * sub-nav rows 20px/10px padding, 4px radius, 4px gap; 24px icon then a + * 10px-padded label. Its 163px width in the frame is exactly its + * hug-content width, so no width is set. + * content the input row is 466 + 4 + 106 = 576px wide, which is `max-w-xl` + * to the pixel, so that bounds the column. The frame's rule is + * drawn 627px wide; it spans the column here instead. + * nav <-> body 35px in the frame -> gap-9 + * + * Selected sub-nav row: #2b2b2b fill with a #BFFB4F label. Unselected rows are + * white at 50% opacity, exactly as the design draws them. + */ + +const NAV_ROW = + "flex items-center gap-0 rounded-menu px-5 py-2.5 text-left outline-none " + + "focus-visible:ring-2 focus-visible:ring-cc-lime"; + +/* + * Interactive states for the sub-nav rows. The design draws unselected rows at + * 50% opacity and gives no hover or pressed state, so these build on what it does + * define: hover brings the row to full opacity over a #2b2b2b wash — the same + * fill the selected row uses — and pressing settles on that fill outright. + * Disabled rows stay flat. + */ +const NAV_ROW_INTERACTIVE = + "cursor-pointer opacity-50 transition " + + "hover:opacity-100 hover:bg-cc-darkest-gray/50 " + + "active:bg-cc-darkest-gray active:opacity-100 " + + "disabled:cursor-not-allowed disabled:opacity-50 " + + "disabled:hover:bg-transparent motion-reduce:transition-none"; + +const NAV_LABEL = "type-display type-settings-nav whitespace-nowrap p-2.5"; + +export default function SettingsPage() { + const navigate = useNavigate(); + + // The signed-in user's real email, from the endpoint AuthGuard already uses. + // Figma shows the field in its "Disabled" state with placeholder text; the + // value here is the actual address rather than that placeholder. + const { data: auth, isLoading } = useSWR<{ user?: User }>( + "/auth/check", + fetcher, + { revalidateOnFocus: false }, + ); + const email = auth?.user?.email; + + /* + * Email address and password are both owned by the OIDC provider — the API + * exposes no endpoint for either, and the previous navigation's "Profile" + * item already sent users there. So "Change" and "Password" hand off to it, + * and are disabled when no provider URL is configured. + */ + const goToProvider = () => { + if (USER_PROFILE_URL) window.location.href = USER_PROFILE_URL; + }; + + return ( +
+
+ {/* Sub-nav — Figma 77:885 */} + + + {/* Content — Figma 77:951 / 218:15560 / 77:952 / 77:771 / 218:15559 */} +
+

+ Profile +

+ +
+

+ Email +

+

+ Manage your email address to receive important + updates and notifications +

+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/webapp/src/router.tsx b/webapp/src/router.tsx index fcd852371..b20323e2b 100644 --- a/webapp/src/router.tsx +++ b/webapp/src/router.tsx @@ -13,6 +13,7 @@ const ProjectsPage = lazy(() => import("./pages/ProjectsPage")); const ProjectDashboardPage = lazy(() => import("./pages/ProjectDashboardPage")); const ProjectSettingsPage = lazy(() => import("./pages/ProjectSettingsPage")); const MembersPage = lazy(() => import("./pages/MembersPage")); +const SettingsPage = lazy(() => import("./pages/SettingsPage")); function SuspenseWrapper({ children }: { children: React.ReactNode }) { return }>{children}; @@ -43,6 +44,21 @@ export const router = createBrowserRouter([ ), }, + /* + * Settings sits outside DashboardLayout: Figma frame 77:762 has no sidebar + * rail and provides its own "Go back" control, so it is a standalone + * authenticated page. + */ + { + path: "/settings", + element: ( + + + + + + ), + }, { element: ( diff --git a/webapp/tailwind.config.ts b/webapp/tailwind.config.ts index 31cf1a474..4b4ad737e 100644 --- a/webapp/tailwind.config.ts +++ b/webapp/tailwind.config.ts @@ -13,7 +13,57 @@ const config = { }, }, extend: { + spacing: { + /* + * Structural dimensions from the redesign, each defined once + * and only where it belongs. + * + * `rail` is the compact sidebar's own width. `gauge` is the + * consumed-energy ring's diameter (Figma 199.23, normalised — + * the ring's proportions live in the SVG viewBox, so the + * rendered size is free). `heading` is the gap between a + * section heading and its content (Figma 46px, which is off + * the default scale but consistent across both sections). + */ + rail: "101px", + gauge: "200px", + heading: "46px", + /* Account menu panel width and row/field control height. */ + control: "46px", + }, + boxShadow: { + /* Figma 218:14541 — account menu panel. */ + menu: "0 4px 10px rgba(0, 0, 0, 0.25)", + }, + fontFamily: { + // IBM Plex Mono. `font-mono` is already applied on . + mono: ["var(--font-mono)"], + // Disket Mono Bold — the design's display face, self-hosted + // from public/fonts. See the note in globals.css. + display: ["var(--font-display)"], + }, colors: { + /* + * Figma variables from frame 218:7838, added additively. The + * existing shadcn tokens below are untouched: `--primary` + * already resolves to #BFFB4F and `--background` to #181818, + * so those two are reused rather than duplicated in markup. + */ + cc: { + background: "var(--cc-background)", + "page-background": "var(--cc-page-background)", + lime: "var(--cc-lime)", + white: "var(--cc-white)", + "dark-gray": "var(--cc-dark-gray)", + "darkest-gray": "var(--cc-darkest-gray)", + gray: "var(--cc-gray)", + "button-hover": "var(--cc-button-hover)", + "text-input-gray": "var(--cc-text-input-gray)", + "breadcrumb-gray": "var(--cc-breadcrumb-gray)", + rule: "var(--cc-rule)", + "gauge-track": "var(--cc-gauge-track)", + "gauge-label": "var(--cc-gauge-label)", + }, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", @@ -49,6 +99,9 @@ const config = { }, }, borderRadius: { + /* Design-specific radii from the Figma frame. */ + menu: "4px", + field: "2px", lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", diff --git a/webapp/tests/api/mock/handlers.test.ts b/webapp/tests/api/mock/handlers.test.ts index b0dd2c4ca..9cc9d7e96 100644 --- a/webapp/tests/api/mock/handlers.test.ts +++ b/webapp/tests/api/mock/handlers.test.ts @@ -48,6 +48,66 @@ describe("resolveMock — organizations", () => { expect((r.body as { name: string }).name).toBe("Mock Organization"); }); + /* + * The real endpoint filters the emissions table by timestamp; these pin the + * mock to the same behaviour so the dashboard's date picker can be exercised + * — and regressions in date handling caught — without a live backend. + */ + describe("org sums honour the date range", () => { + type Report = { + emissions: number; + energy_consumed: number; + duration: number; + }; + const sums = (qs = ""): Report => + resolveMock(url(`/organizations/${ID.org}/sums${qs}`), "GET") + .body as Report; + const iso = (daysAgo: number) => + new Date(Date.now() - daysAgo * 86_400_000).toISOString(); + + it("aggregates every emission when no range is given", () => { + const all = sums(); + // 12 + 12 + 6 sampled rows across the fixture's three runs. + expect(all.duration).toBe(30 * 5 * 60); + expect(all.emissions).toBeGreaterThan(0); + expect(all.energy_consumed).toBeGreaterThan(0); + }); + + it("excludes runs outside the range", () => { + const all = sums(); + // Fixtures sit at now-20d (x2) and now-5d; a 7-day window keeps only + // the most recent one, which has 6 of the 30 rows. + const week = sums(`?start_date=${iso(7)}&end_date=${iso(0)}`); + expect(week.duration).toBe(6 * 5 * 60); + expect(week.emissions).toBeLessThan(all.emissions); + expect(week.emissions).toBeGreaterThan(0); + }); + + it("returns zeros for a range containing nothing", () => { + const empty = sums(`?start_date=${iso(400)}&end_date=${iso(390)}`); + expect(empty).toMatchObject({ + emissions: 0, + energy_consumed: 0, + duration: 0, + }); + }); + + it("widening the range can only add emissions", () => { + const week = sums(`?start_date=${iso(7)}&end_date=${iso(0)}`); + const month = sums(`?start_date=${iso(30)}&end_date=${iso(0)}`); + expect(month.emissions).toBeGreaterThan(week.emissions); + expect(month.duration).toBeGreaterThan(week.duration); + }); + + it("ignores an unparseable date rather than failing", () => { + const r = resolveMock( + url(`/organizations/${ID.org}/sums?start_date=not-a-date`), + "GET", + ); + expect(r.status).toBe(200); + }); + }); + it("synthesizes an added user on POST /add-user", () => { const r = resolveMock( url(`/organizations/${ID.org}/add-user`), From 57fdcba40b1b954760a909b40a1510aabb7eed9d Mon Sep 17 00:00:00 2001 From: Satsh Date: Sat, 22 Aug 2026 19:30:22 +0200 Subject: [PATCH 2/8] feat(webapp): redesign projects page and project dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second step of the dashboard redesign. Rebuild the Projects page in both its empty and populated states: breadcrumb and surface shared with the global panel, a heading row carrying the primary action, and a table of rows whose two text columns light as one item and whose overflow menu holds the existing Settings and Delete actions. Rework the create-project and project-settings dialogs onto the design's panel, fields and buttons. Project settings now opens in place from a project row, which makes ProjectSettingsPage a duplicate of it, so that page and its route are removed and the public sharing link it owned moves into the dialog beside the toggle that produces it. The visibility toggle saves on its own so that link appears and disappears with it, while "Save changes" writes the fields and closes; a failed save keeps the dialog open so the edits survive. Creating a project with an empty name is no longer possible, and the form submits on Enter. Fix the dialog transition app-wide. The panel was centred with a transform that tailwindcss-animate's keyframes overwrote, which the shadcn default compensates for by sliding it in from the top-left corner; centring it by layout instead lets it fade and rise in place, with a reduced-motion variant. Fix the account menu regrouping its organizations as it closes, by latching the admin lookup instead of keying it on the open state. Extract the redesign's controls into components — form field, primary and secondary button, menu panel and item, tab nav, modal header, project row, settings nav item, rail button — replacing the class-string constants the first step left behind, and adopt them in the pages and dialogs already redesigned. Drop the Figma node ids from comments, keeping the reasoning they carried, and rename createProjectModal to kebab-case to match the convention the rest of the components follow. --- webapp/src/components/account-menu.tsx | 141 +++------ .../src/components/consumed-energy-gauge.tsx | 4 +- .../src/components/create-project-modal.tsx | 165 ++++++++++ webapp/src/components/createProjectModal.tsx | 136 -------- webapp/src/components/date-range-picker.tsx | 2 +- webapp/src/components/icons/figma-icons.tsx | 47 ++- webapp/src/components/project-row.tsx | 107 +++++++ .../src/components/project-settings-modal.tsx | 237 +++++++++----- .../projectTokens/projectTokenTable.tsx | 17 +- webapp/src/components/settings-nav-item.tsx | 64 ++++ .../src/components/share-project-button.tsx | 32 +- webapp/src/components/sidebar-rail.tsx | 111 ++++--- webapp/src/components/ui/dialog.tsx | 61 +++- webapp/src/components/ui/form-field.tsx | 68 ++++ webapp/src/components/ui/menu.tsx | 81 +++++ webapp/src/components/ui/modal-header.tsx | 54 ++++ webapp/src/components/ui/primary-button.tsx | 50 +++ webapp/src/components/ui/secondary-button.tsx | 48 +++ webapp/src/components/ui/tab-nav.tsx | 54 ++++ webapp/src/globals.css | 141 +++++++-- webapp/src/layouts/DashboardLayout.tsx | 4 +- webapp/src/pages/OrgDashboardPage.tsx | 14 +- webapp/src/pages/ProjectSettingsPage.tsx | 134 -------- webapp/src/pages/ProjectsPage.tsx | 293 ++++++++++++------ webapp/src/pages/SettingsPage.tsx | 91 ++---- webapp/src/router.tsx | 11 +- webapp/tailwind.config.ts | 2 + ...test.tsx => create-project-modal.test.tsx} | 32 +- 28 files changed, 1442 insertions(+), 759 deletions(-) create mode 100644 webapp/src/components/create-project-modal.tsx delete mode 100644 webapp/src/components/createProjectModal.tsx create mode 100644 webapp/src/components/project-row.tsx create mode 100644 webapp/src/components/settings-nav-item.tsx create mode 100644 webapp/src/components/ui/form-field.tsx create mode 100644 webapp/src/components/ui/menu.tsx create mode 100644 webapp/src/components/ui/modal-header.tsx create mode 100644 webapp/src/components/ui/primary-button.tsx create mode 100644 webapp/src/components/ui/secondary-button.tsx create mode 100644 webapp/src/components/ui/tab-nav.tsx delete mode 100644 webapp/src/pages/ProjectSettingsPage.tsx rename webapp/tests/components/{createProjectModal.test.tsx => create-project-modal.test.tsx} (59%) diff --git a/webapp/src/components/account-menu.tsx b/webapp/src/components/account-menu.tsx index b33f3b663..9b70e4800 100644 --- a/webapp/src/components/account-menu.tsx +++ b/webapp/src/components/account-menu.tsx @@ -11,16 +11,12 @@ import CreateOrganizationModal from "./createOrganizationModal"; import { ExitToAppIcon, SettingsIcon } from "./icons/figma-icons"; import { OrganizationIcon } from "./icons/organization-icon"; import { PlusIcon } from "./icons/plus-icon"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { MenuItem, MenuPanel } from "./ui/menu"; /* - * The account menu from Figma 218:14541 ("menu"), the subject of frame - * 218:7838 — "Changing organization from side bar". + * The account menu, the subject of the design's "Changing organization from side + * bar". * * The panel is one of the few places where fixed dimensions are the right answer: * its width and row height are the component's own size, not a position, so they @@ -32,13 +28,10 @@ import { * Figma values: * panel 203px wide, #181818, 2px #a0c55b border, 4px radius, * drop-shadow 0 4px 10px rgba(0,0,0,0.25) - * heading Inter Medium 10px, centred, 15px vertical padding (218:14542) + * heading Inter Medium 10px, centred, 15px vertical padding * row 46px tall, 15px/8px padding, 4px gap, 24px avatar, * IBM Plex Mono Medium 12px label - * rule 1px #949494 with 10px either side (218:14547) - * - * Deviation from the frame: the design's rows carry a 2px #a0c55b edge on both - * the left and the right when highlighted; only the right edge is kept here. + * rule 1px #949494 with 10px either side * * Anchoring is expressed as a relationship to the trigger rather than as frame * coordinates. In the design the panel occupies x 49..252, y 621..959 and the @@ -60,69 +53,6 @@ import { * hover/focus state. */ -/* - * The row label fills the remaining width instead of carrying Figma's fixed - * 121px text box, so long organization names wrap rather than overflow. - */ -const ROW = - "min-h-control w-full flex items-center gap-1 px-4 py-2 " + - "cursor-pointer select-none outline-none rounded-none " + - "border-y-0 border-l-0 border-r-0 border-transparent " + - // Reaches only the icons: every label sets its own colour below. - "text-cc-gray"; - -/* - * The highlight treatment, from Figma 218:14545: a #2b2b2b fill with a 2px - * #a0c55b right edge and a #a0c55b label. The design also draws a matching left - * edge; that one is dropped here by request. It serves two purposes - * — it marks the organization you are currently viewing, and it is the - * hover/focus state for any row. Focus is bound as well as hover so the menu is - * fully operable from the keyboard; Radix moves focus with the arrow keys and - * also sets it on hover. - */ -const ROW_HIGHLIGHT = - "bg-cc-darkest-gray border-cc-button-hover text-cc-button-hover"; -const ROW_HOVER = - "focus:bg-cc-darkest-gray focus:border-cc-button-hover focus:text-cc-button-hover " + - "data-[highlighted]:bg-cc-darkest-gray data-[highlighted]:border-cc-button-hover " + - "data-[highlighted]:text-cc-button-hover"; - -const ROW_LABEL = - "type-mono-medium type-menu-item min-w-0 flex-1 break-words " + - "group-focus:text-cc-button-hover " + - "group-data-[highlighted]:text-cc-button-hover"; - -function MenuRow({ - onSelect, - children, - icon, - /** Marks the organization currently being viewed. */ - isCurrent, -}: Readonly<{ - onSelect: () => void; - children: React.ReactNode; - icon: React.ReactNode; - isCurrent?: boolean; -}>) { - return ( - - {icon} - - {children} - - - ); -} - export default function AccountMenu({ orgs, selectedOrg, @@ -136,6 +66,14 @@ export default function AccountMenu({ children: React.ReactNode; }>) { const [open, setOpen] = useState(false); + /* + * Latched on the first open, and never cleared. The admin lookup below is + * keyed on it rather than on `open` so that closing the menu does not drop the + * result: with the key gone, SWR reports no data, every dashboard falls back + * into the "invited" group, and the one the user administers visibly jumps up + * out of its section while the menu is animating out. + */ + const [hasOpened, setHasOpened] = useState(false); const navigate = useNavigate(); const newOrgModal = useModal(); /* @@ -164,13 +102,13 @@ export default function AccountMenu({ * them only per organization via its member list (`is_admin` on * `GET /organizations/{id}/users`), so this asks each organization in turn. * - * Deferred until the menu is opened, to keep it off the page-load path, and - * cached by SWR after that. Until it resolves every dashboard sits in the - * invited section, which is how the menu behaved before the split. + * Deferred until the menu is first opened, to keep it off the page-load path, + * and kept from then on — see `hasOpened`. Until it resolves every dashboard + * sits in the invited section, which is how the menu behaved before the split. */ const userId = auth?.user?.id; const { data: adminOrgIds } = useSWR( - open && userId && list && list.length > 0 + hasOpened && userId && list && list.length > 0 ? ["organization-admin", userId, list.map((o) => o.id).join(",")] : null, async () => { @@ -206,9 +144,15 @@ export default function AccountMenu({ return ( <> - + { + setOpen(next); + if (next) setHasOpened(true); + }} + > {children} - {invited.length > 0 && ( @@ -239,7 +178,7 @@ export default function AccountMenu({ )} {invited.map((org) => ( - onSelectOrg(org.id)} @@ -248,7 +187,7 @@ export default function AccountMenu({ } > {org.name} - + ))} {/* @@ -258,23 +197,23 @@ export default function AccountMenu({ * working functionality, so it is kept here using the * design's own row styling. */} - newOrgModal.open()} icon={} > Add new organization - + - {/* Figma 218:14547 — a rule with 10px of space either side. */} + {/* A rule with 10px of space either side. */}
{/* * The dashboards the user administers — their own. Figma - * 218:14548 shows a person's name here. They select like any + * The design shows a person's name here. They select like any * other dashboard and highlight when one is being viewed. */} {owned.map((org) => ( - onSelectOrg(org.id)} @@ -283,27 +222,27 @@ export default function AccountMenu({ } > {org.name} - + ))} - {/* Figma 218:14549 — sits between the profile row and + {/* Sits between the profile row and "Log out", as the design orders them. */} - navigate("/settings")} icon={} > Settings - + - { window.location.href = `${import.meta.env.VITE_API_URL}/auth/logout`; }} icon={} > Log out - - + + 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 ( + + {/* The design's own close control lives in the header, so the shared + corner button is omitted. */} + + + + {/* The form shares the header's gutter. Its vertical padding is + the panel's main source of air. */} +
{ + event.preventDefault(); + handleSave(); + }} + > + + setFormData({ ...formData, name: e.target.value }) + } + /> + + + setFormData({ + ...formData, + description: e.target.value, + }) + } + /> + + {/* Separated by more than the gap between the fields, so it + reads as the end of the form rather than a third row. */} +
+ + {isLoading ? "Creating..." : "Create project"} + +
+ +
+
+ ); +}; + +export default CreateProjectModal; 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 ( - - - - Create new project - - Fill in the details to create your project - - - -
-
- - - setFormData({ - ...formData, - name: e.target.value, - }) - } - placeholder="Project Name" - /> -
-
- -
-
- - - setFormData({ - ...formData, - description: e.target.value, - }) - } - placeholder="Project Description" - /> -
-
- - - - -
-
- ); -}; - -export default CreateProjectModal; diff --git a/webapp/src/components/date-range-picker.tsx b/webapp/src/components/date-range-picker.tsx index b5b90c3b7..685ecfe2b 100644 --- a/webapp/src/components/date-range-picker.tsx +++ b/webapp/src/components/date-range-picker.tsx @@ -16,7 +16,7 @@ interface DateRangePickerProps { onDateChange: (newDate: DateRange | undefined) => void; /** * `default` keeps the outline button used elsewhere in the app. - * `dashboard` matches the Figma "Input" component (218:14532): a 16px + * `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. diff --git a/webapp/src/components/icons/figma-icons.tsx b/webapp/src/components/icons/figma-icons.tsx index 10227ad32..2a1025828 100644 --- a/webapp/src/components/icons/figma-icons.tsx +++ b/webapp/src/components/icons/figma-icons.tsx @@ -1,5 +1,5 @@ /* - * Icons exported verbatim from the Code Carbon Figma file (frame 218:7838). + * Icons exported verbatim from the Code Carbon Figma file. * * Every path below is the exact "d" attribute Figma produced — nothing was * redrawn, simplified, or swapped for a Lucide/Heroicons equivalent. Figma @@ -19,7 +19,7 @@ type IconProps = { className?: string; }; -/** Figma 218:16025 — 32x32. Lime (#BFFB4F) in the selected rail item. */ +/** 32x32. Lime (#BFFB4F) in the selected rail item. */ export function GlobalIcon({ className }: IconProps) { return ( ); } + +/** + * The overflow trigger on a project row — 24x24. + * + * Ships from the design as a glyph clipped by an alpha mask whose only content is + * the bounding-box rect; that mask is a no-op and is flattened away, as with the + * other Material-derived icons above. The glyph already spans its box, so there is + * no inner offset to preserve. + */ +export function MoreVertIcon({ className }: IconProps) { + return ( + + ); +} diff --git a/webapp/src/components/project-row.tsx b/webapp/src/components/project-row.tsx new file mode 100644 index 000000000..007f88d45 --- /dev/null +++ b/webapp/src/components/project-row.tsx @@ -0,0 +1,107 @@ +import { Link } from "react-router-dom"; + +import { Project } from "@/api/schemas"; +import { cn } from "@/helpers/utils"; +import { MoreVertIcon } from "./icons/figma-icons"; +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. + * + * The name and the secondary text are both links to the project, and each fills + * its cell — the row's vertical padding is on the links, so the whole height and + * width of a cell is a click target rather than just the glyphs. + * + * The two light together on hover, because they are one item rather than two + * independently hoverable cells. The actions cell is excluded from that: its + * trigger is a small glyph in a padded cell, so if the cell lit the row along + * with it there would be no way to tell whether the pointer is on the button or + * merely near it. The trigger keeps its own hover, and that is the only thing + * reporting its hitbox — which is also why an open menu lights the button alone. + * + * Focus stays per-control, since focus really is on one thing at a time. + */ + +/** 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. */} + + + + + {`Actions for ${project.name}`} + + + + + + Settings + Delete + + + + + ); +} diff --git a/webapp/src/components/project-settings-modal.tsx b/webapp/src/components/project-settings-modal.tsx index 0befd0ba9..dfed47e71 100644 --- a/webapp/src/components/project-settings-modal.tsx +++ b/webapp/src/components/project-settings-modal.tsx @@ -1,22 +1,44 @@ -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 panel, the title, the fields and the primary button are the Create-project + * modal's — the two dialogs are the same object in the design system, so they + * share the same components rather than each describing the treatment. This one is + * wider because it holds the API-tokens table, and that width is the only + * dimension it sets for itself. + * + * The design has no frame for this dialog, so nothing here is traced: it is the + * redesign's own vocabulary applied to the controls the dialog already had. + * + * Two things are deliberately untouched, since they are not redesigned yet: the + * `Switch` and the API-tokens table. Both are shared with screens outside this + * dialog, so restyling them belongs to their own pass. + * + * Two things save independently. "Save changes" writes the fields and closes the + * dialog, as it did before. The public toggle writes itself the moment it is + * flipped, which is what lets the sharing link it controls appear and disappear + * with it — a link is not something to hold in a draft state, since it either + * works or it does not. + * + * Only a failed submit keeps the dialog open, so edits that did not save are still + * there to retry rather than being discarded. + */ interface ProjectSettingsModalProps { open: boolean; @@ -37,12 +59,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 +115,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 ( - - - Project Settings - - Manage your project settings and API tokens - - + + - - - General - - - API Tokens - - - - -
-
- - setName(e.target.value)} - placeholder="Enter project name" - className="mt-1" - /> -
-
- - - setDescription(e.target.value) - } - placeholder="Enter project description" - className="mt-1" - /> -
-
+ + General + API Tokens + + + +
{ + event.preventDefault(); + handleSave(); + }} + > + setName(e.target.value)} + /> + + setDescription(e.target.value)} + /> + +
-
-
- - - - + + {/* Appears and disappears with the toggle above, + which saves itself. */} + + +
+ + {isSaving && ( + + )} + {isSaving ? "Saving..." : "Save changes"} + +
+ - + {/* Not redesigned yet — the table keeps its current look. */} + diff --git a/webapp/src/components/projectTokens/projectTokenTable.tsx b/webapp/src/components/projectTokens/projectTokenTable.tsx index 87b03ddcf..03b909919 100644 --- a/webapp/src/components/projectTokens/projectTokenTable.tsx +++ b/webapp/src/components/projectTokens/projectTokenTable.tsx @@ -6,6 +6,8 @@ import CustomRowToken from "@/components/projectTokens/custom-row-token"; import { useMemo, useState, useEffect, useRef } from "react"; import { Loader2, ClipboardCopy, ClipboardCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { PlusIcon } from "@/components/icons/plus-icon"; +import { PrimaryButton } from "@/components/ui/primary-button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import copy from "copy-to-clipboard"; @@ -103,14 +105,15 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => { return (
-
+
{!isCreatingToken && !createdToken ? ( - + /* The redesign's primary action. The rest of this table is + not restyled yet, so this is the one control that follows + the new treatment for now. */ + setIsCreatingToken(true)}> + + Create a new token + ) : createdToken ? (

diff --git a/webapp/src/components/settings-nav-item.tsx b/webapp/src/components/settings-nav-item.tsx new file mode 100644 index 000000000..152d96797 --- /dev/null +++ b/webapp/src/components/settings-nav-item.tsx @@ -0,0 +1,64 @@ +import { cn } from "@/helpers/utils"; + +/* + * A row in the Settings page's sub-navigation. + * + * The design draws unselected rows at 50% opacity and gives no hover or pressed + * state, so those are built from what it does define: hover brings the row to + * full opacity over a #2b2b2b wash — the same fill the selected row uses — and + * pressing settles on that fill outright. Disabled rows stay flat. + * + * The selected row is not a button. It is the panel you are already on, so there + * is nothing to press: it renders as a `span` marked `aria-current`, which is + * also what keeps it out of the tab order. + */ + +const ROW = + "flex items-center gap-0 rounded-menu px-5 py-2.5 text-left outline-none " + + "focus-visible:ring-2 focus-visible:ring-cc-lime"; + +const LABEL = "type-display type-settings-nav whitespace-nowrap p-2.5"; + +export default function SettingsNavItem({ + icon: Icon, + label, + isCurrent, + onClick, + disabled, +}: Readonly<{ + icon: (props: { className?: string }) => React.JSX.Element; + label: string; + isCurrent?: boolean; + onClick?: () => void; + disabled?: boolean; +}>) { + const tone = isCurrent ? "text-cc-lime" : "text-cc-white"; + + if (isCurrent) { + return ( + + + {label} + + ); + } + + return ( + + ); +} diff --git a/webapp/src/components/share-project-button.tsx b/webapp/src/components/share-project-button.tsx index 32ef913dd..4b8f61685 100644 --- a/webapp/src/components/share-project-button.tsx +++ b/webapp/src/components/share-project-button.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { SecondaryButton } from "@/components/ui/secondary-button"; import { Popover, PopoverContent, @@ -13,11 +14,19 @@ import { toast } from "sonner"; interface ShareProjectButtonProps { projectId: string; isPublic: boolean; + /* + * How the control presents itself. `icon` is the round icon button the + * project dashboard has always shown; `labelled` is the redesign's outlined + * "Copy link" button, used where the control sits in a form beside the + * setting that produces the link. Both open the same panel. + */ + trigger?: "icon" | "labelled"; } export default function ShareProjectButton({ projectId, isPublic, + trigger = "icon", }: ShareProjectButtonProps) { const [copied, setCopied] = useState(false); const copyTimerRef = useRef | null>(null); @@ -51,14 +60,21 @@ export default function ShareProjectButton({
- + {trigger === "labelled" ? ( + + + Copy link + + ) : ( + + )}
diff --git a/webapp/src/components/sidebar-rail.tsx b/webapp/src/components/sidebar-rail.tsx index 4b73430f3..13ae5e8f1 100644 --- a/webapp/src/components/sidebar-rail.tsx +++ b/webapp/src/components/sidebar-rail.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; @@ -9,7 +10,7 @@ import { AccountIcon } from "./icons/account-icon"; import { MembersIcon } from "./icons/members-icon"; /* - * The "Side Menu closed" rail from Figma 218:14364. + * The "Side Menu closed" rail from the design. * * Figma positions the rail's groups as absolutely placed children with * percentage insets, but they are structurally just a column: the destinations @@ -58,10 +59,48 @@ import { MembersIcon } from "./icons/members-icon"; * icon and its label together with no extra rules. */ -/** Shared hover/pressed colouring for every control in the rail. */ -export const RAIL_INTERACTIVE = - "cursor-pointer transition-colors hover:text-cc-button-hover " + - "active:text-cc-lime motion-reduce:transition-none"; +/* + * A control in the rail: an icon above its label, green when it is the page you + * are on and white otherwise. + * + * Hover and pressed are not drawn in the design, so they are built from the + * palette it already uses — hover goes to its own "Button-hover" (#a0c55b) and + * pressing to the selected green. The icons take their fill from `currentColor`, + * so colouring the control moves the icon and its label together. + * + * It renders a plain button and forwards its props, so the destinations use it for + * navigation and the account control uses it as a menu trigger. + */ +const RailButton = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<"button"> & { + icon: (props: { className?: string }) => React.JSX.Element; + label: string; + isSelected?: boolean; + } +>(({ icon: Icon, label, isSelected, className, ...props }, ref) => ( + +)); +RailButton.displayName = "RailButton"; type RailItem = { key: string; @@ -165,43 +204,29 @@ export default function SidebarRail({ // groups below collapse to `display: contents` and let the nav // itself space them. "flex w-full flex-row items-stretch justify-around border-t-2 border-black px-2 py-1.5", - // From `md`: the vertical rail from Figma 218:14364. + // From `md`: the design's vertical rail. "md:h-full md:w-rail md:flex-col md:items-center md:justify-start md:overflow-y-auto md:border-r-2 md:border-t-0 md:px-4 md:pb-4 md:pt-6", className, )} > - {/* Destinations — Figma I218:14364;218:7831 */} + {/* Destinations */}
- {ITEMS.map(({ key, label, Icon, path }) => { - const isSelected = key === selectedKey; - return ( - - ); - })} + {ITEMS.map(({ key, label, Icon, path }) => ( + { + if (!selectedOrg) return; + navigate(path(selectedOrg)); + }} + /> + ))}
{/* - * Account item — Figma I218:14364;79:3291, pinned to the bottom of + * Account item, pinned to the bottom of * the rail. Triggers the account menu, which opens upward from it. * * The `mt-auto` and the minimum separation live on this wrapper, not @@ -217,19 +242,11 @@ export default function SidebarRail({ navigate(`/${organizationId}`); }} > - +
diff --git a/webapp/src/components/ui/dialog.tsx b/webapp/src/components/ui/dialog.tsx index 37a694ff3..8d8ae4cc1 100644 --- a/webapp/src/components/ui/dialog.tsx +++ b/webapp/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< , - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + /** + * Omits the built-in corner close button, for dialogs whose own header + * provides one (e.g. the redesigned Create-project modal). + */ + hideClose?: boolean; + } +>(({ className, children, hideClose = false, ...props }, ref) => ( - - {children} - - - Close - - +
+ + {children} + {!hideClose && ( + + + Close + + )} + +
)); DialogContent.displayName = DialogPrimitive.Content.displayName; diff --git a/webapp/src/components/ui/form-field.tsx b/webapp/src/components/ui/form-field.tsx new file mode 100644 index 000000000..4b6170bcf --- /dev/null +++ b/webapp/src/components/ui/form-field.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; + +import { cn } from "@/helpers/utils"; + +/* + * A labelled text field, as the design draws it: a 16px label, then a 46px + * control on a 5% white fill with a 2px radius and a #666 placeholder. + * + * The label and the control are one component because the design treats them as + * one — the 4px between them and the label's type are part of the field, not + * decisions a form remakes each time it needs one. `id` is required for the same + * reason: the label is only a label if it points at something. + * + * Separate from `ui/input.tsx`, which is the shadcn input the screens outside the + * redesign still use, and which carries no label. + */ +type FormFieldProps = Omit, "id"> & { + id: string; + label: string; + /** + * Hides the label visually while leaving it for assistive technology, for the + * rare field whose surroundings already name it. + */ + hideLabel?: boolean; + /** Classes for the wrapper; `className` styles the control itself. */ + containerClassName?: string; +}; + +export const FormField = React.forwardRef( + ( + { + id, + label, + className, + containerClassName, + hideLabel = false, + type = "text", + ...props + }, + ref, + ) => ( +
+ + +
+ ), +); +FormField.displayName = "FormField"; diff --git a/webapp/src/components/ui/menu.tsx b/webapp/src/components/ui/menu.tsx new file mode 100644 index 000000000..c0d838021 --- /dev/null +++ b/webapp/src/components/ui/menu.tsx @@ -0,0 +1,81 @@ +import * as React from "react"; + +import { cn } from "@/helpers/utils"; +import { + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; + +/* + * The redesign's dropdown menu: a dark panel with a green edge, and rows that + * highlight as one. + * + * Both menus in the app — the account menu at the end of the rail, and a project + * row's overflow menu — are the same object in the design, so they render through + * these rather than each restating the panel's border, fill and shadow. + * + * The design defines a single highlight treatment and does not distinguish hover + * from selected, so `isCurrent` and the hover/focus state share it: a #2b2b2b + * fill with a green right edge and a green label. Focus is bound as well as hover + * so the menu is fully operable from the keyboard — Radix moves focus with the + * arrow keys and also sets it on hover. + */ + +export const MenuPanel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +MenuPanel.displayName = "MenuPanel"; + +type MenuItemProps = React.ComponentPropsWithoutRef & { + /** Rendered before the label, at the row's own size. */ + icon?: React.ReactNode; + /** Marks the row as the thing currently being viewed. */ + isCurrent?: boolean; +}; + +export const MenuItem = React.forwardRef< + React.ElementRef, + MenuItemProps +>(({ className, children, icon, isCurrent, ...props }, ref) => ( + + {icon} + {/* Fills the remaining width rather than sitting in a fixed text box, so + a long name wraps instead of overflowing. */} + + {children} + + +)); +MenuItem.displayName = "MenuItem"; diff --git a/webapp/src/components/ui/modal-header.tsx b/webapp/src/components/ui/modal-header.tsx new file mode 100644 index 000000000..e38e35a5b --- /dev/null +++ b/webapp/src/components/ui/modal-header.tsx @@ -0,0 +1,54 @@ +import { PlusIcon } from "@/components/icons/plus-icon"; +import { DialogClose, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/helpers/utils"; + +/* + * A dialog's header, as the design draws it: the title in the display face with + * the close control beside it, above the rule that separates it from the body. + * + * The ✕ is the design's "add" glyph rotated 45 degrees — that is how the file + * builds it — so it renders as the same `PlusIcon` under a `rotate-45`, rather + * than a second asset that would have to stay in step with the first. Dialogs + * using this header pass `hideClose` to `DialogContent`, since this is their close + * control. + * + * The title's size is set here with utilities rather than through a `type-*` + * class, deliberately: `DialogTitle` ships a `text-lg` of its own, and because + * `twMerge` cannot tell that a project class is a font-size it leaves both in + * place — then `text-lg` wins, being a utility, and the title renders at 18px. + * Naming the size as a utility is what lets the merge drop the one it replaces. + */ +export default function ModalHeader({ + title, + className, + children, +}: Readonly<{ + title: string; + className?: string; + /** Optional controls placed before the close button. */ + children?: React.ReactNode; +}>) { + return ( + +
+ + {title} + + +
+ {children} + + + + Close + +
+
+
+ ); +} diff --git a/webapp/src/components/ui/primary-button.tsx b/webapp/src/components/ui/primary-button.tsx new file mode 100644 index 000000000..1415a9ba2 --- /dev/null +++ b/webapp/src/components/ui/primary-button.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; + +import { cn } from "@/helpers/utils"; + +/* + * The redesign's primary action: the lime button, wherever the design calls for + * one. + * + * Separate from `ui/button.tsx` rather than added to it as a variant — that + * button's variants are built on the shadcn token set (`bg-primary`, + * `ring-offset-background`) and every screen the redesign has not reached still + * renders them, so its shape is not free to change. This one owns the design's + * treatment and nothing else renders through it by accident. + * + * `ringOffset` exists because the focus ring is drawn against whatever the button + * sits on: the page's surface behind a page-level action, the panel's behind one + * in a dialog. It is the only thing about the button that its context decides. + */ +type PrimaryButtonProps = React.ComponentPropsWithoutRef<"button"> & { + ringOffset?: "background" | "page"; +}; + +export const PrimaryButton = React.forwardRef< + HTMLButtonElement, + PrimaryButtonProps +>( + ( + { className, type = "button", ringOffset = "background", ...props }, + ref, + ) => ( + -
- - -

-

API tokens

-
- -
-
-
-
- ); -} diff --git a/webapp/src/pages/ProjectsPage.tsx b/webapp/src/pages/ProjectsPage.tsx index 1a7d1d66a..99a2e4402 100644 --- a/webapp/src/pages/ProjectsPage.tsx +++ b/webapp/src/pages/ProjectsPage.tsx @@ -1,43 +1,104 @@ -import BreadcrumbHeader from "@/components/breadcrumb"; -import CreateProjectModal from "@/components/createProjectModal"; -import CustomRow from "@/components/custom-row"; +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import useSWR from "swr"; +import { toast } from "sonner"; + +import CreateProjectModal from "@/components/create-project-modal"; import DeleteProjectModal from "@/components/delete-project-modal"; import ErrorMessage from "@/components/error-message"; import Loader from "@/components/loader"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; +import ProjectRow from "@/components/project-row"; +import ProjectSettingsModal from "@/components/project-settings-modal"; +import { PlusIcon } from "@/components/icons/plus-icon"; +import { PrimaryButton } from "@/components/ui/primary-button"; import { Table, TableBody } from "@/components/ui/table"; + import { fetcher } from "@/api/swr"; +import { deleteProject, getProjects } from "@/api/projects"; +import { Organization, Project } from "@/api/schemas"; import { useModal } from "@/hooks/useModal"; -import { getProjects, deleteProject } from "@/api/projects"; -import { Project } from "@/api/schemas"; -import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import useSWR from "swr"; -import { toast } from "sonner"; +/* + * The Projects page, in its empty and populated states. + * + * Both of the design's frames still draw the older navigation — an in-page "Code + * carbon" header, a "Your projects / Members" tab row and an account dropdown + * pinned top right. All of that now lives in `SidebarRail`, so it is deliberately + * not rebuilt here; the page opens with the breadcrumb and background of the + * redesigned Global dashboard instead. Confirmed with the project owners. + * + * The two states differ only in where the primary action sits: with no projects it + * is centred in the empty area above the explanatory line, and with projects it + * sits on the heading row. Both render from the same shell below, so the page never + * changes structure — only which of the two regions is present. + * + * As with the other redesigned pages, the design's absolute coordinates are not + * reproduced: horizontal placement comes from the container's padding and vertical + * rhythm from gaps, so the page holds at any width rather than only at the frame's + * 1440x1024. + * + * The rows are a table, as the page used before the redesign: one record per line + * with the same fields in the same order, so they line up down the page instead of + * each row placing them wherever its own content falls. Columns size to their + * content and wrap, so a narrow screen compresses them rather than scrolling the + * table sideways. There is no `thead`, because the design labels no columns. + * + * The design labels a row's secondary text "Last updated on 02/02/24"; the project + * API carries no timestamp, so that slot keeps the description the page has always + * shown. Its overflow menu keeps the existing Settings and Delete actions — the + * design draws the trigger but never the open menu. + */ export default function ProjectsPage() { const { organizationId } = useParams<{ organizationId: string }>(); - let organizationName: string | null = null; - try { - organizationName = localStorage.getItem("organizationName"); - } catch { - organizationName = null; - } + const createModal = useModal(); + const settingsModal = useModal(); const deleteModal = useModal(); const [projectList, setProjectList] = useState([]); + const [projectToEdit, setProjectToEdit] = useState(null); const [projectToDelete, setProjectToDelete] = useState( null, ); - const handleClick = async () => { - createModal.open(); - }; + /* + * The breadcrumb's organization name. Fetched like the Global dashboard does, + * with the name the navigation already cached as the fallback while the request + * is in flight, so the crumb never flashes a bare id. + */ + const { data: organization } = useSWR( + organizationId ? `/organizations/${organizationId}` : null, + fetcher, + { revalidateOnFocus: false }, + ); + let cachedOrganizationName: string | null = null; + try { + cachedOrganizationName = localStorage.getItem("organizationName"); + } catch { + cachedOrganizationName = null; + } + const organizationName = + organization?.name || cachedOrganizationName || organizationId!; + + const { + data: projects, + error, + isLoading, + } = useSWR(`/projects?organization=${organizationId}`, fetcher); + + useEffect(() => { + if (projects) { + setProjectList(projects); + } + }, [projects]); const refreshProjectList = async () => { - const projects = await getProjects(organizationId!); - setProjectList(projects || []); + const refreshed = await getProjects(organizationId!); + setProjectList(refreshed || []); + }; + + const handleSettingsClick = (project: Project) => { + setProjectToEdit(project); + settingsModal.open(); }; const handleDeleteClick = (project: Project) => { @@ -56,22 +117,6 @@ export default function ProjectsPage() { } }; - const { - data: projects, - error, - isLoading, - } = useSWR( - `/projects?organization=${organizationId}`, - fetcher, - {}, - ); - - useEffect(() => { - if (projects) { - setProjectList(projects); - } - }, [projects]); - if (isLoading) { return ; } @@ -80,73 +125,115 @@ export default function ProjectsPage() { return ; } + const sortedProjects = [...projectList].sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()), + ); + const hasProjects = sortedProjects.length > 0; + + /* The same button in the design's two placements: on the heading row, and + centred in the empty state. */ + const addProjectButton = ( + + + Add a project + + ); + return ( -
- -
-
-

Projects

- - + /* The Global dashboard's content column: one scrolling region whose + padding is the single horizontal gutter for everything inside it. */ +
+ {/* The parent crumb hovers to white rather than to the design's + button-hover green, which is the colour of the current crumb beside + it — hovering should not make a link look like the page you are + already on. */} + + + {/* The action joins the heading only when there are projects; the + empty state centres it instead. */} +
+

+ Projects +

+ {hasProjects && addProjectButton} +
+ + {hasProjects ? ( + + + {sortedProjects.map((project) => ( + handleSettingsClick(project)} + onDelete={() => handleDeleteClick(project)} + /> + ))} + +
+ ) : ( + /* + * The design gives the empty region a fixed 525px height purely to + * centre its contents in the frame; here it takes the space the + * column has left over and centres within that, with a minimum so + * it still reads as an empty area when the viewport is short. + */ +
+ {addProjectButton} +

+ You have no projects added yet... +

- - - - {projectList && - projectList - .sort((a, b) => - a.name - .toLowerCase() - .localeCompare( - b.name.toLowerCase(), - ), - ) - .map((project) => ( - - handleDeleteClick(project) - } - /> - ))} - -
-
- {projectToDelete && ( - - )} -
+ )} + + + + {/* + * Settings opens in place rather than navigating away. The project + * dashboard's own settings control already opens this same modal, so + * the row menu now behaves the way the rest of the app does, and acting + * on a row no longer costs you the list you were working in. The row + * already holds the whole project, so nothing is refetched. + */} + {projectToEdit && ( + + )} + + {projectToDelete && ( + + )}
); } diff --git a/webapp/src/pages/SettingsPage.tsx b/webapp/src/pages/SettingsPage.tsx index 0b5e90e13..d76baa77b 100644 --- a/webapp/src/pages/SettingsPage.tsx +++ b/webapp/src/pages/SettingsPage.tsx @@ -3,7 +3,9 @@ import useSWR from "swr"; import { User } from "@/api/schemas"; import { fetcher } from "@/api/swr"; -import { cn } from "@/helpers/utils"; +import SettingsNavItem from "@/components/settings-nav-item"; +import { FormField } from "@/components/ui/form-field"; +import { PrimaryButton } from "@/components/ui/primary-button"; import { AccountCircleIcon, ArrowBackIosIcon, @@ -13,7 +15,7 @@ import { const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; /* - * The Settings page from Figma frame 77:762. + * The Settings page from the design. * * The frame has no sidebar rail and carries its own "Go back" control, so this is * a standalone page rather than a child of DashboardLayout — it is routed under @@ -35,26 +37,6 @@ const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; * white at 50% opacity, exactly as the design draws them. */ -const NAV_ROW = - "flex items-center gap-0 rounded-menu px-5 py-2.5 text-left outline-none " + - "focus-visible:ring-2 focus-visible:ring-cc-lime"; - -/* - * Interactive states for the sub-nav rows. The design draws unselected rows at - * 50% opacity and gives no hover or pressed state, so these build on what it does - * define: hover brings the row to full opacity over a #2b2b2b wash — the same - * fill the selected row uses — and pressing settles on that fill outright. - * Disabled rows stay flat. - */ -const NAV_ROW_INTERACTIVE = - "cursor-pointer opacity-50 transition " + - "hover:opacity-100 hover:bg-cc-darkest-gray/50 " + - "active:bg-cc-darkest-gray active:opacity-100 " + - "disabled:cursor-not-allowed disabled:opacity-50 " + - "disabled:hover:bg-transparent motion-reduce:transition-none"; - -const NAV_LABEL = "type-display type-settings-nav whitespace-nowrap p-2.5"; - export default function SettingsPage() { const navigate = useNavigate(); @@ -81,48 +63,34 @@ export default function SettingsPage() { return (
- {/* Sub-nav — Figma 77:885 */} + {/* Sub-nav */} - {/* Content — Figma 77:951 / 218:15560 / 77:952 / 77:771 / 218:15559 */} + {/* Content */}

Profile @@ -138,26 +106,25 @@ export default function SettingsPage() {

- - - +

diff --git a/webapp/src/router.tsx b/webapp/src/router.tsx index b20323e2b..2e935ae06 100644 --- a/webapp/src/router.tsx +++ b/webapp/src/router.tsx @@ -11,7 +11,6 @@ const HomePage = lazy(() => import("./pages/HomePage")); const OrgDashboardPage = lazy(() => import("./pages/OrgDashboardPage")); const ProjectsPage = lazy(() => import("./pages/ProjectsPage")); const ProjectDashboardPage = lazy(() => import("./pages/ProjectDashboardPage")); -const ProjectSettingsPage = lazy(() => import("./pages/ProjectSettingsPage")); const MembersPage = lazy(() => import("./pages/MembersPage")); const SettingsPage = lazy(() => import("./pages/SettingsPage")); @@ -45,7 +44,7 @@ export const router = createBrowserRouter([ ), }, /* - * Settings sits outside DashboardLayout: Figma frame 77:762 has no sidebar + * Settings sits outside DashboardLayout: its design has no sidebar * rail and provides its own "Go back" control, so it is a standalone * authenticated page. */ @@ -98,14 +97,6 @@ export const router = createBrowserRouter([ ), }, - { - path: "/:organizationId/projects/:projectId/settings", - element: ( - - - - ), - }, { path: "/:organizationId/members", element: ( diff --git a/webapp/tailwind.config.ts b/webapp/tailwind.config.ts index 4b4ad737e..6a05f589b 100644 --- a/webapp/tailwind.config.ts +++ b/webapp/tailwind.config.ts @@ -34,6 +34,8 @@ const config = { boxShadow: { /* Figma 218:14541 — account menu panel. */ menu: "0 4px 10px rgba(0, 0, 0, 0.25)", + /* Figma 218:11927 — modal panel. */ + dialog: "0 4px 20px rgba(0, 0, 0, 0.25)", }, fontFamily: { // IBM Plex Mono. `font-mono` is already applied on . diff --git a/webapp/tests/components/createProjectModal.test.tsx b/webapp/tests/components/create-project-modal.test.tsx similarity index 59% rename from webapp/tests/components/createProjectModal.test.tsx rename to webapp/tests/components/create-project-modal.test.tsx index f6601a64a..7e5a56f51 100644 --- a/webapp/tests/components/createProjectModal.test.tsx +++ b/webapp/tests/components/create-project-modal.test.tsx @@ -7,7 +7,7 @@ vi.mock("@/api/projects", () => ({ createProject: createProjectMock, })); -import CreateProjectModal from "@/components/createProjectModal"; +import CreateProjectModal from "@/components/create-project-modal"; beforeEach(() => { createProjectMock.mockReset(); @@ -22,6 +22,25 @@ beforeEach(() => { }); describe("CreateProjectModal", () => { + it("keeps the submit action disabled until a name is entered", async () => { + render( + , + ); + + const submit = screen.getByRole("button", { + name: /^create project$/i, + }); + expect(submit).toBeDisabled(); + + await userEvent.type(screen.getByLabelText(/^name$/i), "New project"); + expect(submit).toBeEnabled(); + }); + it("submits the form with name + description and the parent org id", async () => { const onProjectCreated = vi.fn().mockResolvedValue(undefined); const onClose = vi.fn(); @@ -35,17 +54,16 @@ describe("CreateProjectModal", () => { />, ); + // The redesign labels the fields "Name" and "Description", and the + // action "Create project". + await userEvent.type(screen.getByLabelText(/^name$/i), "New project"); await userEvent.type( - screen.getByPlaceholderText(/project name/i), - "New project", - ); - await userEvent.type( - screen.getByPlaceholderText(/project description/i), + screen.getByLabelText(/^description$/i), "Some desc", ); await userEvent.click( - screen.getByRole("button", { name: /^create$/i }), + screen.getByRole("button", { name: /^create project$/i }), ); // toast.promise resolves the inner thunk asynchronously. From 67f647390835d7e7d4af12c7a913168076e41d97 Mon Sep 17 00:00:00 2001 From: Satsh Date: Sat, 22 Aug 2026 19:55:08 +0200 Subject: [PATCH 3/8] refactor(webapp): split the icon barrel into one module per icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every icon exported from the Figma file lived in a single figma-icons.tsx. Give each its own module, named the way the icons supplied outside that file already were, and delete the barrel. Repoint the rail, the account menu, the project row, the global dashboard and the settings page at the new modules. Declare the props contract once as FigmaIconProps, replacing the local copy each icon carried, and reduce every icon's comment to a line saying what the glyph shows — the sizes, insets, hex fills and node ids they listed described the design file rather than the code, and went stale the moment either changed. --- webapp/src/components/account-menu.tsx | 3 +- .../components/icons/account-circle-icon.tsx | 24 ++ webapp/src/components/icons/account-icon.tsx | 13 +- .../src/components/icons/add-a-photo-icon.tsx | 22 ++ .../components/icons/arrow-back-ios-icon.tsx | 24 ++ .../src/components/icons/exit-to-app-icon.tsx | 24 ++ webapp/src/components/icons/figma-icons.tsx | 284 ------------------ webapp/src/components/icons/global-icon.tsx | 33 ++ webapp/src/components/icons/glossary-icon.tsx | 39 +++ webapp/src/components/icons/lock-icon.tsx | 22 ++ webapp/src/components/icons/members-icon.tsx | 21 +- webapp/src/components/icons/menu-icon.tsx | 22 ++ .../src/components/icons/more-vert-icon.tsx | 22 ++ .../components/icons/organization-icon.tsx | 20 +- webapp/src/components/icons/plus-icon.tsx | 18 +- webapp/src/components/icons/projects-icon.tsx | 35 +++ webapp/src/components/icons/settings-icon.tsx | 24 ++ webapp/src/components/icons/types.ts | 16 + webapp/src/components/project-row.tsx | 2 +- .../projectTokens/projectTokenTable.tsx | 5 +- webapp/src/components/sidebar-rail.tsx | 5 +- webapp/src/pages/OrgDashboardPage.tsx | 2 +- webapp/src/pages/SettingsPage.tsx | 8 +- .../projectTokens/projectTokenTable.test.tsx | 2 +- 24 files changed, 338 insertions(+), 352 deletions(-) create mode 100644 webapp/src/components/icons/account-circle-icon.tsx create mode 100644 webapp/src/components/icons/add-a-photo-icon.tsx create mode 100644 webapp/src/components/icons/arrow-back-ios-icon.tsx create mode 100644 webapp/src/components/icons/exit-to-app-icon.tsx delete mode 100644 webapp/src/components/icons/figma-icons.tsx create mode 100644 webapp/src/components/icons/global-icon.tsx create mode 100644 webapp/src/components/icons/glossary-icon.tsx create mode 100644 webapp/src/components/icons/lock-icon.tsx create mode 100644 webapp/src/components/icons/menu-icon.tsx create mode 100644 webapp/src/components/icons/more-vert-icon.tsx create mode 100644 webapp/src/components/icons/projects-icon.tsx create mode 100644 webapp/src/components/icons/settings-icon.tsx create mode 100644 webapp/src/components/icons/types.ts diff --git a/webapp/src/components/account-menu.tsx b/webapp/src/components/account-menu.tsx index 9b70e4800..29178bac7 100644 --- a/webapp/src/components/account-menu.tsx +++ b/webapp/src/components/account-menu.tsx @@ -8,7 +8,8 @@ import { fetcher } from "@/api/swr"; import { cn } from "@/helpers/utils"; import { useModal } from "@/hooks/useModal"; import CreateOrganizationModal from "./createOrganizationModal"; -import { ExitToAppIcon, SettingsIcon } from "./icons/figma-icons"; +import { ExitToAppIcon } from "./icons/exit-to-app-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"; 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 index b0f5f43a4..88cd957bf 100644 --- a/webapp/src/components/icons/account-icon.tsx +++ b/webapp/src/components/icons/account-icon.tsx @@ -1,18 +1,11 @@ +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. - * - * Supplied directly rather than exported from Figma, which is why it sits - * outside `figma-icons.tsx` — everything in that file traces to a Figma node. - * The glyph takes its colour from `currentColor`, so the rail's hover and - * pressed states drive it exactly as they do the other items. */ -type IconProps = { - className?: string; -}; - -export function AccountIcon({ className }: IconProps) { +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/exit-to-app-icon.tsx b/webapp/src/components/icons/exit-to-app-icon.tsx new file mode 100644 index 000000000..eef2325e0 --- /dev/null +++ b/webapp/src/components/icons/exit-to-app-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Log-out icon — an arrow leaving through an open door. + */ + +export function ExitToAppIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/figma-icons.tsx b/webapp/src/components/icons/figma-icons.tsx deleted file mode 100644 index 2a1025828..000000000 --- a/webapp/src/components/icons/figma-icons.tsx +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Icons exported verbatim from the Code Carbon Figma file. - * - * Every path below is the exact "d" attribute Figma produced — nothing was - * redrawn, simplified, or swapped for a Lucide/Heroicons equivalent. Figma - * gives each of these a single flat fill; that fill is replaced with - * `currentColor` so the caller controls state colour (the rail icons are - * #BFFB4F when selected and #FFFFFF otherwise, which is a colour change on one - * shared vector, not two different vectors). - * - * The Material-derived icons (menu, settings, exit_to_app, add_a_photo) ship - * from Figma as a glyph clipped by an alpha mask whose only content is the - * bounding-box rect. That mask is a no-op, so it is flattened away here; the - * glyph is instead placed at its exact Figma offset inside the outer box, which - * preserves both the outer box and the inner leaf geometry. - */ - -type IconProps = { - className?: string; -}; - -/** 32x32. Lime (#BFFB4F) in the selected rail item. */ -export function GlobalIcon({ className }: IconProps) { - return ( - - ); -} - -/** Projects — 32x32. */ -export function ProjectsIcon({ className }: IconProps) { - return ( - - ); -} - -/** Glossary — 32x32. */ -export function GlossaryIcon({ className }: IconProps) { - return ( - - ); -} - -/** 24x24 hamburger, fill #666666 in the design. */ -export function MenuIcon({ className }: IconProps) { - return ( - - ); -} - -/** - * 24x24 outer box holding an 18.792x19 glyph, inset 10.85% - * horizontally (2.604px) and 10.42% vertically (2.5px). Fill #949494. - */ -export function SettingsIcon({ className }: IconProps) { - return ( - - ); -} - -/** - * 24x24 outer box holding a 16.596x16.596 glyph, inset 15.43% - * on every side (3.701px). Fill #949494. - */ -export function ExitToAppIcon({ className }: IconProps) { - return ( - - ); -} - -/** 21.5384x21.5384, fill #7C7C7C. */ -export function AddAPhotoIcon({ className }: IconProps) { - return ( - - ); -} - -/** - * 24x24 outer box holding an 11.073x19.3075 chevron, inset - * 1.44% left (0.346px) and 9.78% top/bottom (2.346px). Used by "Go back" on the - * settings sub-nav. - */ -export function ArrowBackIosIcon({ className }: IconProps) { - return ( - - ); -} - -/** - * 24x24 outer box holding an 18x18 glyph, inset 12.5% (3px) - * on every side. Lime when its sub-nav row is selected. - */ -export function AccountCircleIcon({ className }: IconProps) { - return ( - - ); -} - -/** 24x24; the glyph already fills its box in the design. */ -export function LockIcon({ className }: IconProps) { - return ( - - ); -} - -/** - * The overflow trigger on a project row — 24x24. - * - * Ships from the design as a glyph clipped by an alpha mask whose only content is - * the bounding-box rect; that mask is a no-op and is flattened away, as with the - * other Material-derived icons above. The glyph already spans its box, so there is - * no inner offset to preserve. - */ -export function MoreVertIcon({ className }: IconProps) { - 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..2dba40027 --- /dev/null +++ b/webapp/src/components/icons/lock-icon.tsx @@ -0,0 +1,22 @@ +import { FigmaIconProps } from "./types"; + +/* + * Lock icon — a closed padlock. + */ + +export function LockIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/members-icon.tsx b/webapp/src/components/icons/members-icon.tsx index 56180fbcb..837ef4eba 100644 --- a/webapp/src/components/icons/members-icon.tsx +++ b/webapp/src/components/icons/members-icon.tsx @@ -1,22 +1,11 @@ +import { FigmaIconProps } from "./types"; + /* - * Members icon — two figures side by side, drawn in the same pixel-art style as - * the rail's other icons. - * - * Unlike the icons in `figma-icons.tsx`, this one was supplied directly rather - * than exported from the Figma file, which is why it lives in its own module: - * that file's contents are all traceable to a Figma node, and this is not. - * - * `shape-rendering="crispEdges"` keeps the pixel grid sharp by disabling - * anti-aliasing, which matters for artwork built from whole-pixel rectangles. - * The glyph takes its colour from `currentColor`, so the rail's selected and - * hover states drive it exactly as they do the others. + * Members icon — three figures side by side, middle one popping out, + * drawn in the same pixel-art style as the rail's other icons. */ -type IconProps = { - className?: string; -}; - -export function MembersIcon({ className }: IconProps) { +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..0abbc0f4e --- /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. + */ + +export function MoreVertIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/organization-icon.tsx b/webapp/src/components/icons/organization-icon.tsx index 83c3833b8..1f9dfc74d 100644 --- a/webapp/src/components/icons/organization-icon.tsx +++ b/webapp/src/components/icons/organization-icon.tsx @@ -1,22 +1,10 @@ +import { FigmaIconProps } from "./types"; + /* - * Organization icon — an office building, used for each organization in the - * account menu. - * - * Supplied directly rather than exported from Figma, which is why it sits - * outside `figma-icons.tsx` — everything in that file traces to a Figma node. - * Every drawn element carries `currentColor`, so the menu row's hover and - * selected colours drive it like the other glyphs. - * - * The source also contains a ``, the usual - * bounding-box spacer. It draws nothing and the viewBox already covers the same - * 24x24 area, so it is left out. + * Organization icon — an office building. */ -type IconProps = { - className?: string; -}; - -export function OrganizationIcon({ className }: IconProps) { +export function OrganizationIcon({ className }: FigmaIconProps) { return ( `, the usual - * bounding-box spacer. It draws nothing and the viewBox already covers the same - * 24x24 area, so it is left out. + * Plus icon — a plus sign. */ -type IconProps = { - className?: string; -}; - -export function PlusIcon({ className }: IconProps) { +export function PlusIcon({ 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..8f8bd7304 --- /dev/null +++ b/webapp/src/components/icons/settings-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Settings icon — a cogwheel. + */ + +export function SettingsIcon({ 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/project-row.tsx b/webapp/src/components/project-row.tsx index 007f88d45..022b04507 100644 --- a/webapp/src/components/project-row.tsx +++ b/webapp/src/components/project-row.tsx @@ -2,7 +2,7 @@ import { Link } from "react-router-dom"; import { Project } from "@/api/schemas"; import { cn } from "@/helpers/utils"; -import { MoreVertIcon } from "./icons/figma-icons"; +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"; diff --git a/webapp/src/components/projectTokens/projectTokenTable.tsx b/webapp/src/components/projectTokens/projectTokenTable.tsx index 03b909919..7f3156f67 100644 --- a/webapp/src/components/projectTokens/projectTokenTable.tsx +++ b/webapp/src/components/projectTokens/projectTokenTable.tsx @@ -110,7 +110,10 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => { /* The redesign's primary action. The rest of this table is not restyled yet, so this is the one control that follows the new treatment for now. */ - setIsCreatingToken(true)}> + setIsCreatingToken(true)} + className="gap-4" + > Create a new token diff --git a/webapp/src/components/sidebar-rail.tsx b/webapp/src/components/sidebar-rail.tsx index 13ae5e8f1..16301e292 100644 --- a/webapp/src/components/sidebar-rail.tsx +++ b/webapp/src/components/sidebar-rail.tsx @@ -5,7 +5,8 @@ import { useLocation, useNavigate } from "react-router-dom"; import { Organization } from "@/api/schemas"; import { cn } from "@/helpers/utils"; import AccountMenu from "./account-menu"; -import { GlobalIcon, ProjectsIcon } from "./icons/figma-icons"; +import { GlobalIcon } from "./icons/global-icon"; +import { ProjectsIcon } from "./icons/projects-icon"; import { AccountIcon } from "./icons/account-icon"; import { MembersIcon } from "./icons/members-icon"; @@ -112,7 +113,7 @@ type RailItem = { /* * The third item is Members, matching the destination it has always pointed at * (`/:organizationId/members`). Figma labels it "Glossary" and draws a different - * glyph; `GlossaryIcon` is still exported from `figma-icons.tsx` for whenever a + * glyph; `GlossaryIcon` is still exported from its own module for whenever a * glossary page exists. */ const ITEMS: RailItem[] = [ diff --git a/webapp/src/pages/OrgDashboardPage.tsx b/webapp/src/pages/OrgDashboardPage.tsx index afe4fa436..b2300163d 100644 --- a/webapp/src/pages/OrgDashboardPage.tsx +++ b/webapp/src/pages/OrgDashboardPage.tsx @@ -3,7 +3,7 @@ import { useParams } from "react-router-dom"; import ErrorMessage from "@/components/error-message"; import Loader from "@/components/loader"; -import { AddAPhotoIcon } from "@/components/icons/figma-icons"; +import { AddAPhotoIcon } from "@/components/icons/add-a-photo-icon"; import { DateRangePicker } from "@/components/date-range-picker"; import ConsumedEnergyGauge from "@/components/consumed-energy-gauge"; diff --git a/webapp/src/pages/SettingsPage.tsx b/webapp/src/pages/SettingsPage.tsx index d76baa77b..541ef24eb 100644 --- a/webapp/src/pages/SettingsPage.tsx +++ b/webapp/src/pages/SettingsPage.tsx @@ -6,11 +6,9 @@ import { fetcher } from "@/api/swr"; import SettingsNavItem from "@/components/settings-nav-item"; import { FormField } from "@/components/ui/form-field"; import { PrimaryButton } from "@/components/ui/primary-button"; -import { - AccountCircleIcon, - ArrowBackIosIcon, - LockIcon, -} from "@/components/icons/figma-icons"; +import { AccountCircleIcon } from "@/components/icons/account-circle-icon"; +import { ArrowBackIosIcon } from "@/components/icons/arrow-back-ios-icon"; +import { LockIcon } from "@/components/icons/lock-icon"; const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; diff --git a/webapp/tests/components/projectTokens/projectTokenTable.test.tsx b/webapp/tests/components/projectTokens/projectTokenTable.test.tsx index 5732e69f0..ba71b1fff 100644 --- a/webapp/tests/components/projectTokens/projectTokenTable.test.tsx +++ b/webapp/tests/components/projectTokens/projectTokenTable.test.tsx @@ -32,7 +32,7 @@ describe("ProjectTokensTable", () => { renderWithRouter(); await userEvent.click( await screen.findByRole("button", { - name: /\+ create a new token/i, + name: /create a new token/i, }), ); expect(screen.getByPlaceholderText(/token name/i)).toBeInTheDocument(); From a5c4783c21d30a88d4c10a68980bd388e1d9649d Mon Sep 17 00:00:00 2001 From: Satsh Date: Sat, 22 Aug 2026 19:56:03 +0200 Subject: [PATCH 4/8] feat(webapp): restyle the switch for the redesign Square 2px corners like the fields and buttons, lime when on, and the palette's hover colour. Restyled in place rather than added alongside the shadcn switch, since the project-settings dialog is its only caller. --- webapp/src/components/ui/switch.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/ui/switch.tsx b/webapp/src/components/ui/switch.tsx index 68d631850..a673da7a1 100644 --- a/webapp/src/components/ui/switch.tsx +++ b/webapp/src/components/ui/switch.tsx @@ -5,13 +5,32 @@ import * as SwitchPrimitives from "@radix-ui/react-switch"; import { cn } from "@/helpers/utils"; +/* + * The redesign's toggle: square-cornered like the buttons, with a matching + * square thumb, green when on. + * + * The design has no frame for a switch, so this is built from the vocabulary it + * does define — the 2px radius its fields and buttons carry, the lime that marks + * anything active, and the #a0c55b every control in the app moves to on hover. + * The thumb takes the page's own dark fill, so it reads as a hole punched in the + * track rather than a chip sitting on top of it. + * + * Restyled in place rather than added alongside the shadcn switch: this is the + * app's only switch, so a second one would leave the original with no callers. + */ const Switch = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( From 375810886a3085c8ade7a0f03635107e4b8ddb4b Mon Sep 17 00:00:00 2001 From: Satsh Date: Mon, 24 Aug 2026 23:45:01 +0200 Subject: [PATCH 5/8] chore(webapp): remove modules the redesign left unreachable The pre-redesign navigation, the page-header breadcrumb and the radial chart have had no callers since the rail, the pages' inline breadcrumbs and the consumed-energy gauges replaced them. Four test files were the only thing still importing them, so the suite was covering code no user could reach. Co-Authored-By: Claude Opus 5 --- webapp/src/components/breadcrumb.tsx | 43 --- webapp/src/components/icons/menu-icon.tsx | 22 -- webapp/src/components/nav-item.tsx | 30 --- webapp/src/components/navbar.tsx | 254 ------------------ webapp/src/components/radial-chart.tsx | 121 --------- webapp/src/components/ui/sheet.tsx | 146 ---------- webapp/tests/components/breadcrumb.test.tsx | 27 -- webapp/tests/components/nav-item.test.tsx | 25 -- webapp/tests/components/navbar.test.tsx | 64 ----- webapp/tests/components/radial-chart.test.tsx | 32 --- 10 files changed, 764 deletions(-) delete mode 100644 webapp/src/components/breadcrumb.tsx delete mode 100644 webapp/src/components/icons/menu-icon.tsx delete mode 100644 webapp/src/components/nav-item.tsx delete mode 100644 webapp/src/components/navbar.tsx delete mode 100644 webapp/src/components/radial-chart.tsx delete mode 100644 webapp/src/components/ui/sheet.tsx delete mode 100644 webapp/tests/components/breadcrumb.test.tsx delete mode 100644 webapp/tests/components/nav-item.test.tsx delete mode 100644 webapp/tests/components/navbar.test.tsx delete mode 100644 webapp/tests/components/radial-chart.test.tsx 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/icons/menu-icon.tsx b/webapp/src/components/icons/menu-icon.tsx deleted file mode 100644 index 67e803438..000000000 --- a/webapp/src/components/icons/menu-icon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { FigmaIconProps } from "./types"; - -/* - * Menu icon — three bars stacked one above another. - */ - -export function MenuIcon({ className }: FigmaIconProps) { - return ( - - ); -} diff --git a/webapp/src/components/nav-item.tsx b/webapp/src/components/nav-item.tsx deleted file mode 100644 index 8f9f4f103..000000000 --- a/webapp/src/components/nav-item.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from "./ui/button"; - -export default function NavItem({ - isSelected, - onClick, - icon, - children, - paddingY = 3, -}: { - isSelected: boolean; - onClick?: () => void; - icon?: React.ReactNode; - children: React.ReactNode; - paddingY?: number; -}) { - return ( - - ); -} diff --git a/webapp/src/components/navbar.tsx b/webapp/src/components/navbar.tsx deleted file mode 100644 index a1431db8f..000000000 --- a/webapp/src/components/navbar.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { Organization } from "@/api/schemas"; -import { SelectGroup } from "@radix-ui/react-select"; -import { - AreaChart, - Building, - Home, - LogOutIcon, - UserIcon, - Users, -} from "lucide-react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; -import NavItem from "./nav-item"; -import { - Select, - SelectContent, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "./ui/select"; -import CreateOrganizationModal from "./createOrganizationModal"; -import { getOrganizations } from "@/api/organizations"; -import { Button } from "./ui/button"; -import { useModal } from "@/hooks/useModal"; - -const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; -export default function NavBar({ - orgs, - setSheetOpened, -}: Readonly<{ - orgs: Organization[] | undefined; - setSheetOpened?: (value: boolean) => void; -}>) { - const [selected, setSelected] = useState(null); - const navigate = useNavigate(); - const [selectedOrg, setSelectedOrg] = useState(() => { - try { - return localStorage.getItem("organizationId"); - } catch { - return null; - } - }); - const iconStyles = "h-4 w-4 flex-shrink-0 text-muted-foreground"; - const { pathname } = useLocation(); - const newOrgModal = useModal(); - const [organizationList, setOrganizationList] = useState< - Organization[] | undefined - >([]); - const [isDropdownOpen, setDropdownOpen] = useState(false); - - useEffect(() => { - if (pathname.includes("/members")) { - setSelected("members"); - } else if (pathname.includes("/profile")) { - setSelected("profile"); - } else if (pathname.includes("/projects")) { - setSelected("projects"); - return; - } else { - setSelected("home"); - } - }, [pathname, orgs]); - - useEffect(() => { - if (orgs) { - setOrganizationList(orgs); - } - }, [orgs]); - - useEffect(() => { - if (!organizationList?.length) { - setSelectedOrg(null); - return; - } - const routeOrgId = pathname.split("/")[1]; - if (organizationList.some((org) => org.id === routeOrgId)) { - setSelectedOrg(routeOrgId); - } else if (!organizationList.some((org) => org.id === selectedOrg)) { - setSelectedOrg(organizationList[0].id); - } - }, [pathname, organizationList, selectedOrg]); - - useEffect(() => { - if (!selectedOrg) return; - try { - localStorage.setItem("organizationId", selectedOrg); - const organizationName = organizationList?.find( - (organization) => organization.id === selectedOrg, - )?.name; - if (organizationName) { - localStorage.setItem("organizationName", organizationName); - } - } catch (error) { - console.error("Error writing to localStorage:", error); - } - }, [selectedOrg, organizationList]); - - const handleNewOrgClick = async () => { - newOrgModal.open(); - setDropdownOpen(false); // Close the dropdown menu - }; - - const refreshOrgList = async () => { - // Fetch the updated list of organizations from the server - const orgs = await getOrganizations(); - setOrganizationList(orgs); - }; - - return ( -
- -
- ); -} diff --git a/webapp/src/components/radial-chart.tsx b/webapp/src/components/radial-chart.tsx deleted file mode 100644 index efe7fa02b..000000000 --- a/webapp/src/components/radial-chart.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { - Label, - PolarGrid, - PolarRadiusAxis, - RadialBar, - RadialBarChart, -} from "recharts"; - -import { Card, CardContent } from "@/components/ui/card"; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; - -type chartDataType = { - label: string; - value: number; -}; - -export default function RadialChart({ - data, -}: Readonly<{ data: chartDataType }>) { - const chartConfig = { - value: { - label: data?.label || "", - color: "hsl(var(--primary))", - }, - } satisfies ChartConfig; - - // Check if data is missing or empty - if (!data || data.value === undefined) { - return ( - - -
- No data available -
-
-
- ); - } - - return ( - - - - - - } - /> - - - - - - - - ); -} diff --git a/webapp/src/components/ui/sheet.tsx b/webapp/src/components/ui/sheet.tsx deleted file mode 100644 index eba93bca2..000000000 --- a/webapp/src/components/ui/sheet.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import * as React from "react"; -import * as SheetPrimitive from "@radix-ui/react-dialog"; -import { cva, type VariantProps } from "class-variance-authority"; -import { X } from "lucide-react"; - -import { cn } from "@/helpers/utils"; - -const Sheet = SheetPrimitive.Root; - -const SheetTrigger = SheetPrimitive.Trigger; - -const SheetClose = SheetPrimitive.Close; - -const SheetPortal = SheetPrimitive.Portal; - -const SheetOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; - -const sheetVariants = cva( - "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", - { - variants: { - side: { - top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", - bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", - left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", - right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", - }, - }, - defaultVariants: { - side: "right", - }, - }, -); - -interface SheetContentProps - extends React.ComponentPropsWithoutRef, - VariantProps { - /** - * Suppress the default close button so the caller can supply one that fits - * its own design. Use `SheetClose` to keep the dismiss behaviour. - */ - hideClose?: boolean; -} - -const SheetContent = React.forwardRef< - React.ElementRef, - SheetContentProps ->(({ side = "right", className, children, hideClose, ...props }, ref) => ( - - - - {children} - {!hideClose && ( - - - Close - - )} - - -)); -SheetContent.displayName = SheetPrimitive.Content.displayName; - -const SheetHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -SheetHeader.displayName = "SheetHeader"; - -const SheetFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -SheetFooter.displayName = "SheetFooter"; - -const SheetTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SheetTitle.displayName = SheetPrimitive.Title.displayName; - -const SheetDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SheetDescription.displayName = SheetPrimitive.Description.displayName; - -export { - Sheet, - SheetPortal, - SheetOverlay, - SheetTrigger, - SheetClose, - SheetContent, - SheetHeader, - SheetFooter, - SheetTitle, - SheetDescription, -}; diff --git a/webapp/tests/components/breadcrumb.test.tsx b/webapp/tests/components/breadcrumb.test.tsx deleted file mode 100644 index 6eac8ae3e..000000000 --- a/webapp/tests/components/breadcrumb.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import BreadcrumbHeader from "@/components/breadcrumb"; -import { renderWithRouter } from "../test-utils"; - -describe("BreadcrumbHeader", () => { - it("renders all segments and links the ones with hrefs", () => { - renderWithRouter( - , - ); - - const orgLink = screen.getByRole("link", { name: /org/i }); - expect(orgLink).toHaveAttribute("href", "/org-1"); - expect(screen.getByRole("link", { name: /projects/i })).toHaveAttribute( - "href", - "/org-1/projects", - ); - // Last segment (no href) is plain text, not a link. - expect(screen.getByText("Project A").tagName).toBe("SPAN"); - }); -}); diff --git a/webapp/tests/components/nav-item.test.tsx b/webapp/tests/components/nav-item.test.tsx deleted file mode 100644 index f3d57ddfb..000000000 --- a/webapp/tests/components/nav-item.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import NavItem from "@/components/nav-item"; - -describe("NavItem", () => { - it("renders children and fires onClick", async () => { - const onClick = vi.fn(); - render( - - Dashboard - , - ); - - const button = screen.getByRole("button", { name: /dashboard/i }); - await userEvent.click(button); - expect(onClick).toHaveBeenCalledOnce(); - }); - - it("applies the selected styling when isSelected is true", () => { - render(Home); - const button = screen.getByRole("button", { name: /home/i }); - expect(button.className).toContain("text-primary"); - }); -}); diff --git a/webapp/tests/components/navbar.test.tsx b/webapp/tests/components/navbar.test.tsx deleted file mode 100644 index e44c66ef8..000000000 --- a/webapp/tests/components/navbar.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -const navigateMock = vi.hoisted(() => vi.fn()); -vi.mock("react-router-dom", async () => { - const actual = - await vi.importActual( - "react-router-dom", - ); - return { - ...actual, - useNavigate: () => navigateMock, - useLocation: () => ({ pathname: "/o1" }), - }; -}); - -vi.mock("@/api/organizations", () => ({ - getOrganizations: vi.fn().mockResolvedValue([]), -})); - -import NavBar from "@/components/navbar"; -import { renderWithRouter } from "../test-utils"; - -beforeEach(() => { - navigateMock.mockReset(); -}); - -const orgs = [ - { id: "o1", name: "Acme", description: "" }, - { id: "o2", name: "Beta", description: "" }, -]; - -describe("NavBar", () => { - it("renders the primary nav items", () => { - renderWithRouter(); - expect( - screen.getByRole("button", { name: /home/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /projects/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /members/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /log out/i }), - ).toBeInTheDocument(); - }); - - it("navigates to the organization root when Home is clicked", async () => { - renderWithRouter(); - await userEvent.click(screen.getByRole("button", { name: /home/i })); - expect(navigateMock).toHaveBeenCalledWith("/o1"); - }); - - it("navigates to //projects when Projects is clicked", async () => { - renderWithRouter(); - await userEvent.click( - screen.getByRole("button", { name: /projects/i }), - ); - expect(navigateMock).toHaveBeenCalledWith("/o1/projects"); - }); -}); diff --git a/webapp/tests/components/radial-chart.test.tsx b/webapp/tests/components/radial-chart.test.tsx deleted file mode 100644 index 357b645d5..000000000 --- a/webapp/tests/components/radial-chart.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; - -class NoopResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} -// @ts-expect-error -- jsdom doesn't have it -globalThis.ResizeObserver = NoopResizeObserver; - -import RadialChart from "@/components/radial-chart"; - -describe("RadialChart", () => { - it("falls back to 'No data available' when value is undefined", () => { - render( - // @ts-expect-error -- testing the missing-value branch - , - ); - expect(screen.getByText(/no data available/i)).toBeInTheDocument(); - }); - - it("does not show the empty-state when value is defined", () => { - render(); - // The fallback is hidden when a value is provided. We don't assert - // on Recharts SVG output because jsdom doesn't measure layout, so - // the chart never reaches the rendered phase here. - expect( - screen.queryByText(/no data available/i), - ).not.toBeInTheDocument(); - }); -}); From a994ce8c3913e86ad28d82728a033e322e208d0f Mon Sep 17 00:00:00 2001 From: Satsh Date: Mon, 24 Aug 2026 23:45:41 +0200 Subject: [PATCH 6/8] feat(webapp): redesign the project details page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put the page on the redesigned shell — dark surface, breadcrumb, display title carrying the project's description and its visibility pill — and move the project's own actions into that heading as ProjectActions, so nothing about the project's identity is drawn from inside its panels. ProjectSettingsPage went with it: opening settings in place from a row made that page a duplicate of the dialog. Replace the three radial charts with the global dashboard's gauges and equivalences, both now shared components, and give the four charts one section treatment: a heading, a line of context, its export control, and no card. They sit in a cross of rules rather than in boxes, with the gaps as padding inside the cells so the rules meet. Rework the experiment controls: a section heading with the picker and "Add an experiment" beside it, the selected experiment's details in a field-styled panel, and the create-experiment dialog on the same panel, fields and buttons as the other two dialogs. Add the pixel-art refresh, download, share, logout, cog, padlock and overflow glyphs, and restyle the select, the toast-adjacent controls and the token form onto the redesign's fields and buttons. Cover what the redesign had left untested — the rail, the account menu, settings, the project row, the dialogs and the equivalences — taking the suite to 124 tests, and stub ResizeObserver so Radix components can mount under jsdom. Co-Authored-By: Claude Opus 5 --- webapp/src/api/schemas.ts | 1 - webapp/src/components/account-menu.tsx | 107 +---- webapp/src/components/chart-row.tsx | 55 +++ webapp/src/components/chart-section.tsx | 44 ++ .../src/components/consumed-energy-gauge.tsx | 55 +-- .../src/components/consumed-energy-gauges.tsx | 37 ++ .../components/create-experiment-modal.tsx | 221 ++++++++++ .../src/components/create-project-modal.tsx | 40 +- .../src/components/createExperimentModal.tsx | 215 ---------- .../src/components/emissions-time-series.tsx | 311 +++++++------- webapp/src/components/equivalence-list.tsx | 113 +++++ .../src/components/experiment-bar-chart.tsx | 93 ++-- webapp/src/components/export-csv-button.tsx | 16 +- .../src/components/icons/add-a-photo-icon.tsx | 22 - webapp/src/components/icons/download-icon.tsx | 24 ++ .../src/components/icons/exit-to-app-icon.tsx | 24 -- webapp/src/components/icons/lock-icon.tsx | 5 +- webapp/src/components/icons/logout-icon.tsx | 24 ++ .../src/components/icons/more-vert-icon.tsx | 4 +- webapp/src/components/icons/refresh-icon.tsx | 24 ++ webapp/src/components/icons/settings-icon.tsx | 7 +- webapp/src/components/icons/share-icon.tsx | 23 + webapp/src/components/project-actions.tsx | 216 ++++++++++ .../src/components/project-dashboard-base.tsx | 400 +++++++----------- webapp/src/components/project-dashboard.tsx | 271 ++---------- webapp/src/components/project-row.tsx | 16 +- .../src/components/project-settings-modal.tsx | 29 +- .../components/project-visibility-badge.tsx | 35 ++ .../projectTokens/projectTokenTable.tsx | 68 +-- webapp/src/components/runs-scatter-chart.tsx | 158 ++++--- .../src/components/share-project-button.tsx | 17 +- webapp/src/components/sidebar-rail.tsx | 68 +-- webapp/src/components/ui/dialog.tsx | 19 +- webapp/src/components/ui/icon-button.tsx | 39 ++ webapp/src/components/ui/menu.tsx | 2 +- webapp/src/components/ui/modal-header.tsx | 19 +- webapp/src/components/ui/select.tsx | 18 +- webapp/src/globals.css | 21 + webapp/src/helpers/constants.ts | 2 +- webapp/src/layouts/DashboardLayout.tsx | 26 +- webapp/src/pages/OrgDashboardPage.tsx | 124 +----- webapp/src/pages/ProjectDashboardPage.tsx | 90 +++- webapp/src/pages/ProjectsPage.tsx | 33 +- webapp/src/pages/SettingsPage.tsx | 24 +- webapp/tests/components/account-menu.test.tsx | 147 +++++++ .../components/consumed-energy-gauge.test.tsx | 32 ++ .../components/equivalence-list.test.tsx | 52 +++ .../tests/components/project-actions.test.tsx | 104 +++++ webapp/tests/components/project-row.test.tsx | 79 ++++ .../project-settings-modal.test.tsx | 177 ++++++++ .../project-visibility-badge.test.tsx | 16 + webapp/tests/components/sidebar-rail.test.tsx | 79 ++++ .../tests/components/ui/form-field.test.tsx | 20 + webapp/tests/pages/SettingsPage.test.tsx | 68 +++ webapp/tests/setup.ts | 18 + 55 files changed, 2364 insertions(+), 1588 deletions(-) create mode 100644 webapp/src/components/chart-row.tsx create mode 100644 webapp/src/components/chart-section.tsx create mode 100644 webapp/src/components/consumed-energy-gauges.tsx create mode 100644 webapp/src/components/create-experiment-modal.tsx delete mode 100644 webapp/src/components/createExperimentModal.tsx create mode 100644 webapp/src/components/equivalence-list.tsx delete mode 100644 webapp/src/components/icons/add-a-photo-icon.tsx create mode 100644 webapp/src/components/icons/download-icon.tsx delete mode 100644 webapp/src/components/icons/exit-to-app-icon.tsx create mode 100644 webapp/src/components/icons/logout-icon.tsx create mode 100644 webapp/src/components/icons/refresh-icon.tsx create mode 100644 webapp/src/components/icons/share-icon.tsx create mode 100644 webapp/src/components/project-actions.tsx create mode 100644 webapp/src/components/project-visibility-badge.tsx create mode 100644 webapp/src/components/ui/icon-button.tsx create mode 100644 webapp/tests/components/account-menu.test.tsx create mode 100644 webapp/tests/components/consumed-energy-gauge.test.tsx create mode 100644 webapp/tests/components/equivalence-list.test.tsx create mode 100644 webapp/tests/components/project-actions.test.tsx create mode 100644 webapp/tests/components/project-row.test.tsx create mode 100644 webapp/tests/components/project-settings-modal.test.tsx create mode 100644 webapp/tests/components/project-visibility-badge.test.tsx create mode 100644 webapp/tests/components/sidebar-rail.test.tsx create mode 100644 webapp/tests/components/ui/form-field.test.tsx create mode 100644 webapp/tests/pages/SettingsPage.test.tsx diff --git a/webapp/src/api/schemas.ts b/webapp/src/api/schemas.ts index 8eb6fbd1f..e70c6c9a7 100644 --- a/webapp/src/api/schemas.ts +++ b/webapp/src/api/schemas.ts @@ -204,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 index 29178bac7..7344cd173 100644 --- a/webapp/src/components/account-menu.tsx +++ b/webapp/src/components/account-menu.tsx @@ -8,52 +8,13 @@ import { fetcher } from "@/api/swr"; import { cn } from "@/helpers/utils"; import { useModal } from "@/hooks/useModal"; import CreateOrganizationModal from "./createOrganizationModal"; -import { ExitToAppIcon } from "./icons/exit-to-app-icon"; +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"; -/* - * The account menu, the subject of the design's "Changing organization from side - * bar". - * - * The panel is one of the few places where fixed dimensions are the right answer: - * its width and row height are the component's own size, not a position, so they - * live in the `menu` and `control` spacing tokens. Everything inside is a flex - * column whose spacing comes from padding and gaps, and the panel's height is - * whatever its rows add up to — it grows and shrinks with the real organization - * list rather than being pinned to the frame's 338px. - * - * Figma values: - * panel 203px wide, #181818, 2px #a0c55b border, 4px radius, - * drop-shadow 0 4px 10px rgba(0,0,0,0.25) - * heading Inter Medium 10px, centred, 15px vertical padding - * row 46px tall, 15px/8px padding, 4px gap, 24px avatar, - * IBM Plex Mono Medium 12px label - * rule 1px #949494 with 10px either side - * - * Anchoring is expressed as a relationship to the trigger rather than as frame - * coordinates. In the design the panel occupies x 49..252, y 621..959 and the - * "Account" item occupies x 9..93, y 959..1009, which means: - * - the panel's bottom edge sits on the trigger's top edge -> sideOffset 0 - * - the panel's left edge sits at the trigger's centre -> align start, - * shifted by half the trigger's width - * The second is taken from Radix's own trigger-width variable, so it holds if the - * rail's width or padding ever change. - * - * State: the menu is drawn open in Figma only to document its open appearance. - * It is closed by default here and driven by real `open` state. - * - * The highlighted "Mozilla" row in the frame is an instance override rather than - * a component variant — the Figma "menu item" component has a single "Default" - * variant, so the design defines exactly one highlight treatment and does not - * distinguish hover from selected. That one treatment is used for both here: it - * persistently marks the organization currently being viewed, and it is also the - * hover/focus state. - */ - export default function AccountMenu({ orgs, selectedOrg, @@ -67,46 +28,23 @@ export default function AccountMenu({ children: React.ReactNode; }>) { const [open, setOpen] = useState(false); - /* - * Latched on the first open, and never cleared. The admin lookup below is - * keyed on it rather than on `open` so that closing the menu does not drop the - * result: with the key gone, SWR reports no data, every dashboard falls back - * into the "invited" group, and the one the user administers visibly jumps up - * out of its section while the menu is animating out. - */ + // 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(); - /* - * Holds the list only after creating an organization, so the new one shows up - * without a reload. It deliberately starts undefined rather than mirroring - * `orgs`: `orgs` arrives empty on the first render while SWR is still - * fetching, and seeding state from it would freeze the menu on that empty - * array. Until a refresh happens the live prop is used. - */ const [organizationList, setOrganizationList] = useState< Organization[] | undefined >(undefined); - // The user's real name for the profile row. Figma shows a person's name and - // photograph here; "Inimaz" is demo content and is not hardcoded. This uses - // the endpoint AuthGuard already relies on, so no new API surface is added. const { data: auth } = useSWR<{ user?: User }>("/auth/check", fetcher, { revalidateOnFocus: false, }); const list = organizationList ?? orgs; - /* - * Dashboards the user administers go below the rule; the rest are ones they - * were invited to. Admin rights live on the membership, and the API exposes - * them only per organization via its member list (`is_admin` on - * `GET /organizations/{id}/users`), so this asks each organization in turn. - * - * Deferred until the menu is first opened, to keep it off the page-load path, - * and kept from then on — see `hasOpened`. Until it resolves every dashboard - * sits in the invited section, which is how the menu behaved before the split. - */ + // 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 @@ -157,18 +95,8 @@ export default function AccountMenu({ side="top" align="start" sideOffset={0} - /* - * Collision handling stays on so the panel is nudged back - * on-screen when the trigger sits near an edge — which it - * does on the mobile bottom bar, where Account is the - * right-most item. On the desktop rail there is nothing to - * collide with, so the Figma placement is unaffected. - */ collisionPadding={8} className={cn( - // Left edge at the trigger's centre, which is the rail's - // placement. Not applied on the bottom bar, where it - // would push the panel off the right of the screen. "md:ml-[calc(var(--radix-dropdown-menu-trigger-width)/2)]", )} > @@ -184,50 +112,35 @@ export default function AccountMenu({ isCurrent={org.id === selectedOrg} onSelect={() => onSelectOrg(org.id)} icon={ - + } > {org.name} ))} - {/* - * Not present in the Figma frame, but the organization - * switcher it replaces owned the only entry point to - * creating an organization. Removing it would delete - * working functionality, so it is kept here using the - * design's own row styling. - */} newOrgModal.open()} - icon={} + icon={} > Add new organization - {/* A rule with 10px of space either side. */}
- {/* - * The dashboards the user administers — their own. Figma - * The design shows a person's name here. They select like any - * other dashboard and highlight when one is being viewed. - */} {owned.map((org) => ( onSelectOrg(org.id)} icon={ - + } > {org.name} ))} - {/* Sits between the profile row and - "Log out", as the design orders them. */} navigate("/settings")} icon={} @@ -239,7 +152,9 @@ export default function AccountMenu({ onSelect={() => { window.location.href = `${import.meta.env.VITE_API_URL}/auth/logout`; }} - icon={} + icon={ + + } > Log out 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 index b21d0a81c..0420f299e 100644 --- a/webapp/src/components/consumed-energy-gauge.tsx +++ b/webapp/src/components/consumed-energy-gauge.tsx @@ -1,35 +1,15 @@ import { cn } from "@/helpers/utils"; /* - * The "Consumed energy" gauge from the design. + * 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 whole gauge is one SVG, so its geometry lives in the viewBox rather than - * in CSS offsets: the ring, the value and the caption are all placed in the - * design's own coordinate space, exactly as Figma specifies them. That keeps the - * numbers where they are meaningful (vector coordinates) instead of turning them - * into layout margins, and — because the SVG scales to its box rather than - * carrying a fixed pixel size — it lets the parent shrink the gauge on narrow - * screens. - * - * Figma values, identical for all three gauges: - * viewBox 199.23 x 199.23 - * ring cx = cy = 99.6152, r = 87.0511, 25.1281 stroke - * track #3B4041 - * value arc #BFFB4F, butt caps, sweeping anti-clockwise from 12 o'clock - * value Disket Mono Bold 26, #BFFB4F, at 40.384 / 75.384 - * caption IBM Plex Mono Medium 13.343, #8A8A8A, at 43.974 / 115.769 - * - * The arc is a fixed decorative sweep, not a proportion of the value. That is - * deliberate and matches what this dashboard has always drawn: the previous - * recharts gauges swept a constant 100 degrees regardless of their data, and - * Figma gives the three gauges different arbitrary sweeps even where two show the - * same number. 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. - * - * What changed from the old gauges is presentation only: the arc was rendering - * black (its fill never resolved), and it started at 3 o'clock. It is now the - * design's lime and starts at 12 o'clock. + * 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; @@ -83,12 +63,19 @@ export default function ConsumedEnergyGauge({ stroke="var(--cc-gauge-track)" strokeWidth={STROKE} /> - + {/* + * The arc is decorative, so it stands for "there is something here" + * rather than for a proportion: at zero there is nothing to mark and + * the ring is left as bare track. + */} + {value > 0 && ( + + )} {/* * Figma left-aligns both labels from their own x offsets, with the * value's baseline box starting at y 75.384 and the caption's at 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 ( + + + {!isCreated ? ( + <> + + +
{ + event.preventDefault(); + handleSave(); + }} + > + + setExperimentData({ + ...experimentData, + name: e.target.value, + }) + } + /> + + + setExperimentData({ + ...experimentData, + description: e.target.value, + }) + } + /> + +
+ + {isSaving && ( + + )} + {isSaving + ? "Creating..." + : "Create experiment"} + +
+ + + ) : ( + <> + + +
+
+

+ Id of this experiment +

+
+ + {createdExperiment?.id} + + + handleCopy(createdExperiment?.id) + } + > + {isCopied ? ( + + ) : ( + + )} + +
+
+ +
+ + Done + +
+
+ + )} +
+
+ ); +} diff --git a/webapp/src/components/create-project-modal.tsx b/webapp/src/components/create-project-modal.tsx index c1c9f2a43..47ee0b7b2 100644 --- a/webapp/src/components/create-project-modal.tsx +++ b/webapp/src/components/create-project-modal.tsx @@ -8,42 +8,12 @@ import { FormField } from "./ui/form-field"; import { PrimaryButton } from "./ui/primary-button"; /* - * Create a project. + * Create a project: a dialog holding a name and a description. * - * Proportions are taken from the design rather than copied off it. Its fixed - * 664x524 panel cannot be reproduced as a size — its own numbers do not add up, - * with the form starting below where the panel ends — but its proportion, a little - * wider than tall, is what makes it read as a dialog rather than as a bar. So the - * panel is built to land near it: - * - * - the maximum width is 560, not 664. Reproducing 664 against a height that - * follows the content gave a distinctly wider box; pulling the width in - * recovers the near-square proportion, and it costs nothing because the form - * is two single-line fields - * - the title stays at display size while the box around it narrows, at 24px - * rather than the design's 32 - * - the vertical padding is generous, but bounded by how it looks rather than by - * the ratio it produces. Pushed far enough to force the design's exact - * proportion it just looks empty, so the panel sits a little wider than tall - * - * The design's two horizontal measurements disagree — the title row is inset 33px - * from the panel edge while the form is a 466px column centred in the 664px panel, - * 99px either side. They are normalised to a single gutter here, as the redesigned - * pages do, so the title and the fields share one left edge instead of stepping in - * twice. - * - * Below its maximum the panel narrows, and `DialogContent` scrolls it if it ever - * exceeds the viewport, so the modal is usable on a phone. - * - * The design's ✕ is its "add" glyph rotated 45 degrees — that is how the file - * builds it — so it renders as the same `PlusIcon` under a `rotate-45`, rather - * than as a second asset that would have to stay in step with the first. - * - * Two things in the design were confirmed as component defaults rather than - * intent: the envelope icon inside the Name field (its Input component was built - * for an email address, and the Description field below it has no icon), which is - * dropped; and the absence of a Cancel button, which is kept — the ✕, the Esc key - * and a click outside all still close the dialog. + * 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 { 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 ( - - - {!isCreated ? ( - <> - - Create new experiment - - -
-
- - - setExperimentData({ - ...experimentData, - name: e.target.value, - }) - } - placeholder="Experiment Name" - /> -
-
- - - setExperimentData({ - ...experimentData, - description: e.target.value, - }) - } - placeholder="Experiment Description" - /> -
-
- - - - - ) : ( - <> - - - Experiment {createdExperiment?.name} Created - - - -
-

Id of this experiment:

-
-
-                                    {createdExperiment?.id}
-                                
- -
-
- - - - - )} -
-
- ); -} diff --git a/webapp/src/components/emissions-time-series.tsx b/webapp/src/components/emissions-time-series.tsx index 4d5ad94ce..084fa341d 100644 --- a/webapp/src/components/emissions-time-series.tsx +++ b/webapp/src/components/emissions-time-series.tsx @@ -1,10 +1,3 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { ChartConfig, ChartContainer, @@ -16,6 +9,8 @@ import * as React from "react"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import { ExportCsvButton } from "@/components/export-csv-button"; +import ChartRow from "@/components/chart-row"; +import ChartSection from "@/components/chart-section"; import ChartSkeleton from "@/components/chart-skeleton"; import { getEmissionsTimeSeries } from "@/api/runs"; import { exportEmissionsTimeSeriesCsv } from "@/utils/export"; @@ -104,171 +99,155 @@ export default function EmissionsTimeSeriesChart({ const tickFmt = pickTimeFormat(spanMs); return ( -
- - - Run Metadata - - Hardware and environment details - - - -
-
- - CPU: - - {emissionTimeSeries.metadata.cpu_model} ( - {emissionTimeSeries.metadata.cpu_count} cores) - -
- {emissionTimeSeries.metadata.gpu_model && ( -
- - GPU: - - {emissionTimeSeries.metadata.gpu_model} ( - {emissionTimeSeries.metadata.gpu_count}) - -
- )} + + { + if (!emissionTimeSeries) return; + exportEmissionsTimeSeriesCsv( + emissionTimeSeries, + projectName, + experimentName, + ); + }} + loadingMessage="Exporting time series..." + successMessage="Time series exported successfully" + errorMessage="Failed to export time series" + /> + ) + } + > +
+ {Object.keys(chartConfig).map((key) => { + const chart = key as keyof typeof chartConfig; + return ( + + ); + })} +
+ + + + + format(new Date(value), tickFmt) + } + /> + + { + const tooltipPayload = payload as + | TimeSeriesTooltipPayload + | undefined; + const point = + tooltipPayload?.[0]?.payload; + + if (!point) { + return ""; + } + + return format( + new Date(point.ts), + "MMM d, yyyy HH:mm:ss", + ); + }} + /> + } + /> + + + +
+ +
+
+ + CPU: + + {emissionTimeSeries.metadata.cpu_model} ( + {emissionTimeSeries.metadata.cpu_count} cores) + +
+ {emissionTimeSeries.metadata.gpu_model && (
- - RAM: + + GPU: - {emissionTimeSeries.metadata.ram_total_size} GB + {emissionTimeSeries.metadata.gpu_model} ( + {emissionTimeSeries.metadata.gpu_count})
-
- OS:{" "} - {emissionTimeSeries.metadata.os} -
-
- Python:{" "} - {emissionTimeSeries.metadata.python_version} -
-
- Region:{" "} - {emissionTimeSeries.metadata.region} -
+ )} +
+ + RAM: + + {emissionTimeSeries.metadata.ram_total_size} GB +
- - - - -
-
-
- Emissions Time Series - - Showing emissions rate and energy consumed - over time - -
- {!isPublicView && ( - { - if (!emissionTimeSeries) return; - exportEmissionsTimeSeriesCsv( - emissionTimeSeries, - projectName, - experimentName, - ); - }} - loadingMessage="Exporting time series..." - successMessage="Time series exported successfully" - errorMessage="Failed to export time series" - /> - )} -
+
+ OS:{" "} + {emissionTimeSeries.metadata.os}
-
- {Object.keys(chartConfig).map((key) => { - const chart = key as keyof typeof chartConfig; - return ( - - ); - })} +
+ Python:{" "} + {emissionTimeSeries.metadata.python_version}
- - - - - - - format(new Date(value), tickFmt) - } - /> - - { - const tooltipPayload = payload as - | TimeSeriesTooltipPayload - | undefined; - const point = - tooltipPayload?.[0]?.payload; - - if (!point) { - return ""; - } - - return format( - new Date(point.ts), - "MMM d, yyyy HH:mm:ss", - ); - }} - /> - } - /> - - - - - -
+
+ Region:{" "} + {emissionTimeSeries.metadata.region} +
+
+ + ); } 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.alt} +
    +

    + {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 - -
- {!isPublicView && ( + + ) + } + > + + {experimentsReportData.length > 0 ? ( + + + value.slice(0, 3)} + /> + } + /> + } + radius={4} + /> + + ) : ( +
+

+ No data available +

+
)} -
- - - {experimentsReportData.length > 0 ? ( - - - value.slice(0, 3)} - /> - - } - /> - } - radius={4} - /> - - ) : ( -
-

- No data available -

-
- )} -
-
-
+ + ); } 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/add-a-photo-icon.tsx b/webapp/src/components/icons/add-a-photo-icon.tsx deleted file mode 100644 index b073be9e4..000000000 --- a/webapp/src/components/icons/add-a-photo-icon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { FigmaIconProps } from "./types"; - -/* - * Add-a-photo icon — a camera with a plus sign at its corner. - */ - -export function AddAPhotoIcon({ 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/exit-to-app-icon.tsx b/webapp/src/components/icons/exit-to-app-icon.tsx deleted file mode 100644 index eef2325e0..000000000 --- a/webapp/src/components/icons/exit-to-app-icon.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FigmaIconProps } from "./types"; - -/* - * Log-out icon — an arrow leaving through an open door. - */ - -export function ExitToAppIcon({ className }: FigmaIconProps) { - return ( - - ); -} diff --git a/webapp/src/components/icons/lock-icon.tsx b/webapp/src/components/icons/lock-icon.tsx index 2dba40027..04ab6c302 100644 --- a/webapp/src/components/icons/lock-icon.tsx +++ b/webapp/src/components/icons/lock-icon.tsx @@ -1,7 +1,8 @@ import { FigmaIconProps } from "./types"; /* - * Lock icon — a closed padlock. + * Lock icon — a closed padlock, drawn in the same pixel-art style as the rail's + * icons. */ export function LockIcon({ className }: FigmaIconProps) { @@ -16,7 +17,7 @@ export function LockIcon({ className }: FigmaIconProps) { focusable="false" className={className} > - + ); } 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/more-vert-icon.tsx b/webapp/src/components/icons/more-vert-icon.tsx index 0abbc0f4e..cf3b0ecb9 100644 --- a/webapp/src/components/icons/more-vert-icon.tsx +++ b/webapp/src/components/icons/more-vert-icon.tsx @@ -1,7 +1,7 @@ import { FigmaIconProps } from "./types"; /* - * Vertical more icon — three dots in a vertical line. + * Vertical more icon — three dots in a vertical line, pixel-art style. */ export function MoreVertIcon({ className }: FigmaIconProps) { @@ -16,7 +16,7 @@ export function MoreVertIcon({ className }: FigmaIconProps) { focusable="false" className={className} > - + ); } 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 index 8f8bd7304..b5c6663f8 100644 --- a/webapp/src/components/icons/settings-icon.tsx +++ b/webapp/src/components/icons/settings-icon.tsx @@ -1,7 +1,7 @@ import { FigmaIconProps } from "./types"; /* - * Settings icon — a cogwheel. + * Settings icon — a cog, drawn in the same pixel-art style as the rail's icons. */ export function SettingsIcon({ className }: FigmaIconProps) { @@ -16,9 +16,8 @@ export function SettingsIcon({ className }: FigmaIconProps) { focusable="false" className={className} > - - - + + ); } 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/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. + */ +
+
+ {headerContent} +
onDateChange(newDate || getDefaultDateRange()) @@ -128,234 +126,145 @@ export default function ProjectDashboardBase({ />
-
-
- {isLoading ? ( - <> - - - - - ) : ( - <> -
-
- Household consumption icon -
-
-

- {convertedValues.citizen} % -

-

- Of a U.S citizen weekly energy emissions -

-
-
-
-
- Transportation icon -
-
-

- {convertedValues.transportation} -

-

- Kilometers ridden -

-
-
-
-
- TV icon -
-
-

- {convertedValues.tvTime} days -

-

- Of watching TV -

-
-
- - )} -
-
- {isLoading ? ( - - - - - - ) : ( - - - - - - } - > - - - )} -
-
- {isLoading ? ( - - - - - - ) : ( - - - - - - } - > - - - )} -
-
+
+ {isLoading ? ( +
+ + + +
+ ) : ( + + )} + +
+

+ Consumed energy +

{isLoading ? ( - - - - - +
+ + + +
) : ( - - - - - - } - > - - + )} -
+
- - -
-

- {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."}

+ )} + +
+ {projectExperiments.length !== 0 && ( +
+ + {selectedExperiment && ( +
+ {selectedExperiment.description && ( +

+ {selectedExperiment.description} +

+ )} + {!isPublicView && ( +
+ + Experiment id + + + {selectedExperiment.id} + + +
+ )} +
+ )} +
+ )} + {!isPublicView && ( -
- + + Add an experiment + setIsExperimentModalOpen(false)} onExperimentCreated={onExperimentCreated} /> -
+ )}
- {projectExperiments.length !== 0 && ( -
- - {selectedExperiment && ( - - {selectedExperiment.description && ( -

- {selectedExperiment.description} -

- )} - {!isPublicView && ( -
- - Experiment id - - - {selectedExperiment.id} - - -
- )} -
- )} -
- )} - -
+
+ {isLoading ? ( <> @@ -386,25 +295,22 @@ export default function ProjectDashboardBase({ )} -
+ {selectedRunId && selectedRunId != "" && ( - <> - -
- {isLoading ? ( - - ) : ( - }> - - - )} -
- +
+ {isLoading ? ( + + ) : ( + }> + + + )} +
)}
); 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 index 022b04507..0d025e923 100644 --- a/webapp/src/components/project-row.tsx +++ b/webapp/src/components/project-row.tsx @@ -11,18 +11,10 @@ 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. * - * The name and the secondary text are both links to the project, and each fills - * its cell — the row's vertical padding is on the links, so the whole height and - * width of a cell is a click target rather than just the glyphs. - * - * The two light together on hover, because they are one item rather than two - * independently hoverable cells. The actions cell is excluded from that: its - * trigger is a small glyph in a padded cell, so if the cell lit the row along - * with it there would be no way to tell whether the pointer is on the button or - * merely near it. The trigger keeps its own hover, and that is the only thing - * reporting its hitbox — which is also why an open menu lights the button alone. - * - * Focus stays per-control, since focus really is on one thing at a time. + * 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. */ diff --git a/webapp/src/components/project-settings-modal.tsx b/webapp/src/components/project-settings-modal.tsx index dfed47e71..d7025e24f 100644 --- a/webapp/src/components/project-settings-modal.tsx +++ b/webapp/src/components/project-settings-modal.tsx @@ -15,29 +15,14 @@ import { TabNavList, TabNavTrigger } from "./ui/tab-nav"; import { Tabs, TabsContent } from "./ui/tabs"; /* - * Project settings. + * 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 panel, the title, the fields and the primary button are the Create-project - * modal's — the two dialogs are the same object in the design system, so they - * share the same components rather than each describing the treatment. This one is - * wider because it holds the API-tokens table, and that width is the only - * dimension it sets for itself. - * - * The design has no frame for this dialog, so nothing here is traced: it is the - * redesign's own vocabulary applied to the controls the dialog already had. - * - * Two things are deliberately untouched, since they are not redesigned yet: the - * `Switch` and the API-tokens table. Both are shared with screens outside this - * dialog, so restyling them belongs to their own pass. - * - * Two things save independently. "Save changes" writes the fields and closes the - * dialog, as it did before. The public toggle writes itself the moment it is - * flipped, which is what lets the sharing link it controls appear and disappear - * with it — a link is not something to hold in a draft state, since it either - * works or it does not. - * - * Only a failed submit keeps the dialog open, so edits that did not save are still - * there to retry rather than being discarded. + * 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 { diff --git a/webapp/src/components/project-visibility-badge.tsx b/webapp/src/components/project-visibility-badge.tsx new file mode 100644 index 000000000..9eaf33520 --- /dev/null +++ b/webapp/src/components/project-visibility-badge.tsx @@ -0,0 +1,35 @@ +import { LockIcon } from "./icons/lock-icon"; +import { ShareIcon } from "./icons/share-icon"; +import { Badge } from "./ui/badge"; + +/* + * Whether a project is visible to anyone with its link, shown beside its name. + * + * Its own module rather than an export of the project dashboard, so the page + * heading can show it without importing from the panels below it. + * + * The glyphs are the redesign's own; the pill around them is still the badge as + * it was. They are drawn a little larger than the 12px the old icons used — + * pixel-art strokes need the room to stay legible at this size. + */ +export default function ProjectVisibilityBadge({ + isPublic, +}: Readonly<{ isPublic: boolean }>) { + return isPublic ? ( + + + Public + + ) : ( + + + Private + + ); +} diff --git a/webapp/src/components/projectTokens/projectTokenTable.tsx b/webapp/src/components/projectTokens/projectTokenTable.tsx index 7f3156f67..ec4517365 100644 --- a/webapp/src/components/projectTokens/projectTokenTable.tsx +++ b/webapp/src/components/projectTokens/projectTokenTable.tsx @@ -7,8 +7,9 @@ import { useMemo, useState, useEffect, useRef } from "react"; import { Loader2, ClipboardCopy, ClipboardCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PlusIcon } from "@/components/icons/plus-icon"; +import { FormField } from "@/components/ui/form-field"; import { PrimaryButton } from "@/components/ui/primary-button"; -import { Input } from "@/components/ui/input"; +import { SecondaryButton } from "@/components/ui/secondary-button"; import { toast } from "sonner"; import copy from "copy-to-clipboard"; @@ -107,9 +108,8 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => {
{!isCreatingToken && !createdToken ? ( - /* The redesign's primary action. The rest of this table is - not restyled yet, so this is the one control that follows - the new treatment for now. */ + /* The table itself is not restyled yet; the controls around + it follow the redesign. */ setIsCreatingToken(true)} className="gap-4" @@ -158,36 +158,44 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => {

Create new token

-
- { + event.preventDefault(); + handleCreateToken(); + }} + > + setTokenName(e.target.value)} - placeholder="Token Name" - className="flex-grow" disabled={isSubmitting} + containerClassName="min-w-0 flex-1" /> - - -
+
+ + {isSubmitting && ( + + )} + {isSubmitting ? "Creating..." : "Create"} + + setIsCreatingToken(false)} + disabled={isSubmitting} + > + Cancel + +
+ )}
diff --git a/webapp/src/components/runs-scatter-chart.tsx b/webapp/src/components/runs-scatter-chart.tsx index ad2a0594a..50b127a0a 100644 --- a/webapp/src/components/runs-scatter-chart.tsx +++ b/webapp/src/components/runs-scatter-chart.tsx @@ -2,17 +2,11 @@ import { RunReport } from "@/api/schemas"; import { format } from "date-fns"; import { Label, Scatter, ScatterChart, Tooltip, XAxis, YAxis } from "recharts"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { getRunEmissionsByExperiment } from "@/api/runs"; import { exportRunsToCsv } from "@/utils/export"; import { pickTimeFormat } from "@/helpers/time-axis"; import { useEffect, useMemo, useState } from "react"; +import ChartSection from "./chart-section"; import ChartSkeleton from "./chart-skeleton"; import { ExportCsvButton } from "./export-csv-button"; import { ChartConfig, ChartContainer } from "./ui/chart"; @@ -123,15 +117,11 @@ export default function RunsScatterChart({ } return ( - - -
- Scatter Chart - Emissions by Run Id - - Click a run to see time series - -
- {!isPublicView && ( + - )} -
- - - {runsReportsData.length > 0 ? ( - + + {runsReportsData.length > 0 ? ( + + + format(new Date(value), tickFmt) + } > - - format(new Date(value), tickFmt) - } - > - - - - } /> - onRunClick(data.runId)} - cursor="pointer" + - ) : ( -
-

- No data available -

-
- )} -
-
-
+ + + {/* + * `insideLeft` anchors at the top of the axis and the + * rotation pivots on that anchor, so the label hangs + * from the top unless it is told to anchor on its own + * middle. + */} + + } /> + onRunClick(data.runId)} + cursor="pointer" + /> + + ) : ( +
+

+ No data available +

+
+ )} + + ); } diff --git a/webapp/src/components/share-project-button.tsx b/webapp/src/components/share-project-button.tsx index 4b8f61685..a9212f6d5 100644 --- a/webapp/src/components/share-project-button.tsx +++ b/webapp/src/components/share-project-button.tsx @@ -1,5 +1,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { ShareIcon } from "@/components/icons/share-icon"; +import { IconButton } from "@/components/ui/icon-button"; import { SecondaryButton } from "@/components/ui/secondary-button"; import { Popover, @@ -7,7 +9,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import copy from "copy-to-clipboard"; -import { CheckIcon, CopyIcon, Share2Icon } from "lucide-react"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -62,18 +64,13 @@ export default function ShareProjectButton({ {trigger === "labelled" ? ( - + Copy link ) : ( - + + + )} diff --git a/webapp/src/components/sidebar-rail.tsx b/webapp/src/components/sidebar-rail.tsx index 16301e292..ca04b8d09 100644 --- a/webapp/src/components/sidebar-rail.tsx +++ b/webapp/src/components/sidebar-rail.tsx @@ -11,65 +11,19 @@ import { AccountIcon } from "./icons/account-icon"; import { MembersIcon } from "./icons/members-icon"; /* - * The "Side Menu closed" rail from the design. + * The app's primary navigation: the destinations stacked at the top of a + * vertical rail with the account item pinned to the bottom, and the same items + * as a bottom bar below `md`. The rail's width lives in the `rail` token. * - * Figma positions the rail's groups as absolutely placed children with - * percentage insets, but they are structurally just a column: the destinations - * stacked at the top, then the account item at the bottom. That is what this - * builds — two flex children, with `md:mt-auto` on the account wrapper — so the - * rail's own padding and gaps produce the design's spacing instead of - * coordinates doing it. - * - * The rail's width is its own dimension and lives in one place: the `rail` - * spacing token. Nothing else in the app repeats it. - * - * Below `md` the same items render as a bottom navigation bar rather than a - * rail, so this one component is the whole of the app's primary navigation: the - * `nav` flips to a row and the destination group collapses to `display: - * contents` so all four items space evenly across it. Icons and labels step down - * (32 -> 24px icons, and `type-rail-label` is 12px below `md` against 14px - * above). See `DashboardLayout` for how the two presentations are placed. - * - * The frame draws a hamburger above the logo. Neither is rendered here: the - * navigation is always visible in both presentations, so a control to reveal it - * has nothing to do. (Confirmed with the project owners as a slip in the frame.) - * Because nothing sits above the destinations, the rail's items begin directly - * below its top padding rather than at the frame's y offsets. - * - * Figma spacing, mapped to the scale: - * rail padding 24px top (2.44%), 16px bottom (1.46%), 16px sides (16.83%) - * nav gap 24px - * nav item 10px padding, 32px icon, 8px gap, 14px label - * account 8px gap, 14px label - * - * The account item departs from the frame in one respect: the design draws a - * 24px avatar, and this renders the 32px account icon so it matches the size of - * every other item in the rail. - * - * The design also carries a fixed 228px height on its nav group that is smaller - * than the group's own content (3x78 + 2x24 = 282px). Figma lets it overflow, so - * the height is left to the content here, which is what the frame renders. - * - * Selected state: the icon and label turn #BFFB4F (Global, in this frame); - * unselected items are #FFFFFF. Both use the same vector with a colour change. - * - * Hover and pressed states are not drawn in the frame. They are built from the - * palette the design already uses: hover goes to #a0c55b (the design's own - * "Button-hover" token) and pressing goes to #BFFB4F, the selected colour. The - * icons take their fill from `currentColor`, so colouring the control moves the - * icon and its label together with no extra rules. + * Icons take their fill from `currentColor`, so colouring a control moves its + * icon and label together — green for the page you are on, white otherwise. */ /* * A control in the rail: an icon above its label, green when it is the page you * are on and white otherwise. * - * Hover and pressed are not drawn in the design, so they are built from the - * palette it already uses — hover goes to its own "Button-hover" (#a0c55b) and - * pressing to the selected green. The icons take their fill from `currentColor`, - * so colouring the control moves the icon and its label together. - * - * It renders a plain button and forwards its props, so the destinations use it for + * A plain button that forwards its props, so the destinations use it for * navigation and the account control uses it as a menu trigger. */ const RailButton = React.forwardRef< @@ -227,12 +181,10 @@ export default function SidebarRail({
{/* - * Account item, pinned to the bottom of - * the rail. Triggers the account menu, which opens upward from it. - * - * The `mt-auto` and the minimum separation live on this wrapper, not - * on the button: padding inside the trigger would enlarge its hit - * area and drag the popover's anchor edge up with it. + * Account item, pinned to the bottom. The `mt-auto` and the + * separation live on this wrapper rather than on the button: + * padding inside the trigger would enlarge its hit area and drag + * the popover's anchor edge up with it. */}
, diff --git a/webapp/src/components/ui/icon-button.tsx b/webapp/src/components/ui/icon-button.tsx new file mode 100644 index 000000000..8cf3917d3 --- /dev/null +++ b/webapp/src/components/ui/icon-button.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; + +import { cn } from "@/helpers/utils"; +import { SecondaryButton } from "./secondary-button"; + +/* + * An icon-only action: the outlined button, square and sized to its glyph. + * + * It renders through `SecondaryButton` rather than restating its treatment, so + * the hover to #a0c55b and the pressed green stay in one place — this makes it + * square, drops the text padding, and quiets it at rest. The 2px radius comes + * with it, which is the radius every other control in the app carries. + * + * At rest the glyph is the design's "Gray" (#949494) — the fill it gives secondary + * icons — inside a #464646 outline, rather than the white a labelled button uses. + * These sit beside a page heading and are not what the eye should land on first; + * the hover is what makes them findable. + * + * Callers must give it an `aria-label`: there is no text to name it. + */ +type IconButtonProps = React.ComponentPropsWithoutRef< + typeof SecondaryButton +> & { + "aria-label": string; +}; + +export const IconButton = React.forwardRef( + ({ className, ...props }, ref) => ( + + ), +); +IconButton.displayName = "IconButton"; diff --git a/webapp/src/components/ui/menu.tsx b/webapp/src/components/ui/menu.tsx index c0d838021..87c06f574 100644 --- a/webapp/src/components/ui/menu.tsx +++ b/webapp/src/components/ui/menu.tsx @@ -53,7 +53,7 @@ export const MenuItem = React.forwardRef< ref={ref} aria-current={isCurrent ? "true" : undefined} className={cn( - "group min-h-control w-full cursor-pointer select-none items-center gap-1 rounded-none px-4 py-2 outline-none", + "group min-h-control w-full cursor-pointer select-none items-center gap-3 rounded-none px-4 py-2 outline-none", // Only the icons take this; the label sets its own colour below. "border-y-0 border-l-0 border-r-0 border-transparent text-cc-gray", "focus:border-cc-button-hover focus:bg-cc-darkest-gray focus:text-cc-button-hover", diff --git a/webapp/src/components/ui/modal-header.tsx b/webapp/src/components/ui/modal-header.tsx index e38e35a5b..affbfd488 100644 --- a/webapp/src/components/ui/modal-header.tsx +++ b/webapp/src/components/ui/modal-header.tsx @@ -3,20 +3,13 @@ import { DialogClose, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { cn } from "@/helpers/utils"; /* - * A dialog's header, as the design draws it: the title in the display face with - * the close control beside it, above the rule that separates it from the body. + * A dialog's header: the title in the display face with the close control + * beside it, above the rule that separates it from the body. Dialogs using this + * pass `hideClose` to `DialogContent`, since this is their close control. * - * The ✕ is the design's "add" glyph rotated 45 degrees — that is how the file - * builds it — so it renders as the same `PlusIcon` under a `rotate-45`, rather - * than a second asset that would have to stay in step with the first. Dialogs - * using this header pass `hideClose` to `DialogContent`, since this is their close - * control. - * - * The title's size is set here with utilities rather than through a `type-*` - * class, deliberately: `DialogTitle` ships a `text-lg` of its own, and because - * `twMerge` cannot tell that a project class is a font-size it leaves both in - * place — then `text-lg` wins, being a utility, and the title renders at 18px. - * Naming the size as a utility is what lets the merge drop the one it replaces. + * The title's size is a utility rather than a `type-*` class deliberately: + * `DialogTitle` ships its own `text-lg`, which `twMerge` cannot tell is the + * same property as a project class, so both survive and the utility wins. */ export default function ModalHeader({ title, diff --git a/webapp/src/components/ui/select.tsx b/webapp/src/components/ui/select.tsx index 14fcaa419..81d077454 100644 --- a/webapp/src/components/ui/select.tsx +++ b/webapp/src/components/ui/select.tsx @@ -19,14 +19,19 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "type-mono-regular type-field flex h-control w-full cursor-pointer items-center justify-between gap-2", + "rounded-field bg-white/5 px-4 text-cc-white outline-none transition-colors", + "hover:text-cc-button-hover", + "focus-visible:ring-2 focus-visible:ring-cc-lime", + "disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + "motion-reduce:transition-none", className, )} {...props} > {children} - + )); @@ -75,7 +80,9 @@ const SelectContent = React.forwardRef< { return ( diff --git a/webapp/src/layouts/DashboardLayout.tsx b/webapp/src/layouts/DashboardLayout.tsx index b20444d89..f0dfecf08 100644 --- a/webapp/src/layouts/DashboardLayout.tsx +++ b/webapp/src/layouts/DashboardLayout.tsx @@ -8,25 +8,15 @@ import Loader from "@/components/loader"; import SidebarRail from "@/components/sidebar-rail"; /* - * Shell for the redesigned dashboard. + * Shell for the redesigned dashboard: exactly one viewport tall, with the rail + * and the content area as siblings in a row and the content column owning the + * only scroll. * - * The design is an application viewport rather than a scrolling document: the - * rail runs the full height with its "Account" item at the bottom, and the page - * content fits without the page scrolling. So the shell is exactly one viewport - * tall and distributes that height structurally — the rail and the content area - * are siblings in a row, and the content column owns the only scroll. - * - * The `min-h-0` / `min-w-0` on the flex children is what makes that work: without - * them the content's intrinsic size would stretch the shell and push the rail's - * Account item off-screen. Nothing here subtracts the rail's width; the rail - * declares its own width and the content takes what is left via `flex-1`. - * - * One navigation, two presentations, from a single `SidebarRail` instance: a - * vertical rail beside the content from `md` up, and a bottom bar beneath it - * below that. `flex-col-reverse` is what places it — the rail stays first in the - * DOM, so it keeps its natural reading and tab order, while appearing last on - * screen. Being in normal flow, the bar takes its own space instead of covering - * the content. + * The `min-h-0` / `min-w-0` on the flex children is what makes that work — + * without them the content's intrinsic size stretches the shell and pushes the + * rail's Account item off-screen. `flex-col-reverse` puts the mobile bar below + * the content while leaving the rail first in the DOM, so it keeps its reading + * and tab order. */ export default function DashboardLayout() { const [initialLoad, setInitialLoad] = useState(true); diff --git a/webapp/src/pages/OrgDashboardPage.tsx b/webapp/src/pages/OrgDashboardPage.tsx index b2300163d..220700d16 100644 --- a/webapp/src/pages/OrgDashboardPage.tsx +++ b/webapp/src/pages/OrgDashboardPage.tsx @@ -3,9 +3,9 @@ import { useParams } from "react-router-dom"; import ErrorMessage from "@/components/error-message"; import Loader from "@/components/loader"; -import { AddAPhotoIcon } from "@/components/icons/add-a-photo-icon"; import { DateRangePicker } from "@/components/date-range-picker"; -import ConsumedEnergyGauge from "@/components/consumed-energy-gauge"; +import ConsumedEnergyGauges from "@/components/consumed-energy-gauges"; +import EquivalenceList, { equivalences } from "@/components/equivalence-list"; import { getEquivalentCarKm, @@ -20,23 +20,9 @@ import { DateRange } from "react-day-picker"; import useSWR from "swr"; /* - * The Global dashboard from the design. - * - * The page is one fluid content column: a breadcrumb, a header, then two - * sections below a rule. Its structure is expressed as flex regions with the - * spacing on their parents, so it holds together at any width rather than only - * at the frame's 1440x1024. - * - * Where the design's own measurements were internally inconsistent they have - * been normalised to a single value, since they clearly describe one gutter: - * - the breadcrumb, rule and sections sit at x 178 / 170 / 163 in the frame; - * all three now share the container's horizontal padding - * - the three equivalence items have 26 / 22 / 21px icon gaps; all now use one - * - * Text metrics are untouched: the exact Figma families, weights and sizes live in - * the `type-*` classes in globals.css, at line-height "normal" as the design - * specifies. Vertical rhythm comes from gaps, so nothing depends on a pinned line - * box and the layout holds as the display face loads. + * The Global dashboard: one fluid content column — a breadcrumb, a header, then + * two sections below a rule — built as flex regions with the spacing on their + * parents, so it holds at any width rather than only at the frame's 1440x1024. */ export default function OrgDashboardPage() { const { organizationId } = useParams<{ organizationId: string }>(); @@ -109,32 +95,15 @@ export default function OrgDashboardPage() { }, }; - const equivalences = [ - { - icon: "/icons/household_consumption.svg", - alt: "Household consumption icon", - value: `${getEquivalentCitizenPercentage( - RadialChartData.emissions.value, - ).toFixed(2)}%`, - caption: "Of an american household weekly energy consumption", - }, - { - icon: "/icons/transportation.svg", - alt: "Transportation icon", - value: `${getEquivalentCarKm( - RadialChartData.emissions.value, - ).toFixed(2)} km`, - caption: "Kilometers ridden", - }, - { - icon: "/icons/tv.svg", - alt: "TV icon", - value: `${getEquivalentTvTime(RadialChartData.energy.value).toFixed( - 2, - )} days`, - caption: "Of watching TV", - }, - ]; + const equivalenceItems = equivalences({ + citizen: getEquivalentCitizenPercentage( + RadialChartData.emissions.value, + ).toFixed(2), + transportation: getEquivalentCarKm( + RadialChartData.emissions.value, + ).toFixed(2), + tvTime: getEquivalentTvTime(RadialChartData.energy.value).toFixed(2), + }); const gauges = [ RadialChartData.energy, @@ -153,7 +122,7 @@ export default function OrgDashboardPage() {
{/* Breadcrumb and header, with the space below them separating the group from the rule. */} -
+
{/* Breadcrumb */}
diff --git a/webapp/src/pages/ProjectDashboardPage.tsx b/webapp/src/pages/ProjectDashboardPage.tsx index 34b7043d9..3693fa72a 100644 --- a/webapp/src/pages/ProjectDashboardPage.tsx +++ b/webapp/src/pages/ProjectDashboardPage.tsx @@ -1,5 +1,6 @@ -import BreadcrumbHeader from "@/components/breadcrumb"; +import ProjectActions from "@/components/project-actions"; import ProjectDashboard from "@/components/project-dashboard"; +import ProjectVisibilityBadge from "@/components/project-visibility-badge"; import { calculateConvertedValues, calculateRadialChartData, @@ -14,7 +15,7 @@ import { import { getOneProject } from "@/api/projects"; import { Experiment, ExperimentReport, Project } from "@/api/schemas"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { DateRange } from "react-day-picker"; export default function ProjectDashboardPage() { @@ -133,24 +134,72 @@ export default function ProjectDashboardPage() { ); return ( -
- -
+ /* + * The redesign's content column, as the Global dashboard and the Projects + * page use it: one scrolling region whose padding is the single horizontal + * gutter for everything inside. + * + * Only the shell is redesigned so far — the surface, the breadcrumb and + * the page heading. The panels below are still the old dashboard. + */ +
+ {/* Parent crumbs hover to white: the current crumb is the green one, + so hovering a link must not make it look like the page you are + already on. */} + + + {/* The heading holds everything about the project itself: its name, + whether it is public, what it is, and the actions that apply to + it. The actions sit at the end of the row and drop below the + name when there is no room for both. */} +
+
+ {/* The visibility pill belongs with the name it describes, + so it sits on the title's line and wraps under it when + there is no room. */} +
+

+ {project.name} +

+ {project.public !== undefined && ( + + )} +
+ {project.description && ( +

+ {project.description} +

+ )} +
+ + +
+ +
diff --git a/webapp/src/pages/ProjectsPage.tsx b/webapp/src/pages/ProjectsPage.tsx index 99a2e4402..7da02b755 100644 --- a/webapp/src/pages/ProjectsPage.tsx +++ b/webapp/src/pages/ProjectsPage.tsx @@ -19,34 +19,15 @@ import { Organization, Project } from "@/api/schemas"; import { useModal } from "@/hooks/useModal"; /* - * The Projects page, in its empty and populated states. + * The Projects page, in its empty and populated states, which differ only in + * where the primary action sits: centred in the empty area, or on the heading + * row beside the title. * - * Both of the design's frames still draw the older navigation — an in-page "Code - * carbon" header, a "Your projects / Members" tab row and an account dropdown - * pinned top right. All of that now lives in `SidebarRail`, so it is deliberately - * not rebuilt here; the page opens with the breadcrumb and background of the - * redesigned Global dashboard instead. Confirmed with the project owners. + * The design's frames still draw the older in-page navigation; that lives in + * `SidebarRail` and `AccountMenu` now and is not rebuilt here. * - * The two states differ only in where the primary action sits: with no projects it - * is centred in the empty area above the explanatory line, and with projects it - * sits on the heading row. Both render from the same shell below, so the page never - * changes structure — only which of the two regions is present. - * - * As with the other redesigned pages, the design's absolute coordinates are not - * reproduced: horizontal placement comes from the container's padding and vertical - * rhythm from gaps, so the page holds at any width rather than only at the frame's - * 1440x1024. - * - * The rows are a table, as the page used before the redesign: one record per line - * with the same fields in the same order, so they line up down the page instead of - * each row placing them wherever its own content falls. Columns size to their - * content and wrap, so a narrow screen compresses them rather than scrolling the - * table sideways. There is no `thead`, because the design labels no columns. - * - * The design labels a row's secondary text "Last updated on 02/02/24"; the project - * API carries no timestamp, so that slot keeps the description the page has always - * shown. Its overflow menu keeps the existing Settings and Delete actions — the - * design draws the trigger but never the open menu. + * A row's secondary text is the project's description — the design labels it + * "Last updated on 02/02/24", and the API carries no timestamp. */ export default function ProjectsPage() { const { organizationId } = useParams<{ organizationId: string }>(); diff --git a/webapp/src/pages/SettingsPage.tsx b/webapp/src/pages/SettingsPage.tsx index 541ef24eb..d6c600bda 100644 --- a/webapp/src/pages/SettingsPage.tsx +++ b/webapp/src/pages/SettingsPage.tsx @@ -13,26 +13,12 @@ import { LockIcon } from "@/components/icons/lock-icon"; const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; /* - * The Settings page from the design. + * The Settings page: a hugging sub-nav beside a bounded content column, both + * starting at the same top edge. * - * The frame has no sidebar rail and carries its own "Go back" control, so this is - * a standalone page rather than a child of DashboardLayout — it is routed under - * AuthGuard only. - * - * Layout: a hugging sub-nav beside a bounded content column, both starting at the - * same top edge (the design aligns "Go back" with the "Profile" title). Figma - * positions everything absolutely; here the design's measurements map onto - * container padding and gaps: - * sub-nav rows 20px/10px padding, 4px radius, 4px gap; 24px icon then a - * 10px-padded label. Its 163px width in the frame is exactly its - * hug-content width, so no width is set. - * content the input row is 466 + 4 + 106 = 576px wide, which is `max-w-xl` - * to the pixel, so that bounds the column. The frame's rule is - * drawn 627px wide; it spans the column here instead. - * nav <-> body 35px in the frame -> gap-9 - * - * Selected sub-nav row: #2b2b2b fill with a #BFFB4F label. Unselected rows are - * white at 50% opacity, exactly as the design draws them. + * The frame has no sidebar rail and carries its own "Go back" control, so this + * is a standalone page routed under AuthGuard rather than a child of + * DashboardLayout. */ export default function SettingsPage() { diff --git a/webapp/tests/components/account-menu.test.tsx b/webapp/tests/components/account-menu.test.tsx new file mode 100644 index 000000000..092f7e004 --- /dev/null +++ b/webapp/tests/components/account-menu.test.tsx @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const fetcherMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/swr", () => ({ fetcher: fetcherMock, swrConfig: {} })); +vi.mock("@/api/organizations", () => ({ getOrganizations: vi.fn() })); + +import AccountMenu from "@/components/account-menu"; +import { renderWithRouter } from "../test-utils"; +import { SWRConfig } from "swr"; + +const orgs = [ + { id: "o1", name: "Mozilla", description: "" }, + { id: "o2", name: "Wecasa", description: "" }, +]; + +/* + * `o1` is administered by the signed-in user and `o2` is not, which is the split + * the menu draws: administered dashboards below the rule, invited ones above it. + */ +function mockApi() { + fetcherMock.mockImplementation((key: string) => { + if (key === "/auth/check") + return Promise.resolve({ user: { id: "u1" } }); + if (key === "/organizations/o1/users") + return Promise.resolve([{ id: "u1", is_admin: true }]); + if (key === "/organizations/o2/users") + return Promise.resolve([{ id: "u1", is_admin: false }]); + return Promise.resolve([]); + }); +} + +function renderMenu() { + const onSelectOrg = vi.fn(); + renderWithRouter( + new Map(), dedupingInterval: 0 }}> + + + + , + ); + return { onSelectOrg }; +} + +beforeEach(() => { + fetcherMock.mockReset(); + navigateMock.mockReset(); + mockApi(); +}); + +describe("AccountMenu", () => { + it("lists the dashboards and the account actions once opened", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + + expect( + await screen.findByRole("menuitem", { name: "Mozilla" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Wecasa" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /add new organization/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Settings" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Log out" }), + ).toBeInTheDocument(); + }); + + it("marks the dashboard being viewed", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + + expect( + await screen.findByRole("menuitem", { name: "Mozilla" }), + ).toHaveAttribute("aria-current", "true"); + expect( + screen.getByRole("menuitem", { name: "Wecasa" }), + ).not.toHaveAttribute("aria-current"); + }); + + it("separates administered dashboards from invited ones", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + + // The heading only appears when there is something invited to list. + expect( + await screen.findByText(/dashboards you've been invited to/i), + ).toBeInTheDocument(); + + const items = screen + .getAllByRole("menuitem") + .map((item) => item.textContent); + // Invited first, administered after the rule. + expect(items.indexOf("Wecasa")).toBeLessThan(items.indexOf("Mozilla")); + }); + + it("keeps that split after the menu is closed and reopened", async () => { + renderMenu(); + const trigger = screen.getByRole("button", { name: "Account" }); + + await userEvent.click(trigger); + await screen.findByText(/dashboards you've been invited to/i); + await userEvent.keyboard("{Escape}"); + await userEvent.click(trigger); + + // Regression: keying the admin lookup on `open` dropped the result here, + // and every dashboard fell back into the invited group. + const items = screen + .getAllByRole("menuitem") + .map((item) => item.textContent); + expect(items.indexOf("Wecasa")).toBeLessThan(items.indexOf("Mozilla")); + }); + + it("switches dashboard when one is picked", async () => { + const { onSelectOrg } = renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Wecasa" }), + ); + expect(onSelectOrg).toHaveBeenCalledWith("o2"); + }); + + it("goes to settings from its own row", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Settings" }), + ); + await waitFor(() => + expect(navigateMock).toHaveBeenCalledWith("/settings"), + ); + }); +}); diff --git a/webapp/tests/components/consumed-energy-gauge.test.tsx b/webapp/tests/components/consumed-energy-gauge.test.tsx new file mode 100644 index 000000000..91ba6e940 --- /dev/null +++ b/webapp/tests/components/consumed-energy-gauge.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import ConsumedEnergyGauge from "@/components/consumed-energy-gauge"; + +/* + * The arc stands for "there is something here", so an empty range must not draw + * it — otherwise a zero gauge looks like some amount. + */ +describe("ConsumedEnergyGauge", () => { + it("draws the arc when there is a value", () => { + const { container } = render( + , + ); + expect(container.querySelector("path")).not.toBeNull(); + }); + + it("draws no arc at zero", () => { + const { container } = render( + , + ); + expect(container.querySelector("path")).toBeNull(); + // The track and the figures stay. + expect(container.querySelector("circle")).not.toBeNull(); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("names itself with its value and unit", () => { + render(); + expect(screen.getByRole("img", { name: "3 days" })).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/equivalence-list.test.tsx b/webapp/tests/components/equivalence-list.test.tsx new file mode 100644 index 000000000..c7d06d63e --- /dev/null +++ b/webapp/tests/components/equivalence-list.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import EquivalenceList, { equivalences } from "@/components/equivalence-list"; + +/* + * Both dashboards render these from one builder, so the captions and units + * cannot drift apart again — they did once, and one page showed kilometres with + * no unit at all. + */ +describe("equivalences", () => { + it("carries the unit on every figure", () => { + const [citizen, transport, tv] = equivalences({ + citizen: "1.50", + transportation: "42.00", + tvTime: "7.00", + }); + + expect(citizen.value).toBe("1.50%"); + expect(transport.value).toBe("42.00 km"); + expect(tv.value).toBe("7.00 days"); + }); + + it("describes the first figure as a citizen's emissions, which is what it is", () => { + const [citizen] = equivalences({ + citizen: "1", + transportation: "1", + tvTime: "1", + }); + expect(citizen.caption).toMatch(/citizen/i); + expect(citizen.caption).toMatch(/emissions/i); + }); +}); + +describe("EquivalenceList", () => { + it("renders a figure and caption per item", () => { + render( + , + ); + + expect(screen.getByText("1.50%")).toBeInTheDocument(); + expect(screen.getByText("42.00 km")).toBeInTheDocument(); + expect(screen.getByText("7.00 days")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); +}); diff --git a/webapp/tests/components/project-actions.test.tsx b/webapp/tests/components/project-actions.test.tsx new file mode 100644 index 000000000..9ecd15941 --- /dev/null +++ b/webapp/tests/components/project-actions.test.tsx @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@/api/projects", () => ({ updateProject: vi.fn() })); +vi.mock("@/api/runs", () => ({ + getEmissionsTimeSeries: vi.fn(), + getRunEmissionsByExperiment: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/components/projectTokens/projectTokenTable", () => ({ + ProjectTokensTable: () =>
, +})); + +const exportToJsonMock = vi.hoisted(() => vi.fn()); +vi.mock("@/utils/export", () => ({ exportToJson: exportToJsonMock })); + +import ProjectActions from "@/components/project-actions"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly run", + public: false, + organizationId: "o1", + experiments: [], +}; + +const runData = { + experimentId: "e1", + startDate: "2024-01-01", + endDate: "2024-02-01", +}; + +beforeEach(() => { + exportToJsonMock.mockReset(); +}); + +function renderActions() { + const onRefresh = vi.fn(); + render( + , + ); + return { onRefresh }; +} + +describe("ProjectActions", () => { + it("offers the project's actions, each named", () => { + renderActions(); + expect( + screen.getByRole("button", { name: /refresh data/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /download json export/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /project settings/i }), + ).toBeInTheDocument(); + }); + + it("hides the share control while the project is private", () => { + renderActions(); + expect( + screen.queryByRole("button", { name: /share project/i }), + ).not.toBeInTheDocument(); + }); + + it("refreshes when asked", async () => { + const { onRefresh } = renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /refresh data/i }), + ); + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()); + }); + + it("exports the project as JSON", async () => { + renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /download json export/i }), + ); + await waitFor(() => expect(exportToJsonMock).toHaveBeenCalledOnce()); + + const [payload] = exportToJsonMock.mock.calls[0]; + expect(payload.projects[0].id).toBe("p1"); + expect(payload.projects[0].date_range).toEqual({ + startDate: runData.startDate, + endDate: runData.endDate, + }); + }); + + it("opens project settings in place", async () => { + renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /project settings/i }), + ); + expect( + await screen.findByRole("heading", { name: /project settings/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/project-row.test.tsx b/webapp/tests/components/project-row.test.tsx new file mode 100644 index 000000000..98134d59d --- /dev/null +++ b/webapp/tests/components/project-row.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import ProjectRow from "@/components/project-row"; +import { Table, TableBody } from "@/components/ui/table"; +import { renderWithRouter } from "../test-utils"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly training run", + public: false, + organizationId: "o1", + experiments: [], +}; + +function renderRow(overrides: Partial[0]> = {}) { + const onSettings = vi.fn(); + const onDelete = vi.fn(); + renderWithRouter( + + + + +
, + ); + return { onSettings, onDelete }; +} + +describe("ProjectRow", () => { + it("links both the name and the secondary text to the project", () => { + renderRow(); + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(2); + links.forEach((link) => + expect(link).toHaveAttribute("href", "/o1/projects/p1"), + ); + }); + + it("omits the secondary link when there is no description", () => { + renderRow({ project: { ...project, description: "" } }); + expect(screen.getAllByRole("link")).toHaveLength(1); + }); + + it("names its overflow trigger after the project", () => { + renderRow(); + expect( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ).toBeInTheDocument(); + }); + + it("calls back when a menu action is picked", async () => { + const { onSettings, onDelete } = renderRow(); + + await userEvent.click( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: /settings/i }), + ); + expect(onSettings).toHaveBeenCalledOnce(); + expect(onDelete).not.toHaveBeenCalled(); + + await userEvent.click( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: /delete/i }), + ); + expect(onDelete).toHaveBeenCalledOnce(); + }); +}); diff --git a/webapp/tests/components/project-settings-modal.test.tsx b/webapp/tests/components/project-settings-modal.test.tsx new file mode 100644 index 000000000..e2db51151 --- /dev/null +++ b/webapp/tests/components/project-settings-modal.test.tsx @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const updateProjectMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/projects", () => ({ updateProject: updateProjectMock })); + +// The token table fetches on mount and is not what these tests are about. +vi.mock("@/components/projectTokens/projectTokenTable", () => ({ + ProjectTokensTable: () =>
, +})); + +import ProjectSettingsModal from "@/components/project-settings-modal"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly training run", + public: false, + organizationId: "o1", + experiments: [], +}; + +beforeEach(() => { + updateProjectMock.mockReset(); + updateProjectMock.mockResolvedValue(project); +}); + +function renderModal(overrides: Record = {}) { + const onOpenChange = vi.fn(); + const onProjectUpdated = vi.fn(); + render( + , + ); + return { onOpenChange, onProjectUpdated }; +} + +describe("ProjectSettingsModal", () => { + it("saves the visibility toggle on its own and reveals the sharing link", async () => { + renderModal(); + + expect( + screen.queryByRole("button", { name: /copy link/i }), + ).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("switch", { name: /make project public/i }), + ); + + await waitFor(() => + expect(updateProjectMock).toHaveBeenCalledWith("p1", { + name: project.name, + description: project.description, + public: true, + }), + ); + expect( + await screen.findByRole("button", { name: /copy link/i }), + ).toBeInTheDocument(); + }); + + it("does not commit unsaved field edits when the toggle is flipped", async () => { + renderModal(); + + const name = screen.getByLabelText(/^name$/i); + await userEvent.clear(name); + await userEvent.type(name, "Renamed but unsaved"); + + await userEvent.click( + screen.getByRole("switch", { name: /make project public/i }), + ); + + await waitFor(() => + expect(updateProjectMock).toHaveBeenCalledWith( + "p1", + expect.objectContaining({ name: project.name }), + ), + ); + }); + + it("rolls the toggle back when the write fails", async () => { + updateProjectMock.mockRejectedValue(new Error("nope")); + vi.spyOn(console, "error").mockImplementation(() => {}); + renderModal(); + + const toggle = screen.getByRole("switch", { + name: /make project public/i, + }); + await userEvent.click(toggle); + + await waitFor(() => + expect(toggle).toHaveAttribute("data-state", "unchecked"), + ); + expect( + screen.queryByRole("button", { name: /copy link/i }), + ).not.toBeInTheDocument(); + }); + + it("closes on a successful save", async () => { + const { onOpenChange, onProjectUpdated } = renderModal(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => expect(onProjectUpdated).toHaveBeenCalled()); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("stays open when the save fails, so the edits survive", async () => { + updateProjectMock.mockRejectedValue(new Error("nope")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const { onOpenChange } = renderModal(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => expect(updateProjectMock).toHaveBeenCalled()); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + + it("opens on General, whichever tab it was left on", async () => { + const { rerender } = render( + , + ); + + await userEvent.click(screen.getByRole("tab", { name: /api tokens/i })); + expect( + screen.getByRole("tab", { name: /api tokens/i }), + ).toHaveAttribute("aria-selected", "true"); + + // Closed and opened again: the dialog stays mounted between openings. + rerender( + , + ); + rerender( + , + ); + + expect(screen.getByRole("tab", { name: /general/i })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("disables the save action until the project has a name", async () => { + renderModal(); + + const save = screen.getByRole("button", { name: /save changes/i }); + expect(save).toBeEnabled(); + + await userEvent.clear(screen.getByLabelText(/^name$/i)); + expect(save).toBeDisabled(); + }); +}); diff --git a/webapp/tests/components/project-visibility-badge.test.tsx b/webapp/tests/components/project-visibility-badge.test.tsx new file mode 100644 index 000000000..b757373b3 --- /dev/null +++ b/webapp/tests/components/project-visibility-badge.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import ProjectVisibilityBadge from "@/components/project-visibility-badge"; + +describe("ProjectVisibilityBadge", () => { + it("says Public when the project is public", () => { + render(); + expect(screen.getByText("Public")).toBeInTheDocument(); + }); + + it("says Private otherwise", () => { + render(); + expect(screen.getByText("Private")).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/sidebar-rail.test.tsx b/webapp/tests/components/sidebar-rail.test.tsx new file mode 100644 index 000000000..d93058506 --- /dev/null +++ b/webapp/tests/components/sidebar-rail.test.tsx @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +const locationMock = vi.hoisted(() => ({ current: { pathname: "/o1" } })); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useNavigate: () => navigateMock, + useLocation: () => locationMock.current, + }; +}); + +// The account menu fetches on open; the rail's own behaviour is what is tested. +vi.mock("@/components/account-menu", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import SidebarRail from "@/components/sidebar-rail"; +import { renderWithRouter } from "../test-utils"; + +const orgs = [ + { id: "o1", name: "Mozilla", description: "" }, + { id: "o2", name: "Wecasa", description: "" }, +]; + +beforeEach(() => { + navigateMock.mockReset(); + localStorage.clear(); + locationMock.current = { pathname: "/o1" }; +}); + +describe("SidebarRail", () => { + it("offers the dashboard's destinations", () => { + renderWithRouter(); + ["Global", "Projects", "Members", "Account"].forEach((label) => + expect( + screen.getByRole("button", { name: label }), + ).toBeInTheDocument(), + ); + }); + + it("marks the destination matching the current path", () => { + locationMock.current = { pathname: "/o1/projects" }; + renderWithRouter(); + + expect( + screen.getByRole("button", { name: "Projects" }), + ).toHaveAttribute("aria-current", "page"); + expect( + screen.getByRole("button", { name: "Global" }), + ).not.toHaveAttribute("aria-current"); + }); + + it("navigates within the organization in the URL", async () => { + locationMock.current = { pathname: "/o2/members" }; + renderWithRouter(); + + await userEvent.click(screen.getByRole("button", { name: "Projects" })); + expect(navigateMock).toHaveBeenCalledWith("/o2/projects"); + }); + + it("remembers the organization it resolved, for the pages that read it", () => { + renderWithRouter(); + expect(localStorage.getItem("organizationId")).toBe("o1"); + expect(localStorage.getItem("organizationName")).toBe("Mozilla"); + }); + + it("does not navigate before an organization is known", async () => { + renderWithRouter(); + await userEvent.click(screen.getByRole("button", { name: "Projects" })); + expect(navigateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/tests/components/ui/form-field.test.tsx b/webapp/tests/components/ui/form-field.test.tsx new file mode 100644 index 000000000..edf409b22 --- /dev/null +++ b/webapp/tests/components/ui/form-field.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { FormField } from "@/components/ui/form-field"; + +describe("FormField", () => { + it("ties its label to its control", () => { + render(); + expect(screen.getByLabelText("Token name")).toBe( + screen.getByRole("textbox"), + ); + }); + + it("keeps the accessible name when the label is hidden", () => { + render(); + // Still reachable by name, but not shown as a visible label. + expect(screen.getByLabelText("Email address")).toBeInTheDocument(); + expect(screen.getByText("Email address")).toHaveClass("sr-only"); + }); +}); diff --git a/webapp/tests/pages/SettingsPage.test.tsx b/webapp/tests/pages/SettingsPage.test.tsx new file mode 100644 index 000000000..0c9c5a42c --- /dev/null +++ b/webapp/tests/pages/SettingsPage.test.tsx @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const fetcherMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/swr", () => ({ fetcher: fetcherMock, swrConfig: {} })); + +import SettingsPage from "@/pages/SettingsPage"; +import { renderWithRouter } from "../test-utils"; +import { SWRConfig } from "swr"; + +function renderPage() { + return renderWithRouter( + new Map(), dedupingInterval: 0 }}> + + , + ); +} + +beforeEach(() => { + fetcherMock.mockReset(); + navigateMock.mockReset(); + fetcherMock.mockResolvedValue({ user: { email: "person@example.com" } }); +}); + +describe("SettingsPage", () => { + it("shows the signed-in address rather than a placeholder", async () => { + renderPage(); + expect( + await screen.findByDisplayValue("person@example.com"), + ).toBeDisabled(); + }); + + it("marks Profile as the panel being viewed, and does not make it pressable", () => { + renderPage(); + // The current row is not a button: there is nothing to press. + expect( + screen.queryByRole("button", { name: /profile/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByText("Profile", { selector: "span" }), + ).toBeInTheDocument(); + }); + + it("goes back through history", async () => { + renderPage(); + await userEvent.click(screen.getByRole("button", { name: /go back/i })); + expect(navigateMock).toHaveBeenCalledWith(-1); + }); + + it("disables the provider hand-offs when no provider is configured", () => { + renderPage(); + // VITE_OIDC_PROFILE_URL is unset in tests, so both are inert. + expect( + screen.getByRole("button", { name: /password/i }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: /change/i })).toBeDisabled(); + }); +}); diff --git a/webapp/tests/setup.ts b/webapp/tests/setup.ts index 8ed0c3f4e..1b45f39c4 100644 --- a/webapp/tests/setup.ts +++ b/webapp/tests/setup.ts @@ -2,6 +2,24 @@ import "@testing-library/jest-dom/vitest"; import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; +/* + * jsdom implements neither of these, and Radix reaches for both: `useSize` + * observes a control's box (the switch does), and its popper measures scroll + * geometry. Stubbed here rather than in each test, so a component that happens + * to use one does not fail for a reason unrelated to what is being tested. + */ +if (!("ResizeObserver" in globalThis)) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + afterEach(() => { cleanup(); }); From e18cae07ddfe79297ddfa0327e70c46382ba3483 Mon Sep 17 00:00:00 2001 From: Satsh Date: Mon, 24 Aug 2026 23:47:10 +0200 Subject: [PATCH 7/8] feat(webapp): redesign the members page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put the page on the Projects page's shell — dark surface, breadcrumb, display title — and give it the same table treatment, with each member as a MemberRow. The invite control is a form on the page rather than a dialog, since the API takes a single email address. Move that call behind addOrganizationUser: the endpoint subscribes an existing account and answers with a bare status object, so there is nothing to validate and the page refetches the list instead. Restyle the toasts onto the design's alert. Sonner's own rules hang off a stylesheet it injects after Tailwind's, which no class can outrank, so they render unstyled and restate the shape the design draws. Let Table take classes for its scroll container, which the members table needs to bound its own height. Co-Authored-By: Claude Opus 5 --- webapp/src/api/organizations.ts | 20 +- .../components/icons/user-single-aim-icon.tsx | 52 +++ webapp/src/components/member-row.tsx | 125 ++++++ webapp/src/components/ui/sonner.tsx | 43 +- webapp/src/components/ui/table.tsx | 9 +- webapp/src/pages/MembersPage.tsx | 373 ++++++++++-------- webapp/tests/pages/MembersPage.test.tsx | 105 +++-- 7 files changed, 537 insertions(+), 190 deletions(-) create mode 100644 webapp/src/components/icons/user-single-aim-icon.tsx create mode 100644 webapp/src/components/member-row.tsx 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/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. */} +
+ {name && ( + + {name} + + )} + + {member.email} + +
+
+
+ + {/* + * 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. + */} + + + Settings + + + Delete + + + + +
+ ); +} diff --git a/webapp/src/components/ui/sonner.tsx b/webapp/src/components/ui/sonner.tsx index 676be2f68..e7d04524b 100644 --- a/webapp/src/components/ui/sonner.tsx +++ b/webapp/src/components/ui/sonner.tsx @@ -2,21 +2,52 @@ import { Toaster as Sonner } from "sonner"; type ToasterProps = React.ComponentProps; +/* + * The app's notifications. + * + * `unstyled` is load-bearing: Sonner's visual rules hang off + * `[data-sonner-toast][data-styled='true']`, in a stylesheet it injects after + * Tailwind's, so a class can neither outrank them nor win on order. Unstyled + * drops that layer while keeping Sonner's positioning and animation, which is + * why the flex row, the gap and the icon's box are restated below. + * + * The design draws only the success alert; the other variants keep the dark + * surface and take just its shape. + */ + const Toaster = ({ ...props }: ToasterProps) => { return ( ->(({ className, ...props }, ref) => ( -
+ React.HTMLAttributes & { + /** Classes for the scroll container; `className` styles the table. */ + containerClassName?: string; + } +>(({ className, containerClassName, ...props }, ref) => ( +
(); - const [isDialogOpen, setDialogOpen] = useState(false); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const { - data: users, - error: fetchError, - isLoading: usersLoading, - } = useSWR(`/organizations/${organizationId}/users`, fetcher); + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [isInviting, setIsInviting] = useState(false); + /* + * The breadcrumb's organization name, fetched as the other redesigned pages + * fetch it, with the name the navigation already cached as the fallback so + * the crumb never flashes a bare id. + */ const { data: organization } = useSWR( - `/organizations/${organizationId}`, + organizationId ? `/organizations/${organizationId}` : null, fetcher, + { revalidateOnFocus: false }, ); + let cachedOrganizationName: string | null = null; + try { + cachedOrganizationName = localStorage.getItem("organizationName"); + } catch { + cachedOrganizationName = null; + } + const organizationName = + organization?.name || cachedOrganizationName || organizationId!; + + const membersKey = `/organizations/${organizationId}/users`; + const { + data: members, + error: fetchError, + isLoading, + } = useSWR(membersKey, fetcher); + + const emailSchema = z.string().email("Please enter a valid email address"); - const form = { email: undefined as string | undefined }; + async function handleInvite(event: React.FormEvent) { + event.preventDefault(); - const emailSchema = z.object({ - email: z.string().email("Please enter a valid email address"), - }); + const parsed = emailSchema.safeParse(email.trim()); + if (!parsed.success) { + setError(parsed.error.errors[0].message); + return; + } + setError(null); + setIsInviting(true); - async function addUser() { try { - setIsLoading(true); - const email = ( - document.getElementById("emailInput") as HTMLInputElement - ).value; - - emailSchema.parse({ email }); - setError(null); - - const body = JSON.stringify({ email }); - const API_BASE = import.meta.env.VITE_API_URL; - - await toast - .promise( - fetch( - `${API_BASE}/organizations/${organizationId}/add-user`, - { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body, - }, - ).then(async (res) => { - if (!res.ok) throw new Error("Failed to add user"); - return res.json(); - }), - { - loading: `Adding user ${email}...`, - success: `User ${email} added successfully`, - error: (err) => `${err.message}`, - }, - ) - .unwrap(); - - mutate(`/organizations/${organizationId}/users`); - setDialogOpen(false); + await addOrganizationUser(organizationId!, parsed.data); + /* The design's success alert names the address it went to. */ + toast.success(`Invite sent to ${parsed.data}`); + setEmail(""); + await mutate(membersKey); } catch (err) { - if (err instanceof z.ZodError) { - setError(err.errors[0].message); - } else { - setError( - err instanceof Error ? err.message : "An error occurred", - ); - } + const message = + err instanceof Error ? err.message : "Failed to invite member"; + /* Kept on the field as well as in the toast: the toast goes away, + and the address that failed is still sitting in the input. */ + setError(message); + toast.error(message); } finally { - setIsLoading(false); + setIsInviting(false); } } - if (usersLoading) { + if (isLoading) { return ; } - if (fetchError || !users) { + if (fetchError || !members) { return ; } + const sortedMembers = [...members].sort((a, b) => + (a.name || a.email) + .toLowerCase() + .localeCompare((b.name || b.email).toLowerCase()), + ); + const hasMembers = sortedMembers.length > 0; + return ( - <> - -
-
-
-

Members

- -
- {isDialogOpen && ( -
-

Add member

- - - {error && ( - - )} -
- - -
-
+ /* The Global dashboard's content column: one scrolling region whose + padding is the single horizontal gutter for everything inside it. */ +
+ {/* The parent crumb hovers to white rather than to the design's + button-hover green, which is the colour of the current crumb + beside it — hovering should not make a link look like the page + you are already on. */} + + +
+

+ Members +

+
+ + {/* + * A real form, so Enter submits and the browser validates the + * address before the request does. The button aligns with the + * field's control rather than with the label above it, which is why + * the row is bottom-aligned until it wraps. + */} +
+ { + setEmail(event.target.value); + setError(null); + }} + disabled={isInviting} + aria-invalid={error ? true : undefined} + aria-describedby={error ? "member-email-error" : undefined} + containerClassName="w-full max-w-[466px]" + /> + + {isInviting ? ( + <> + {/* The design shows the spinner alone; the label it + replaces still has to reach a screen reader. */} + + Sending invite… + + ) : ( + "Invite" )} - -
- - {users - .sort((a, b) => - a.name - .toLowerCase() - .localeCompare( - b.name.toLowerCase(), - ), - ) - .map((user) => ( - - ))} - -
- + + {error && ( + + )} + + + {hasMembers ? ( + /* The design leaves more air between the invite form's rule and + the column heading than the heading's own padding gives. */ + + {/* + * The design labels this one column and no other, so the + * header row carries a single heading and the status and + * actions columns stay unlabelled. `TableHead` sets its own + * weight and colour, which the design's display face + * overrides here. + */} + + + + Name + + + Role + + + Actions + + + + + {sortedMembers.map((member) => ( + + ))} + +
+ ) : ( + /* + * The design centres this line in whatever the frame has left + * below the form; here it takes the space the column has over + * and centres within that, with a minimum so it still reads as + * an empty area when the viewport is short. + * + * The line is set in Inter in the design, alone among every + * node in the file — plainly a default rather than intent, so it + * takes the mono face the rest of the redesign uses, matching + * the Projects page's equivalent line. + */ +
+

+ You have no members invited yet... +

-
- + )} +
); } diff --git a/webapp/tests/pages/MembersPage.test.tsx b/webapp/tests/pages/MembersPage.test.tsx index 355e2e9b1..5e35a12c1 100644 --- a/webapp/tests/pages/MembersPage.test.tsx +++ b/webapp/tests/pages/MembersPage.test.tsx @@ -16,14 +16,28 @@ vi.mock("@/api/swr", () => ({ swrConfig: {}, })); +const addOrganizationUserMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/organizations", () => ({ + addOrganizationUser: addOrganizationUserMock, +})); + import MembersPage from "@/pages/MembersPage"; import { renderWithRouter } from "../test-utils"; import { SWRConfig } from "swr"; beforeEach(() => { fetcherMock.mockReset(); + addOrganizationUserMock.mockReset(); + addOrganizationUserMock.mockResolvedValue(undefined); }); +function mockOrganization(url: string) { + if (url.endsWith("/organizations/o1")) { + return Promise.resolve({ id: "o1", name: "Acme", description: "" }); + } + return undefined; +} + function renderWithSwr(node: React.ReactNode) { return renderWithRouter( new Map(), dedupingInterval: 0 }}> @@ -33,7 +47,7 @@ function renderWithSwr(node: React.ReactNode) { } describe("MembersPage", () => { - it("renders the user list once loaded", async () => { + it("renders the member list once loaded", async () => { // SWR calls the fetcher per key; first matching response wins per key. fetcherMock.mockImplementation((url: string) => { if (url.endsWith("/users")) { @@ -42,43 +56,90 @@ describe("MembersPage", () => { id: "u1", name: "Alice", email: "alice@example.com", - is_active: true, - organizations: ["o1"], + organization_id: "o1", + is_admin: true, }, ]); } - if (url.endsWith("/organizations/o1")) { - return Promise.resolve({ - id: "o1", - name: "Acme", - description: "", - }); - } - return Promise.resolve(null); + return mockOrganization(url) ?? Promise.resolve(null); }); renderWithSwr(); expect(await screen.findByText("Alice")).toBeInTheDocument(); expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + // The status slot carries the only standing the API records. + expect(screen.getByText("(Admin)")).toBeInTheDocument(); + }); + + it("shows the empty state when the organization has no members", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + + renderWithSwr(); + + expect( + await screen.findByText(/you have no members invited yet/i), + ).toBeInTheDocument(); + }); + + it("keeps the invite button disabled until an address is typed", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + + renderWithSwr(); + + const button = await screen.findByRole("button", { name: /invite/i }); + expect(button).toBeDisabled(); + + await userEvent.type( + screen.getByLabelText(/invite via email/i), + "new@example.com", + ); + expect(button).toBeEnabled(); + }); + + it("invites the typed address", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + + renderWithSwr(); + + await userEvent.type( + await screen.findByLabelText(/invite via email/i), + "new@example.com", + ); + await userEvent.click(screen.getByRole("button", { name: /invite/i })); + + expect(addOrganizationUserMock).toHaveBeenCalledWith( + "o1", + "new@example.com", + ); }); - it("opens the add-member form when the button is clicked", async () => { + it("does not send an invalid address to the API", async () => { fetcherMock.mockImplementation((url: string) => { if (url.endsWith("/users")) return Promise.resolve([]); - if (url.endsWith("/organizations/o1")) - return Promise.resolve({ - id: "o1", - name: "Acme", - description: "", - }); - return Promise.resolve(null); + return mockOrganization(url) ?? Promise.resolve(null); }); renderWithSwr(); - await userEvent.click( - await screen.findByRole("button", { name: /\+ add a member/i }), + + await userEvent.type( + await screen.findByLabelText(/invite via email/i), + "not-an-email", ); - expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /invite/i })); + + // `type="email"` fails the form's own constraint validation, so the + // submit never reaches the handler. The page's schema check behind it + // covers whatever the browser lets through. + expect(addOrganizationUserMock).not.toHaveBeenCalled(); }); }); From 231dae5b2c798d2a2d17fcdcc1af28dea92746d9 Mon Sep 17 00:00:00 2001 From: Satsh Date: Mon, 24 Aug 2026 23:58:57 +0200 Subject: [PATCH 8/8] feat(webapp): redesign the share-project popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put the panel on the app's dark surface, outlined in the page's own rule rather than the menus' green — a menu's border marks where the pointer is working, and a panel you only read does not need that weight. The date range picker's calendar shares the primitive and follows. Show the public link as text instead of a read-only field: it is read and copied, never typed into. Its copy control becomes an icon button, so it matches the four in the project's heading. Co-Authored-By: Claude Opus 5 --- .../src/components/share-project-button.tsx | 44 +++++++++---------- webapp/src/components/ui/popover.tsx | 11 ++++- .../components/share-project-button.test.tsx | 8 ++-- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/webapp/src/components/share-project-button.tsx b/webapp/src/components/share-project-button.tsx index a9212f6d5..64b5595c5 100644 --- a/webapp/src/components/share-project-button.tsx +++ b/webapp/src/components/share-project-button.tsx @@ -1,5 +1,3 @@ -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { ShareIcon } from "@/components/icons/share-icon"; import { IconButton } from "@/components/ui/icon-button"; import { SecondaryButton } from "@/components/ui/secondary-button"; @@ -73,32 +71,34 @@ export default function ShareProjectButton({ )} - -
-

- Share this project -

-

- Anyone with this link can view this project's - emissions data without authentication. -

-
- - +
diff --git a/webapp/src/components/ui/popover.tsx b/webapp/src/components/ui/popover.tsx index 4b80d593d..75bf8219f 100644 --- a/webapp/src/components/ui/popover.tsx +++ b/webapp/src/components/ui/popover.tsx @@ -19,7 +19,16 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 w-72 p-4 outline-none", + // The menus' surface, but outlined in the page's own rule rather + // than their green: a menu's border marks where the pointer is + // working, and a panel you only read does not need that much. + "rounded-menu border border-cc-rule bg-cc-background text-cc-white shadow-menu", + // Its own open/close animation, which is unaffected by the + // centring problem the dialog had — a popover is placed by Radix, + // not by a transform of its own. + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "motion-reduce:animate-none motion-reduce:transition-none", className, )} {...props} diff --git a/webapp/tests/components/share-project-button.test.tsx b/webapp/tests/components/share-project-button.test.tsx index d955e22aa..aa5d6131b 100644 --- a/webapp/tests/components/share-project-button.test.tsx +++ b/webapp/tests/components/share-project-button.test.tsx @@ -40,9 +40,9 @@ describe("ShareProjectButton", () => { screen.getByRole("button", { name: /share project/i }), ); - const input = await screen.findByDisplayValue( - /\/public\/projects\/p1$/, - ); - expect(input).toBeInTheDocument(); + // The link is shown as text, not in a field: it is read and copied, + // never typed into. + const link = await screen.findByText(/\/public\/projects\/p1$/); + expect(link).toBeInTheDocument(); }); });