Skip to content

Commit b2acd5b

Browse files
mnoah1behinddwallsclaude
committed
feat(web): adopt BaseUI with host-owned theming
Use BaseUI directly in SubmitQueue and Stovepipe views, with host-owned themes, providers, and Styletron SSR/hydration. Preserve gateway-owned pagination and project results, and correct filter accessibility for mouse and keyboard use. Validated all 33 web checks and production browser targets, plus repository formatting and dependency checks. React 18 packages hydrate under light, dark, and custom host themes. Co-Authored-By: Preetam Dwivedi <preetam@uber.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent c06f4a4 commit b2acd5b

53 files changed

Lines changed: 2130 additions & 691 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: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ The boundaries are enforced, not just documented. The module imports no generate
3333
| Module | Host |
3434
|---|---|
3535
| Routing in both directions, catalog validation, request and change lookup, receipt windows, pagination, internal links | One catch-all route that hands the still-encoded path to the host pipeline |
36-
| Serializable page models with titles, complete pages, shell, the stylesheet and theme tokens, status and error states | Authentication, the principal it produces, and optional per-route authorization |
36+
| Serializable page models with titles, complete BaseUI pages, shell, status and error states | Authentication, the principal it produces, optional per-route authorization, and BaseUI theme and Styletron providers |
3737
| Client navigation, refresh, and polling policy, expressed through a navigation adapter | The adapter: the framework's link, push, and refresh; the host owns the page model and history |
38-
| The gateway contract, default error classification, cursor format, and instrumentation points | Gateway transport and credentials, cursor signing keys, logger, meter, tracer, configuration, and deployment |
38+
| The gateway contract and default error classification | Gateway transport and credentials, logger, meter, tracer, configuration, and deployment |
3939

40-
Components render serializable props and never fetch; sessions, gateway clients, and protobuf messages stay on the server. A host that supplies no navigation adapter gets ordinary document navigation and manual refresh. The host loads the module's one self-contained stylesheet, by importing it or by serving the file and linking to it. The module caches nothing unless the host configures a catalog cache keyed by viewer, and the host resolves the gateway per request when visibility depends on the viewer.
40+
Components render serializable props and never fetch; sessions, gateway clients, and protobuf messages stay on the server. A host that supplies no navigation adapter gets ordinary document navigation and manual refresh. The host loads the module's stylesheet for long-identifier wrapping, by importing it or by serving the file and linking to it. The module caches nothing unless the host configures a catalog cache keyed by viewer, and the host resolves the gateway per request when visibility depends on the viewer.
4141

4242
## URLs
4343

@@ -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`. BaseUI owns layout, responsive grids, colors and typography; the stylesheet only enables wrapping long identifiers. 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": "18.2.0",
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: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"use client";
22

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

58
export interface AutoRefreshProps {
69
terminal?: boolean;
@@ -96,14 +99,14 @@ export function AutoRefresh({
9699
}, [automatic, cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]);
97100

98101
return (
99-
<div className="sq-refresh" data-paused={paused || terminal}>
100-
<button disabled={refreshing || paused} onClick={() => void runRefresh()} type="button">
102+
<Block display="flex" alignItems="center" flexWrap gridGap="scale500" data-paused={paused || terminal}>
103+
<Button size={SIZE.compact} disabled={refreshing || paused} onClick={() => void runRefresh()} type="button">
101104
{refreshing ? "Refreshing…" : "Refresh"}
102-
</button>
103-
<span aria-live="polite">
105+
</Button>
106+
<LabelXSmall as="span" color="contentSecondary" aria-live="polite">
104107
{terminal ? "Automatic refresh stopped" : !automatic ? "Manual refresh" : paused ? "Automatic refresh paused" :
105108
transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"}
106-
</span>
107-
</div>
109+
</LabelXSmall>
110+
</Block>
108111
);
109112
}

‎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": "18.2.0",
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: 1 addition & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1 @@
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; }
74-
.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); }
80-
.sq-app .shell { padding-block: 28px 48px; }
81-
.sq-app .centered-state { min-height: 100vh; display: grid; place-items: center; }
82-
.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; }
118-
@media (max-width: 680px) {
119-
.sq-app .sq-detail-columns { grid-template-columns: 1fr; gap: 20px; }
120-
}
1+
.sq-app :is(a, code, dd, h1, p, [role="alert"]) { overflow-wrap: anywhere; }

‎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 tabIndex={0} role="region" aria-label={label}>
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)