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 ( +