Skip to content

Commit ba1e49f

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

52 files changed

Lines changed: 1964 additions & 646 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`. 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": "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: 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": "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: 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)