Skip to content

Commit 997ac7f

Browse files
committed
feat(web): adopt BaseUI with host-owned theming
1 parent 6da99a8 commit 997ac7f

52 files changed

Lines changed: 1918 additions & 586 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/rfc/web-ui.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Summary is the default request view, and `?view=history` makes History shareable
6868

6969
**Errors and values.** Gateway failures map from gRPC status: invalid argument and resource exhausted are user errors, not found is a missing resource, unavailable and deadline exceeded are transient, and everything else is internal. A transport with another error shape supplies its own classifier. Raw gateway messages are logged, never rendered, and views keep the last good data on transient failures. Status values stay strings with a display table and an unknown fallback. `int64` values arrive as bigint, number, string, or `Long` and become milliseconds after a safe-range check.
7070

71-
**Theme.** The `--sq-*` custom properties on `.sq-app` are the theming contract, with light, dark, and system themes built in. React 18 and 19 are both supported.
71+
**Theme.** Views use BaseUI components with host-supplied `BaseProvider` and Styletron `Provider`. Custom CSS is limited to page layout, responsive grids, and wrapping long identifiers; colors and typography come directly from BaseUI. There is no independent library theme or styling engine. Hosts choose light, dark, custom, or system-driven themes and own SSR extraction and client hydration. The reference Next.js hosts share a request-scoped Styletron registry that streams new styles and hydrates them in the browser. BaseUI and Styletron React are peers alongside React and React DOM; React 18 and 19 are both supported.
7272

7373
## Observability and identity
7474

‎web/package.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
"typecheck": "pnpm --recursive --if-present run typecheck"
1717
},
1818
"devDependencies": {
19+
"baseui": "16.1.1",
20+
"styletron-engine-atomic": "1.6.2",
21+
"styletron-react": "6.1.1",
1922
"@axe-core/playwright": "4.10.2",
2023
"@bufbuild/buf": "1.73.0",
2124
"@bufbuild/protobuf": "2.15.0",
@@ -26,11 +29,13 @@
2629
"@playwright/test": "1.55.1",
2730
"@submitqueue/api": "workspace:*",
2831
"@types/node": "22.18.6",
32+
"@types/react": "^19.1.0",
33+
"@types/react-dom": "^19.1.0",
2934
"next": "16.3.8",
3035
"pino": "10.4.0",
3136
"react": "19.3.0",
3237
"react-dom": "19.3.0",
3338
"typescript": "5.9.3",
34-
"vitest": "4.0.7"
39+
"vitest": "4.1.11"
3540
}
3641
}

‎web/platform/BUILD.bazel‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
load("@aspect_rules_js//js:defs.bzl", "js_library")
12
load("@npm//:defs.bzl", "npm_link_all_packages")
23
load("@npm//web/platform:vitest/package_json.bzl", vitest = "bin")
34
load("@npm//web/service/submitqueue:eslint/package_json.bzl", eslint = "bin")
@@ -7,6 +8,13 @@ package(default_visibility = ["//web:__subpackages__"])
78

89
npm_link_all_packages(name = "node_modules")
910

11+
js_library(
12+
name = "test_support",
13+
testonly = True,
14+
types = ["test/rtl.tsx"],
15+
deps = [":node_modules"],
16+
)
17+
1018
web_library(
1119
assets = ["styles.css"],
1220
eslint = eslint,
@@ -17,12 +25,15 @@ web_library(
1725
":node_modules/@types/react-dom",
1826
":node_modules/jsdom",
1927
":node_modules/react-dom",
28+
":node_modules/styletron-engine-atomic",
29+
":node_modules/styletron-react",
2030
":node_modules/vitest",
2131
],
2232
vitest = vitest,
2333
deps = [
2434
":node_modules/@types/node",
2535
":node_modules/@types/react",
36+
":node_modules/baseui",
2637
":node_modules/react",
2738
],
2839
)

‎web/platform/auto-refresh.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, fireEvent, render, screen } from "@testing-library/react";
1+
import { act, fireEvent, render, screen } from "./test/rtl.js";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33

44
import { AutoRefresh } from "./auto-refresh.js";

‎web/platform/auto-refresh.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client";
22

33
import { useCallback, useEffect, useRef, useState } from "react";
4+
import { Button, SIZE } from "baseui/button/index.js";
5+
import { LabelXSmall } from "baseui/typography/index.js";
46

57
export interface AutoRefreshProps {
68
terminal?: boolean;
@@ -97,13 +99,13 @@ export function AutoRefresh({
9799

98100
return (
99101
<div className="sq-refresh" data-paused={paused || terminal}>
100-
<button disabled={refreshing || paused} onClick={() => void runRefresh()} type="button">
102+
<Button size={SIZE.compact} disabled={refreshing || paused} onClick={() => void runRefresh()} type="button">
101103
{refreshing ? "Refreshing…" : "Refresh"}
102-
</button>
103-
<span aria-live="polite">
104+
</Button>
105+
<LabelXSmall as="span" color="contentSecondary" aria-live="polite">
104106
{terminal ? "Automatic refresh stopped" : !automatic ? "Manual refresh" : paused ? "Automatic refresh paused" :
105107
transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"}
106-
</span>
108+
</LabelXSmall>
107109
</div>
108110
);
109111
}

‎web/platform/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
export * from "./navigation.js";
44
export * from "./auto-refresh.js";
55
export * from "./timestamp.js";
6+
export * from "./table.js";

‎web/platform/package.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
"typecheck": "tsc --noEmit"
4040
},
4141
"devDependencies": {
42+
"baseui": "16.1.1",
43+
"styletron-engine-atomic": "1.6.2",
44+
"styletron-react": "6.1.1",
4245
"@testing-library/jest-dom": "^6.6.3",
4346
"@testing-library/react": "^16.3.0",
4447
"@types/node": "22.18.6",
@@ -51,6 +54,9 @@
5154
"vitest": "4.1.11"
5255
},
5356
"peerDependencies": {
57+
"baseui": ">=16 <19",
58+
"react-dom": ">=18 <20",
59+
"styletron-react": ">=6",
5460
"react": ">=18 <20"
5561
}
5662
}

‎web/platform/styles.css‎

Lines changed: 14 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,19 @@
1-
/* Common native UI styles, scoped to .sq-app; hosts may override --sq-* properties. */
2-
.sq-app {
3-
color-scheme: light;
4-
--sq-surface: #fff;
5-
--sq-surface-muted: #f5f6f8;
6-
--sq-text: #202327;
7-
--sq-muted: #626a74;
8-
--sq-border: #e3e6e9;
9-
--sq-accent: #175fc5;
10-
--sq-accent-soft: #edf3ff;
11-
--sq-success: #187247;
12-
--sq-success-soft: #edf8f1;
13-
--sq-warning: #8b590f;
14-
--sq-warning-soft: #fff5e7;
15-
--sq-danger: #b33535;
16-
--sq-danger-soft: #fff0ef;
17-
--sq-font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
18-
--sq-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
19-
font-family: var(--sq-font);
20-
}
21-
.sq-app[data-theme="dark"] {
22-
color-scheme: dark;
23-
--sq-surface: #16181b;
24-
--sq-surface-muted: #202328;
25-
--sq-text: #e8eaed;
26-
--sq-muted: #a0a7b0;
27-
--sq-border: #33373d;
28-
--sq-accent: #6ea8ff;
29-
--sq-accent-soft: #1c2a40;
30-
--sq-success: #5cc28a;
31-
--sq-success-soft: #18301f;
32-
--sq-warning: #e0a84a;
33-
--sq-warning-soft: #36290f;
34-
--sq-danger: #ff7b72;
35-
--sq-danger-soft: #3a1f1e;
36-
}
37-
@media (prefers-color-scheme: dark) {
38-
.sq-app[data-theme="system"] {
39-
color-scheme: dark;
40-
--sq-surface: #16181b;
41-
--sq-surface-muted: #202328;
42-
--sq-text: #e8eaed;
43-
--sq-muted: #a0a7b0;
44-
--sq-border: #33373d;
45-
--sq-accent: #6ea8ff;
46-
--sq-accent-soft: #1c2a40;
47-
--sq-success: #5cc28a;
48-
--sq-success-soft: #18301f;
49-
--sq-warning: #e0a84a;
50-
--sq-warning-soft: #36290f;
51-
--sq-danger: #ff7b72;
52-
--sq-danger-soft: #3a1f1e;
53-
}
54-
}
55-
.sq-app, .sq-app * { box-sizing: border-box; }
56-
.sq-app { background: var(--sq-surface); color: var(--sq-text); font-size: 14px; line-height: 1.5; }
57-
.sq-app a { color: var(--sq-accent); text-decoration: none; overflow-wrap: anywhere; }
58-
.sq-app a:hover { text-decoration: underline; }
59-
.sq-app a:focus-visible, .sq-app button:focus-visible, .sq-app input:focus-visible, .sq-app select:focus-visible, .sq-app summary:focus-visible {
60-
outline: 3px solid var(--sq-accent); outline-offset: 3px;
61-
}
62-
.sq-app h1 { font-size: 26px; line-height: 1.25; margin: 0 0 10px; letter-spacing: -.5px; }
63-
.sq-app h2 { font-size: 16px; margin: 0 0 12px; }
64-
.sq-app p { margin: 8px 0; }
65-
.sq-app button, .sq-app input, .sq-app select {
66-
font: inherit; border: 1px solid var(--sq-border); border-radius: 6px;
67-
color: var(--sq-text); background: var(--sq-surface); padding: 8px 12px;
68-
}
69-
.sq-app button, .sq-app summary { cursor: pointer; }
70-
.sq-app button:disabled { cursor: default; opacity: .65; }
71-
.sq-app button:hover:not(:disabled) { background: var(--sq-surface-muted); }
72-
.sq-app input { min-width: 0; }
73-
.sq-app.site-frame { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; }
1+
/* Shared page layout; BaseUI owns component styling and theme tokens. */
2+
.sq-app.site-frame { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto; }
743
.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: min(1200px, calc(100% - 48px)); margin-inline: auto; }
75-
.sq-app .site-header { min-height: 80px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--sq-border); }
76-
.sq-app .brand { display: flex; align-items: center; gap: 9px; color: var(--sq-text); font-weight: 600; font-size: 18px; }
77-
.sq-app .brand-mark { background: var(--sq-text); color: var(--sq-surface); border-radius: 6px; padding: 5px 7px; font-size: 12px; }
78-
.sq-app .environment-pill, .sq-app .site-footer, .sq-app .sq-secondary { color: var(--sq-muted); font-size: 12px; }
79-
.sq-app .site-footer { padding: 20px 0; border-top: 1px solid var(--sq-border); }
4+
.sq-app .site-header { min-height: 80px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
5+
.sq-app .brand { display: inline-flex; align-items: center; gap: 8px; }
806
.sq-app .shell { padding-block: 28px 48px; }
81-
.sq-app .centered-state { min-height: 100vh; display: grid; place-items: center; }
827
.sq-app .page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 24px; }
83-
.sq-app .page-heading p { color: var(--sq-muted); font-size: 12px; }
84-
.sq-app .eyebrow { font-size: 12px; margin: 0 0 10px; color: var(--sq-muted); }
85-
.sq-app .page-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
86-
.sq-app .sq-refresh { display: flex; align-items: center; gap: 10px; color: var(--sq-muted); font-size: 12px; }
87-
.sq-app .sq-error { padding: 16px; border-radius: 6px; background: var(--sq-danger-soft); }
88-
.sq-app .sq-error p { overflow-wrap: anywhere; }
89-
.sq-app .button-link { display: inline-block; margin-top: 16px; }
90-
.sq-app .state-card { max-width: 700px; }
91-
@media (max-width: 680px) {
92-
.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: calc(100% - 32px); }
93-
.sq-app .site-header { gap: 12px; flex-wrap: wrap; padding-block: 14px; }
94-
.sq-app button, .sq-app select, .sq-app input { min-height: 44px; }
95-
}
96-
97-
98-
.sq-app .sq-table-scroll { overflow-x: auto; }
99-
.sq-app table { width: 100%; border-collapse: collapse; text-align: left; }
100-
.sq-app th { font-weight: 500; color: var(--sq-muted); font-size: 12px; padding: 10px 12px; border-bottom: 1px solid var(--sq-border); }
101-
.sq-app td { padding: 18px 12px; border-bottom: 1px solid var(--sq-border); vertical-align: top; }
102-
.sq-app th:first-child, .sq-app td:first-child { padding-left: 0; }
103-
.sq-app th:last-child, .sq-app td:last-child { padding-right: 0; }
104-
.sq-app td time { font-size: 12px; color: var(--sq-muted); }
105-
.sq-app .sq-table-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 18px; font-size: 12px; color: var(--sq-muted); }
106-
.sq-app .sq-status, .sq-app .sq-event { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 12px; color: var(--sq-accent); background: var(--sq-accent-soft); white-space: nowrap; }
107-
.sq-app .sq-status[data-tone="neutral"] { color: var(--sq-muted); background: var(--sq-surface-muted); }
108-
.sq-app .sq-status[data-tone="success"] { color: var(--sq-success); background: var(--sq-success-soft); }
109-
.sq-app .sq-status[data-tone="danger"] { color: var(--sq-danger); background: var(--sq-danger-soft); }
110-
.sq-app .sq-status[data-tone="warning"] { color: var(--sq-warning); background: var(--sq-warning-soft); }
111-
.sq-app .sq-empty { padding: 35px 0; color: var(--sq-muted); }
112-
.sq-app .sq-breadcrumb { display: flex; gap: 10px; flex-wrap: wrap; color: var(--sq-muted); font-size: 12px; margin-bottom: 24px; }
113-
.sq-app .sq-detail-columns { display: grid; grid-template-columns: 1fr 1.15fr; gap: 38px; }
114-
.sq-app .sq-facts { margin: 0; }
115-
.sq-app .sq-facts > div { padding: 11px 0; border-bottom: 1px solid var(--sq-border); }
116-
.sq-app dt { color: var(--sq-muted); font-size: 12px; margin-bottom: 5px; }
117-
.sq-app dd { margin: 0; overflow-wrap: anywhere; }
8+
.sq-app .page-actions, .sq-app .sq-refresh, .sq-app .sq-breadcrumb { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
9+
.sq-app .page-actions { margin-top: 12px; }
10+
.sq-app .sq-breadcrumb { margin-bottom: 24px; }
11+
.sq-app .sq-table-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 16px; }
12+
.sq-app .sq-detail-columns { display: grid; grid-template-columns: 1fr 1.15fr; gap: 32px; }
13+
.sq-app a, .sq-app code, .sq-app dd, .sq-app h1, .sq-app .sq-row-error { overflow-wrap: anywhere; }
14+
.sq-app .sq-row-error { max-width: 36ch; }
11815
@media (max-width: 680px) {
119-
.sq-app .sq-detail-columns { grid-template-columns: 1fr; gap: 20px; }
16+
.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: calc(100% - 32px); }
17+
.sq-app .site-header { gap: 12px; flex-wrap: wrap; padding-block: 16px; }
18+
.sq-app .sq-detail-columns { grid-template-columns: 1fr; gap: 24px; }
12019
}

‎web/platform/table.tsx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"use client";
2+
3+
import {
4+
StyledRoot,
5+
StyledTable,
6+
StyledTableBody,
7+
StyledTableHead,
8+
StyledTableHeadCell,
9+
StyledTableHeadRow,
10+
} from "baseui/table-semantic/index.js";
11+
import type { ReactNode } from "react";
12+
13+
export function Table({ label, headers, children }: {
14+
label: string;
15+
headers: string[];
16+
children: ReactNode;
17+
}) {
18+
return <StyledRoot>
19+
<StyledTable aria-label={label}>
20+
<StyledTableHead><StyledTableHeadRow>
21+
{headers.map(header => <StyledTableHeadCell key={header} scope="col">{header}</StyledTableHeadCell>)}
22+
</StyledTableHeadRow></StyledTableHead>
23+
<StyledTableBody>{children}</StyledTableBody>
24+
</StyledTable>
25+
</StyledRoot>;
26+
}

‎web/platform/test/rtl.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { render as renderRTL, type RenderOptions, type RenderResult } from "@testing-library/react";
2+
import { BaseProvider, LightTheme } from "baseui";
3+
import { useId, useState, type ReactElement, type ReactNode } from "react";
4+
import { Client } from "styletron-engine-atomic";
5+
import { Provider } from "styletron-react";
6+
7+
export * from "@testing-library/react";
8+
9+
export function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">): RenderResult {
10+
function Wrapper({ children }: { children: ReactNode }) {
11+
const prefix = useId().replaceAll(":", "");
12+
const [engine] = useState(() => new Client({ prefix: `sq-test-${prefix}-` }));
13+
return <Provider value={engine}><BaseProvider theme={LightTheme}>{children}</BaseProvider></Provider>;
14+
}
15+
return renderRTL(ui, { wrapper: Wrapper, ...options });
16+
}

0 commit comments

Comments
 (0)