Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/rfc/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Summary is the default request view, and `?view=history` makes History shareable

**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.

**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.
**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.

## Observability and identity

Expand Down
7 changes: 6 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"typecheck": "pnpm --recursive --if-present run typecheck"
},
"devDependencies": {
"baseui": "16.1.1",
"styletron-engine-atomic": "1.6.2",
"styletron-react": "6.1.1",
"@axe-core/playwright": "4.10.2",
"@bufbuild/buf": "1.73.0",
"@bufbuild/protobuf": "2.15.0",
Expand All @@ -26,11 +29,13 @@
"@playwright/test": "1.55.1",
"@submitqueue/api": "workspace:*",
"@types/node": "22.18.6",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"next": "16.3.8",
"pino": "10.4.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"typescript": "5.9.3",
"vitest": "4.0.7"
"vitest": "4.1.11"
}
}
11 changes: 11 additions & 0 deletions web/platform/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
load("@aspect_rules_js//js:defs.bzl", "js_library")
load("@npm//:defs.bzl", "npm_link_all_packages")
load("@npm//web/platform:vitest/package_json.bzl", vitest = "bin")
load("@npm//web/service/submitqueue:eslint/package_json.bzl", eslint = "bin")
Expand All @@ -7,6 +8,13 @@ package(default_visibility = ["//web:__subpackages__"])

npm_link_all_packages(name = "node_modules")

js_library(
name = "test_support",
testonly = True,
types = ["test/rtl.tsx"],
deps = [":node_modules"],
)

web_library(
assets = ["styles.css"],
eslint = eslint,
Expand All @@ -17,12 +25,15 @@ web_library(
":node_modules/@types/react-dom",
":node_modules/jsdom",
":node_modules/react-dom",
":node_modules/styletron-engine-atomic",
":node_modules/styletron-react",
":node_modules/vitest",
],
vitest = vitest,
deps = [
":node_modules/@types/node",
":node_modules/@types/react",
":node_modules/baseui",
":node_modules/react",
],
)
Expand Down
2 changes: 1 addition & 1 deletion web/platform/auto-refresh.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "./test/rtl.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { AutoRefresh } from "./auto-refresh.js";
Expand Down
15 changes: 9 additions & 6 deletions web/platform/auto-refresh.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"use client";

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

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

return (
<div className="sq-refresh" data-paused={paused || terminal}>
<button disabled={refreshing || paused} onClick={() => void runRefresh()} type="button">
<Block display="flex" alignItems="center" flexWrap gridGap="scale500" data-paused={paused || terminal}>
<Button size={SIZE.compact} disabled={refreshing || paused} onClick={() => void runRefresh()} type="button">
{refreshing ? "Refreshing…" : "Refresh"}
</button>
<span aria-live="polite">
</Button>
<LabelXSmall as="span" color="contentSecondary" aria-live="polite">
{terminal ? "Automatic refresh stopped" : !automatic ? "Manual refresh" : paused ? "Automatic refresh paused" :
transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"}
</span>
</div>
</LabelXSmall>
</Block>
);
}
1 change: 1 addition & 0 deletions web/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
export * from "./navigation.js";
export * from "./auto-refresh.js";
export * from "./timestamp.js";
export * from "./table.js";
6 changes: 6 additions & 0 deletions web/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"baseui": "16.1.1",
"styletron-engine-atomic": "1.6.2",
"styletron-react": "6.1.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "22.18.6",
Expand All @@ -51,6 +54,9 @@
"vitest": "4.1.11"
},
"peerDependencies": {
"baseui": ">=16 <19",
"react-dom": ">=18 <20",
"styletron-react": ">=6",
"react": ">=18 <20"
}
}
121 changes: 1 addition & 120 deletions web/platform/styles.css
Original file line number Diff line number Diff line change
@@ -1,120 +1 @@
/* Common native UI styles, scoped to .sq-app; hosts may override --sq-* properties. */
.sq-app {
color-scheme: light;
--sq-surface: #fff;
--sq-surface-muted: #f5f6f8;
--sq-text: #202327;
--sq-muted: #626a74;
--sq-border: #e3e6e9;
--sq-accent: #175fc5;
--sq-accent-soft: #edf3ff;
--sq-success: #187247;
--sq-success-soft: #edf8f1;
--sq-warning: #8b590f;
--sq-warning-soft: #fff5e7;
--sq-danger: #b33535;
--sq-danger-soft: #fff0ef;
--sq-font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--sq-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--sq-font);
}
.sq-app[data-theme="dark"] {
color-scheme: dark;
--sq-surface: #16181b;
--sq-surface-muted: #202328;
--sq-text: #e8eaed;
--sq-muted: #a0a7b0;
--sq-border: #33373d;
--sq-accent: #6ea8ff;
--sq-accent-soft: #1c2a40;
--sq-success: #5cc28a;
--sq-success-soft: #18301f;
--sq-warning: #e0a84a;
--sq-warning-soft: #36290f;
--sq-danger: #ff7b72;
--sq-danger-soft: #3a1f1e;
}
@media (prefers-color-scheme: dark) {
.sq-app[data-theme="system"] {
color-scheme: dark;
--sq-surface: #16181b;
--sq-surface-muted: #202328;
--sq-text: #e8eaed;
--sq-muted: #a0a7b0;
--sq-border: #33373d;
--sq-accent: #6ea8ff;
--sq-accent-soft: #1c2a40;
--sq-success: #5cc28a;
--sq-success-soft: #18301f;
--sq-warning: #e0a84a;
--sq-warning-soft: #36290f;
--sq-danger: #ff7b72;
--sq-danger-soft: #3a1f1e;
}
}
.sq-app, .sq-app * { box-sizing: border-box; }
.sq-app { background: var(--sq-surface); color: var(--sq-text); font-size: 14px; line-height: 1.5; }
.sq-app a { color: var(--sq-accent); text-decoration: none; overflow-wrap: anywhere; }
.sq-app a:hover { text-decoration: underline; }
.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 {
outline: 3px solid var(--sq-accent); outline-offset: 3px;
}
.sq-app h1 { font-size: 26px; line-height: 1.25; margin: 0 0 10px; letter-spacing: -.5px; }
.sq-app h2 { font-size: 16px; margin: 0 0 12px; }
.sq-app p { margin: 8px 0; }
.sq-app button, .sq-app input, .sq-app select {
font: inherit; border: 1px solid var(--sq-border); border-radius: 6px;
color: var(--sq-text); background: var(--sq-surface); padding: 8px 12px;
}
.sq-app button, .sq-app summary { cursor: pointer; }
.sq-app button:disabled { cursor: default; opacity: .65; }
.sq-app button:hover:not(:disabled) { background: var(--sq-surface-muted); }
.sq-app input { min-width: 0; }
.sq-app.site-frame { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; }
.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: min(1200px, calc(100% - 48px)); margin-inline: auto; }
.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); }
.sq-app .brand { display: flex; align-items: center; gap: 9px; color: var(--sq-text); font-weight: 600; font-size: 18px; }
.sq-app .brand-mark { background: var(--sq-text); color: var(--sq-surface); border-radius: 6px; padding: 5px 7px; font-size: 12px; }
.sq-app .environment-pill, .sq-app .site-footer, .sq-app .sq-secondary { color: var(--sq-muted); font-size: 12px; }
.sq-app .site-footer { padding: 20px 0; border-top: 1px solid var(--sq-border); }
.sq-app .shell { padding-block: 28px 48px; }
.sq-app .centered-state { min-height: 100vh; display: grid; place-items: center; }
.sq-app .page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 24px; }
.sq-app .page-heading p { color: var(--sq-muted); font-size: 12px; }
.sq-app .eyebrow { font-size: 12px; margin: 0 0 10px; color: var(--sq-muted); }
.sq-app .page-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
.sq-app .sq-refresh { display: flex; align-items: center; gap: 10px; color: var(--sq-muted); font-size: 12px; }
.sq-app .sq-error { padding: 16px; border-radius: 6px; background: var(--sq-danger-soft); }
.sq-app .sq-error p { overflow-wrap: anywhere; }
.sq-app .button-link { display: inline-block; margin-top: 16px; }
.sq-app .state-card { max-width: 700px; }
@media (max-width: 680px) {
.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: calc(100% - 32px); }
.sq-app .site-header { gap: 12px; flex-wrap: wrap; padding-block: 14px; }
.sq-app button, .sq-app select, .sq-app input { min-height: 44px; }
}


.sq-app .sq-table-scroll { overflow-x: auto; }
.sq-app table { width: 100%; border-collapse: collapse; text-align: left; }
.sq-app th { font-weight: 500; color: var(--sq-muted); font-size: 12px; padding: 10px 12px; border-bottom: 1px solid var(--sq-border); }
.sq-app td { padding: 18px 12px; border-bottom: 1px solid var(--sq-border); vertical-align: top; }
.sq-app th:first-child, .sq-app td:first-child { padding-left: 0; }
.sq-app th:last-child, .sq-app td:last-child { padding-right: 0; }
.sq-app td time { font-size: 12px; color: var(--sq-muted); }
.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); }
.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; }
.sq-app .sq-status[data-tone="neutral"] { color: var(--sq-muted); background: var(--sq-surface-muted); }
.sq-app .sq-status[data-tone="success"] { color: var(--sq-success); background: var(--sq-success-soft); }
.sq-app .sq-status[data-tone="danger"] { color: var(--sq-danger); background: var(--sq-danger-soft); }
.sq-app .sq-status[data-tone="warning"] { color: var(--sq-warning); background: var(--sq-warning-soft); }
.sq-app .sq-empty { padding: 35px 0; color: var(--sq-muted); }
.sq-app .sq-breadcrumb { display: flex; gap: 10px; flex-wrap: wrap; color: var(--sq-muted); font-size: 12px; margin-bottom: 24px; }
.sq-app .sq-detail-columns { display: grid; grid-template-columns: 1fr 1.15fr; gap: 38px; }
.sq-app .sq-facts { margin: 0; }
.sq-app .sq-facts > div { padding: 11px 0; border-bottom: 1px solid var(--sq-border); }
.sq-app dt { color: var(--sq-muted); font-size: 12px; margin-bottom: 5px; }
.sq-app dd { margin: 0; overflow-wrap: anywhere; }
@media (max-width: 680px) {
.sq-app .sq-detail-columns { grid-template-columns: 1fr; gap: 20px; }
}
.sq-app :is(a, code, dd, h1, p, [role="alert"]) { overflow-wrap: anywhere; }
26 changes: 26 additions & 0 deletions web/platform/table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import {
StyledRoot,
StyledTable,
StyledTableBody,
StyledTableHead,
StyledTableHeadCell,
StyledTableHeadRow,
} from "baseui/table-semantic/index.js";
import type { ReactNode } from "react";

export function Table({ label, headers, children }: {
label: string;
headers: string[];
children: ReactNode;
}) {
return <StyledRoot tabIndex={0} role="region" aria-label={label}>
<StyledTable aria-label={label}>
<StyledTableHead><StyledTableHeadRow>
{headers.map(header => <StyledTableHeadCell key={header} scope="col">{header}</StyledTableHeadCell>)}
</StyledTableHeadRow></StyledTableHead>
<StyledTableBody>{children}</StyledTableBody>
</StyledTable>
</StyledRoot>;
}
16 changes: 16 additions & 0 deletions web/platform/test/rtl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { render as renderRTL, type RenderOptions, type RenderResult } from "@testing-library/react";
import { BaseProvider, LightTheme } from "baseui";
import { useId, useState, type ReactElement, type ReactNode } from "react";
import { Client } from "styletron-engine-atomic";
import { Provider } from "styletron-react";

export * from "@testing-library/react";

export function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">): RenderResult {
function Wrapper({ children }: { children: ReactNode }) {
const prefix = useId().replaceAll(":", "");
const [engine] = useState(() => new Client({ prefix: `sq-test-${prefix}-` }));
return <Provider value={engine}><BaseProvider theme={LightTheme}>{children}</BaseProvider></Provider>;
}
return renderRTL(ui, { wrapper: Wrapper, ...options });
}
1 change: 1 addition & 0 deletions web/platform/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default defineConfig({
jsx: "automatic",
},
test: {
maxWorkers: 2,
environment: "jsdom",
globals: true,
setupFiles: ["./test-setup.ts"],
Expand Down
Loading
Loading