diff --git a/.storybook/global.css b/.storybook/global.css
index 32d95f6e4..73464846c 100644
--- a/.storybook/global.css
+++ b/.storybook/global.css
@@ -16,3 +16,5 @@ body {
background-color: orange;
}
}
+
+@import "../frontend/tailwindsetup.css";
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 831ce237f..5be93fed6 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -45,5 +45,8 @@
},
"[md]": {
"editor.wordWrap": "on"
+ },
+ "files.associations": {
+ "*.css": "tailwindcss"
}
}
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 0077b808d..4ab0bd4b9 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -1,6 +1,7 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
+import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "electron-vite";
import flow from "rollup-plugin-flow";
@@ -63,15 +64,23 @@ export default defineConfig({
server: {
open: false,
},
+ css: {
+ preprocessorOptions: {
+ scss: {
+ silenceDeprecations: ["mixed-decls"],
+ },
+ },
+ },
plugins: [
ViteImageOptimizer(),
tsconfigPaths(),
+ flow(),
svgr({
svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true },
include: "**/*.svg",
}),
react({}),
- flow(),
+ tailwindcss(),
viteStaticCopy({
targets: [{ src: "node_modules/monaco-editor/min/vs/*", dest: "monaco" }],
}),
diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx
index c935dfa91..880a034d2 100644
--- a/frontend/app/app.tsx
+++ b/frontend/app/app.tsx
@@ -29,6 +29,9 @@ import { NotificationBubbles } from "./notification/notificationbubbles";
import "./app.scss";
+// this should come after app.scss (don't remove the newline above otherwise prettier will reorder these imports)
+import "../tailwindsetup.css";
+
const dlog = debug("wave:app");
const focusLog = debug("wave:focus");
diff --git a/frontend/app/element/donutchart.stories.tsx b/frontend/app/element/donutchart.stories.tsx
new file mode 100644
index 000000000..1f065ae92
--- /dev/null
+++ b/frontend/app/element/donutchart.stories.tsx
@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import DonutChart from "./donutchart";
+
+const meta = {
+ title: "Components/DonutChart",
+ component: DonutChart,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "The `DonutChart` component displays data in a donut-style chart with customizable colors, labels, and tooltip. Useful for visualizing proportions or percentages.",
+ },
+ },
+ },
+ argTypes: {
+ data: {
+ description:
+ "The data for the chart, where each item includes `label`, `value`, and optional `displayvalue`.",
+ control: { type: "object" },
+ },
+ config: {
+ description: "config for the chart",
+ control: { type: "object" },
+ },
+ innerLabel: {
+ description: "The label displayed inside the donut chart (e.g., percentages).",
+ control: { type: "text" },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ config: {
+ chrome: { label: "Chrome", color: "#8884d8" },
+ safari: { label: "Safari", color: "#82ca9d" },
+ firefox: { label: "Firefox", color: "#ffc658" },
+ edge: { label: "Edge", color: "#ff8042" },
+ other: { label: "Other", color: "#8dd1e1" },
+ },
+ data: [
+ { label: "chrome", value: 275, fill: "#8884d8" }, // Purple
+ { label: "safari", value: 200, fill: "#82ca9d" }, // Green
+ { label: "firefox", value: 287, fill: "#ffc658" }, // Yellow
+ { label: "edge", value: 173, fill: "#ff8042" }, // Orange
+ { label: "other", value: 190, fill: "#8dd1e1" }, // Light Blue
+ ],
+ innerLabel: "50%",
+ innerSubLabel: "50/100",
+ dataKey: "value",
+ nameKey: "label",
+ },
+};
diff --git a/frontend/app/element/donutchart.tsx b/frontend/app/element/donutchart.tsx
new file mode 100644
index 000000000..7dd32afcc
--- /dev/null
+++ b/frontend/app/element/donutchart.tsx
@@ -0,0 +1,92 @@
+import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/app/shadcn/chart";
+import { isBlank } from "@/util/util";
+import { Label, Pie, PieChart } from "recharts";
+import { ViewBox } from "recharts/types/util/types";
+
+const DEFAULT_COLORS = [
+ "#3498db", // blue
+ "#2ecc71", // green
+ "#e74c3c", // red
+ "#f1c40f", // yellow
+ "#9b59b6", // purple
+ "#1abc9c", // turquoise
+ "#e67e22", // orange
+ "#34495e", // dark blue
+];
+
+const NO_DATA_COLOR = "#E0E0E0";
+
+const PieInnerLabel = ({
+ innerLabel,
+ innerSubLabel,
+ viewBox,
+}: {
+ innerLabel: string;
+ innerSubLabel: string;
+ viewBox: ViewBox;
+}) => {
+ if (isBlank(innerLabel)) {
+ return null;
+ }
+ if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
+ return null;
+ }
+ return (
+
+
+ {innerLabel}
+
+ {innerSubLabel && (
+
+ {innerSubLabel}
+
+ )}
+
+ );
+};
+
+const DonutChart = ({
+ data,
+ config,
+ innerLabel,
+ innerSubLabel,
+ dataKey,
+ nameKey,
+}: {
+ data: any[];
+ config: ChartConfig;
+ innerLabel?: string;
+ innerSubLabel?: string;
+ dataKey: string;
+ nameKey: string;
+}) => {
+ return (
+
+
+
+ } />
+
+
+
+
+
+ );
+};
+
+export default DonutChart;
diff --git a/frontend/app/element/markdown.scss b/frontend/app/element/markdown.scss
index b49cdccfd..dee02633e 100644
--- a/frontend/app/element/markdown.scss
+++ b/frontend/app/element/markdown.scss
@@ -93,12 +93,12 @@
ul {
list-style-type: disc;
list-style-position: outside;
- margin-left: 1.143em;
+ margin-left: 1em;
}
ol {
list-style-position: outside;
- margin-left: 1.357em;
+ margin-left: 1.2em;
}
blockquote {
diff --git a/frontend/app/reset.scss b/frontend/app/reset.scss
index aa0063590..399bcf812 100644
--- a/frontend/app/reset.scss
+++ b/frontend/app/reset.scss
@@ -1,32 +1,197 @@
-// Copyright 2024, Command Line Inc.
+// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ box-sizing: border-box;
+ border: 0 solid;
+ }
-* {
- margin: 0;
-}
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ margin: 0;
+ padding: 0;
+ }
-body {
- line-height: 1.2;
- -webkit-font-smoothing: antialiased;
-}
+ html,
+ :host {
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ font-feature-settings: var(--default-font-feature-settings, normal);
+ font-variation-settings: var(--default-font-variation-settings, normal);
+ -webkit-tap-highlight-color: transparent;
+ }
-img,
-picture,
-video,
-canvas,
-svg {
- display: block;
-}
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-size: inherit;
+ font-weight: inherit;
+ }
+
+ ol,
+ ul,
+ menu {
+ list-style: none;
+ }
+
+ img,
+ svg,
+ video,
+ canvas,
+ audio,
+ iframe,
+ embed,
+ object {
+ display: block;
+ // keep this vertical-align (applies if you change the display attribute)
+ vertical-align: middle;
+ }
+
+ hr {
+ height: 0;
+ color: inherit;
+ border-top-width: 1px;
+ }
+
+ b,
+ strong {
+ font-weight: bolder;
+ }
+
+ small {
+ font-size: 80%;
+ }
+
+ sub,
+ sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+
+ sub {
+ bottom: -0.25em;
+ }
+
+ sup {
+ top: -0.5em;
+ }
+
+ progress {
+ vertical-align: baseline;
+ }
+
+ abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+ }
+
+ summary {
+ display: list-item;
+ }
+
+ img,
+ video {
+ max-width: 100%;
+ height: auto;
+ }
+
+ :where(select:is([multiple], [size])) optgroup {
+ font-weight: bolder;
+ }
+
+ ::file-selector-button {
+ margin-inline-end: 4px;
+ }
+
+ ::placeholder {
+ opacity: 1; /* 1 */
+ color: color-mix(in oklab, currentColor 50%, transparent); /* 2 */
+ }
+
+ textarea {
+ resize: vertical;
+ }
+
+ ::-webkit-date-and-time-value {
+ min-height: 1lh;
+ text-align: inherit;
+ }
+
+ ::-webkit-datetime-edit {
+ display: inline-flex;
+ }
+
+ ::-webkit-datetime-edit-fields-wrapper {
+ padding: 0;
+ }
+
+ ::-webkit-datetime-edit,
+ ::-webkit-datetime-edit-year-field,
+ ::-webkit-datetime-edit-month-field,
+ ::-webkit-datetime-edit-day-field,
+ ::-webkit-datetime-edit-hour-field,
+ ::-webkit-datetime-edit-minute-field,
+ ::-webkit-datetime-edit-second-field,
+ ::-webkit-datetime-edit-millisecond-field,
+ ::-webkit-datetime-edit-meridiem-field {
+ padding-block: 0;
+ }
+
+ :-moz-ui-invalid {
+ box-shadow: none;
+ }
+
+ button,
+ input:where([type="button"], [type="reset"], [type="submit"]),
+ ::file-selector-button {
+ appearance: button;
+ }
+
+ ::-webkit-inner-spin-button,
+ ::-webkit-outer-spin-button {
+ height: auto;
+ }
+
+ [hidden]:where(:not([hidden="until-found"])) {
+ display: none !important;
+ }
+
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+
+ body {
+ line-height: 1.2;
+ -webkit-font-smoothing: antialiased;
+ }
+
+ img,
+ picture,
+ video,
+ canvas,
+ svg {
+ display: block;
+ }
-input,
-button,
-textarea,
-select {
- font: inherit;
+ input,
+ button,
+ textarea,
+ select {
+ font: inherit;
+ }
}
diff --git a/frontend/app/shadcn/chart.tsx b/frontend/app/shadcn/chart.tsx
new file mode 100644
index 000000000..345e4c69d
--- /dev/null
+++ b/frontend/app/shadcn/chart.tsx
@@ -0,0 +1,317 @@
+"use client";
+
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import cn from "clsx";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & ({ color?: string; theme?: never } | { color?: never; theme: Record });
+};
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps["children"];
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+ {children}
+
+
+ );
+});
+ChartContainer.displayName = "Chart";
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+