Skip to content
Merged
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
8 changes: 4 additions & 4 deletions doc/rfc/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ The boundaries are enforced, not just documented. The module imports no generate
| Module | Host |
|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| The gateway contract, default error classification, cursor format, and instrumentation points | Gateway transport and credentials, cursor signing keys, logger, meter, tracer, configuration, and deployment |
| The gateway contract and default error classification | Gateway transport and credentials, logger, meter, tracer, configuration, and deployment |

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

## URLs

Expand Down 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": "18.2.0",
"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>
);
}
18 changes: 18 additions & 0 deletions web/platform/facts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import type { ReactNode } from "react";
import { styled } from "baseui/index.js";
import { LabelXSmall, ParagraphSmall } from "baseui/typography/index.js";

const FactRow = styled("div", ({ $theme }) => ({
paddingTop: $theme.sizing.scale500,
paddingBottom: $theme.sizing.scale500,
borderBottom: `1px solid ${$theme.colors.borderOpaque}`,
}));

export function Fact({ label, children }: { label: string; children: ReactNode }) {
return <FactRow>
<LabelXSmall as="dt" color="contentSecondary" marginBottom="scale300">{label}</LabelXSmall>
<ParagraphSmall as="dd" margin="0">{children}</ParagraphSmall>
</FactRow>;
}
2 changes: 2 additions & 0 deletions web/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
export * from "./navigation.js";
export * from "./auto-refresh.js";
export * from "./timestamp.js";
export * from "./table.js";
export * from "./facts.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": "18.2.0",
"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