Skip to content
Closed
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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,50 @@ jobs:
- name: Tests
run: uv run pytest -q

ui:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Enable pnpm via corepack (Node is preinstalled on the runner)
run: corepack enable && corepack prepare pnpm@11.10.0 --activate
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint, format, and typecheck
run: pnpm check
- name: Build
run: pnpm build
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.11"
- name: Verify src/apiSchema.ts still matches the server's OpenAPI schema
# The browser's types are generated from the server's own declaration.
# If a contract change lands without regenerating, they silently drift
# apart again -- which is exactly the failure this generation replaced.
run: |
pnpm gen:api
if ! git diff --exit-code src/apiSchema.ts; then
echo "src/apiSchema.ts is stale: run \`pnpm gen:api\` and commit the result." >&2
exit 1
fi
- name: Verify the hflow-server wheel would carry the built frontend
working-directory: .
# The UI is only usable if the built assets reach the package; without
# this the wheel builds happily and serves the "frontend not built"
# placeholder instead.
run: |
rm -rf packages/hflow-server/src/hflow_server/static
mkdir -p packages/hflow-server/src/hflow_server/static
cp -r ui/dist/. packages/hflow-server/src/hflow_server/static/
uv build --package hflow-server
wheel_path="$(printf '%s\n' dist/hflow_server-*.whl)"
if ! unzip -l "${wheel_path}" | grep -q 'hflow_server/static/index.html'; then
echo "${wheel_path} has no hflow_server/static/index.html -- the frontend build did not reach the package." >&2
exit 1
fi

links:
runs-on: ubuntu-latest
steps:
Expand Down
3 changes: 3 additions & 0 deletions ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.local
140 changes: 140 additions & 0 deletions ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# HFlow workspace UI (frontend)

Read-only React SPA for browsing an hflow catalog. Served in production by the
`hflow-server` FastAPI package (`packages/hflow-server/`), which copies the built
assets from `ui/dist` into `hflow_ui/static/` at integration time.

## Development

Requires Node >= 20.19 and pnpm.

```bash
cd ui
pnpm install
pnpm dev # Vite dev server; proxies /api to http://127.0.0.1:4356
```

Start the API in another terminal (`hflow serve` binds 127.0.0.1:4356 by
default), then open the Vite URL. The API server authenticates nobody, so the
proxied calls just work — there is no credential to carry over.

`pnpm preview` serves the built bundle with the same `/api` proxy — use it when
you need to see production behaviour, such as the pre-paint theme script, which
dev cannot show because dev injects the stylesheet from JS.

## Checks and build

```bash
pnpm check # tsc --noEmit && biome check .
pnpm format # biome check --write .
pnpm build # type-checks, then emits ui/dist
```

The frontend must stay fully offline: no CDN scripts, no web fonts, no remote
assets of any kind. Everything ships in the bundle.

## What this UI is built out of

Prefer a well-maintained library over a hand-rolled one, and prefer the
headless kind — take the behaviour, leave the visual language. The look is
ours: one hand-written `src/styles.css` over CSS custom properties, no
framework, no utility classes.

- **[radix-ui](https://www.radix-ui.com/primitives)** (the umbrella package —
one dependency, tree-shakes to the same bytes as the ~55 scoped ones) for
every overlay and composite widget: `Dialog`, `Popover`, `DropdownMenu`,
`Tabs`, `Tooltip`. It brings focus traps, floating-ui positioning,
portalling, dismiss layers, roving tabindex and the ARIA wiring. Style Radix
parts with the existing classes and their `data-state` / `data-highlighted`
/ `data-disabled` attributes.
- `components/CatalogTree.tsx` stays hand-rolled on purpose: it is a correct
disclosure pattern and deliberately does not claim `role="tree"`.
- **[lucide-react](https://lucide.dev)** for every glyph. Import icons
directly — `import { Search } from "lucide-react"` — and let the icon-scale
block in `styles.css` size them; do not pass `size` at call sites. Icons are
decorative and lucide marks them `aria-hidden` unless you give them an
`aria-*`, `role` or `title` prop. `components/BrandMark.tsx` is the one glyph
from outside lucide: it is the real Hebbian Robotics mark, and
`docs/assets/hebbian-logo-on-black.svg` is the source of truth for the
artwork. Its five paths are copied verbatim into both `BrandMark.tsx` and the
favicon data URI in `index.html`, so re-cutting the logo changes all three.
The component drops the artboard's black rect, paints with `currentColor` so
the mark takes the accent in both themes, and tightens the viewBox to the
glyph's own bounds; the mark is 0.77 : 1, so `.brand-mark` sizes it by height
rather than taking the square `.lucide` box.
- **[@tanstack/react-query](https://tanstack.com/query)** for every server
read and write, **[@tanstack/react-table](https://tanstack.com/table)** for
the grids, **[react-router](https://reactrouter.com)** for routing, and
**[CodeMirror 6](https://codemirror.net)** for the SQL editor.

### Theme

Three states — `"system"` (the default), `"light"`, `"dark"` — modelled in
`src/theme.ts` and persisted in `localStorage` under `hflow-ui-theme`. The
choice is stamped on `<html>` as `data-theme`; `"system"` sets no attribute and
lets the stylesheet's `prefers-color-scheme` block answer, with a `matchMedia`
listener keeping the control's label honest as the OS flips.

`components/ThemeControl.tsx` drives those three states from one quiet icon
button in the rail's footer that cycles `system → light → dark`, showing the
glyph of the state it is in. Because the glyph alone says neither what the
state is nor what a press will do, the accessible name says both ("Theme:
following system (light). Switch to light.") and a visually-hidden
`role="status"` region announces the new state after each press.

`styles.css` is authoritative for all three: every colour token has a value in
the base `:root` block, and the two dark blocks (OS dark, explicit dark) are
the same palette twice and must stay in sync. The inline script at the top of
`index.html` applies the stored choice before the first paint — it duplicates
the storage key and the attribute contract, so change the two together.

### Nav rail width

The rail collapses to icons only. Two states — `"expanded"` (the default),
`"collapsed"` — modelled in `src/railLayout.ts`, persisted in `localStorage`
under `hflow-ui-rail`, and stamped on `<html>` as `data-rail` (expanded sets no
attribute). The same inline script in `index.html` applies it before the first
paint, so a reload never shows the wrong width for a frame.

Three rules the markup keeps, and CSS cannot:

- every destination keeps its label in the DOM in both states — collapsed,
`styles.css` clips it rather than removing it, so a link is never an unnamed
icon;
- collapsed, each destination and each rail control grows a Radix `Tooltip`
(not `title`, which no keyboard reaches and no stylesheet can touch). The
Radix wrapper is mounted in both states with a controlled `open`, so
collapsing does not rebuild the element under the reader's focus;
- the collapse control names the action rather than the state ("Collapse the
sidebar to icons only") and carries `aria-expanded` + `aria-controls`.

At and below 780px the rail is already a horizontal bar, so the collapse is
suspended: the labels come back, the control leaves the layout, and the stored
choice is untouched and returns when the window widens. `railLayout.ts`
duplicates that breakpoint so the markup agrees with the stylesheet about
whether the preference is in force — change the two together.

### Colour

One rule: **chrome is neutral, colour means something.**

Backgrounds, ink, borders, the accent and the focus ring are greys, near-black
and near-white. `--accent` is the far end of that ramp and *inverts* between
themes (near-black on light, near-white on dark), which is why
`--accent-contrast` exists — "the ink that reads on a solid accent fill" cannot
be spelled at the call site. `--boundary` is the one grey held at 3:1 against
every surface it lands on: the outline of anything a reader must be able to
find (fields, buttons, graph nodes, graph connectors), as distinct from the
decorative `--border` / `--border-strong` hairlines.

The hues left in the file all answer a question about the data:
`--ok` / `--warn` / `--err` for status, `--viz-1..3` for interval kinds (a
categorical encoding; `--viz-other` is a true grey because "everything else"
should read as the absence of an identity). Work in flight — `running`,
`deferred` — is the `--run` tone, aliased to the neutral accent: it is the most
separable slot left beside green, amber, red and faint grey, and it separates
by lightness so it survives every form of colour blindness. `src/runState.ts`
owns which Airflow state maps to which tone.

If you need a new colour, first check whether what you are building is chrome.
If it is, it does not get one.
35 changes: 35 additions & 0 deletions ui/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"includes": ["**", "!dist", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
67 changes: 67 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<!--
Favicon: the Hebbian Robotics mark, as a data: URI so the tab icon costs
no request and the app stays fully offline.

Same five paths and the same tightened viewBox as src/components/
BrandMark.tsx; docs/assets/hebbian-logo-on-black.svg is the source of
truth for the artwork, so re-cutting the logo means updating all three.

One difference from BrandMark, unavoidable: a favicon cannot inherit
currentColor, so the fill is literal. The app accent is now a NEUTRAL that
inverts between themes (near-black light, near-white dark), and neither
end survives the other's tab strip, so the favicon takes the mid-grey they
straddle. #808080 is the grey that clears 3:1 against every tab strip a
browser paints: 3.95:1 on white, 3.01:1 on Chrome's light strip (#dee1e6),
3.09:1 on Chrome's dark strip (#323639), 3.59:1 on Firefox's (#2b2a33).
Lighter greys buy dark-strip contrast at the light strip's expense and
drop under 3:1 there, so this is the middle and not a rounding.
-->
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='1.79 -0.23 106.03 137.62'%3E%3Cg fill='%23808080'%3E%3Cpath d='m24.3 68v-61c-0.2-3.5-3.1-6.5-6.3-7-1.9-0.3-6.3-0.3-8.6 0-2.5 0.2-4 1-5.4 2.6-1.8 2.1-2.2 3.6-2.2 7.4v114.2 2.4c0.1 4.1 0.2 5.2 1.4 7.1s3 3.3 5.3 3.6 6.9-0.3 9.2-0.9c2.7-0.6 5.5-2.7 6.3-5.9 0.5-2.4 0.3-0.9 0.3-62.5z'/%3E%3Cpath d='m99.9 1.7c-1.7-0.1-5.3-0.1-7.5 0.2-3.1 0.5-5.9 3.1-6.3 6.3-0.2 1.7-1.2 59.2-1.2 59.8l0.1 58.9c0.1 2.6 0.1 4 1.3 5.9 1.3 2 3.2 3.3 5.6 3.7 2.3 0.3 6.5 0.4 9.1 0.1 2.8-0.4 5.7-2.5 6.5-5.6 0.4-1.6 0.3-4.4 0.3-6.5v-112.1c0-3.4-0.1-5.4-1.3-7-1.2-2-3.6-3.5-6.6-3.7z'/%3E%3Cpath d='m44 16.2c-0.6 1.6-0.6 3.5-0.6 6.8v15c0.1 4.4 0.1 6 1.6 8s3.1 3.4 5.5 3.7c1.8 0.3 6.4 0.4 8.5 0.1 3.3-0.4 6.2-3 6.7-6.2 0.2-1.5 0.2-2.4 0.2-6.1l-0.1-16.5c0-3.6-0.4-5.1-2.2-7.1-1.1-1.2-2.8-2.4-5.1-2.7-1.4-0.2-5.5-0.2-7.5 0-3.1 0.2-6 2.3-7 5z'/%3E%3Cpath d='m68.8 58.5c-1.7-0.3-3.8-0.4-6.2-0.4h-17.7c-1.3 0-3 0.1-4.1 0.3-2.8 1-5.2 3.6-5.5 6.7-0.2 1.7-0.2 5.9 0.1 8.4 0.5 2.9 2.4 4.9 5.4 6.2 1.4 0.2 3.2 0.4 4.2 0.4h19.6c1.7 0 2.3 0 4.5-0.3 2.7-0.8 5-3.3 5.5-6.3 0.2-1.9 0.1-6.9-0.4-9.5-0.8-3-3.2-4.8-5.4-5.5z'/%3E%3Cpath d='m59.4 88c-1.8-0.3-6.9-0.4-9.1 0-2.9 0.6-5.4 2.8-6.2 5.8-0.4 1.8-0.3 4.3-0.3 8.2v11.7c0 4.9 0 7.7 1.3 9.5 1.2 1.9 2.8 3.4 5.4 3.8 1.6 0.3 6.7 0.3 8.4 0 2.6-0.3 4.8-1.9 5.8-3.9 1.2-1.7 1.1-4.5 1.1-8.5v-15.2c0-2.5 0-4.8-0.5-6.1-0.9-2.8-3.4-4.9-5.9-5.3z'/%3E%3C/g%3E%3C/svg%3E"
/>
<title>HFlow</title>
<!--
Apply the stored CHROME PREFERENCES before the first paint. A classic
inline script in <head> runs before the deferred module bundle and before
the stylesheet paints, so an explicit choice never flashes the other
state: not the wrong theme, and not a 184px nav rail that snaps to 56px
once React mounts.

Deliberately dependency-free and offline. It duplicates two storage keys
and two attribute contracts from src/ — change each pair together:

"hflow-ui-theme" / data-theme -> src/theme.ts
"hflow-ui-rail" / data-rail -> src/railLayout.ts

Both defaults are spelled as the ABSENCE of the attribute ("system" for
the theme, "expanded" for the rail), so a blocked localStorage and a
never-touched control land in the same place with nothing to apply.
-->
<script>
(function applyStoredChrome() {
const root = document.documentElement;
function apply(key, attribute, allowed) {
try {
const stored = localStorage.getItem(key);
if (allowed.indexOf(stored) !== -1) root.setAttribute(attribute, stored);
} catch {
/* localStorage is blocked; fall through to the default. */
}
}
apply("hflow-ui-theme", "data-theme", ["dark", "light"]);
apply("hflow-ui-rail", "data-rail", ["collapsed"]);
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
44 changes: 44 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "hflow-workspace-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Workspace UI for hflow catalogs. A client of the hflow-server JSON API.",
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"check": "tsc --noEmit && biome check .",
"format": "biome check --write .",
"gen:api": "uv run --project .. python scripts/dump-openapi.py > .openapi.json && openapi-typescript .openapi.json -o src/apiSchema.ts && rm -f .openapi.json && biome check --write src/apiSchema.ts"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.0",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.9",
"@lezer/highlight": "^1.2.3",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-table": "^9.1.2",
"lucide-react": "^1.33.0",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
"@biomejs/biome": "^2.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"openapi-typescript": "^7.13.0",
"typescript": "^6.0.3",
"vite": "^8.2.2"
}
}
Loading
Loading