HFS Web UI (helios-ui)
SkillWeb & browsingWork on the HFS web UI in crates/ui (helios-ui). Use for Askama templates, htmx fragments, /ui routes, vendored assets and CSS, the schema-driven resource editor, i18n/locales, theme handling, per-user settings, and the Rust + Playwright UI tests.
Use HFS Web UI (helios-ui) in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add HFS Web UI (helios-ui) and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the HFS Web UI (helios-ui) skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; Ahel provides instructions and does not run this skill.
No other account needed.
Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
What this skill tells your AI
The instructions your AI receives, as published by heliossoftware/hfs in .claude/skills/work-with-ui/SKILL.md and read by Ahel’s review.
The crate is helios-ui at crates/ui — a thin Axum library crate mounted by
the hfs binary as a sub-router under /ui. It owns templates, static assets,
and view handlers. crates/ui/README.md is the long-form rationale; this skill
is the operational summary.
Stack: server-rendered htmx, no SPA
There is no React, Vue, Svelte, Alpine, or jQuery, and no bundler or build
step, with one narrow exception: CodeMirror 6 is vendored as a prebuilt
bundle via a documented, hand-run ritual under crates/ui/vendor/codemirror/
— never executed by cargo build or CI. See crates/ui/README.md § "The one
exception: a vendored, prebuilt bundle". Do not introduce another one.
| Layer | What we use |
|---|---|
| Templates | Askama — Jinja2-like, compiled and type-checked at build time, auto-escaping |
| Interactivity | htmx 2.0.4, vendored at assets/htmx.min.js |
| Client JS | Hand-written vanilla IIFEs in assets/*.js — no framework, no npm deps |
| Asset delivery | rust-embed + axum-embed, embedded into the binary; never a runtime CDN |
| Header handling | axum-htmx — HxRequest extractor, AutoVaryLayer (Vary: HX-Request) |
| i18n | fluent-templates over locales/<locale>/main.ftl at the workspace root |
Handlers return a full page on a hard navigation and an HTML fragment on
HX-Request. State lives on the server.
Running
# The `ui` feature is on by default in helios-hfs.
cargo run -p helios-hfs # then open http://127.0.0.1:8080/ui
cargo run -p helios-hfs --features ui
# Headless deployments: a runtime switch, not a build feature.
HFS_UI_ENABLED=false cargo run -p helios-hfs
The mount is #[cfg(feature = "ui")] plus the runtime config.ui_enabled check
in attach_ui (crates/hfs/src/main.rs). FHIR version features forward through
helios-ui?/R4|R4B|R5|R6, so the UI's viewers cover exactly the versions the
server was built with.
Routes (crates/ui/src/lib.rs)
| Route | Method | Page |
|---|---|---|
/ui | GET | Dashboard (stat cards + resources-over-time chart) |
/ui/resources | GET | Resources workspace — type rail, search, edit modal |
/ui/editor | GET | Standalone schema-driven resource editor |
/ui/editor/render | POST | Applies every structural mutation and re-renders; the document rides with the request |
/ui/editor/expand | GET | ValueSet expansion proxy to HFS_TERMINOLOGY_SERVER |
/ui/queries | GET | Saved queries + visual search builder |
/ui/queries/params | GET | Per-type search-parameter catalog (datalist) |
/ui/search | GET | Natural-language search — only registered when NL search is enabled |
/ui/search-parameters | GET | SearchParameter viewer/CRUD |
/ui/compartments | GET | Compartment viewer + membership tester |
/ui/history | GET | Version rail |
/ui/history/diff | POST | Server-side diff of two versions (see docs/history-diff-rendering.md) |
/ui/batch | GET | Batch/Transaction workspace |
/ui/tenants | GET/POST | Tenant maintenance; /ui/tenants/rows (GET), /ui/tenants/{id} (DELETE) |
/ui/status | GET | Reference implementation of the fragment-vs-full-page pattern |
/ui/version | POST | Persists the sidebar FHIR-version choice, redirects back |
/ui/tenant, /ui/tenant/options | POST/GET | Tenant selector |
/ui/login | GET | Interactive login (#1449): starts Authorization Code + PKCE, redirects to the IdP. 404 unless HFS_UI_LOGIN_CLIENT_ID is set |
/ui/callback | GET | The IdP's redirect back: verifies state, exchanges the code, sets the hfs_session cookie |
/ui/logout | POST | Ends the session (and the IdP's, via end-session); the account menu's Sign out form posts here |
/ui/assets/* | GET | Embedded htmx, CSS, JS, fonts, logo |
The router fallback_service is the FHIR app, so anything not under /ui falls
through to the normal REST surface.
Layout
src/— Axum handlers returningimpl IntoResponse. Thin: parse request → call intohelios-rest/helios-persistence/helios-fhir-validator→ render a template. Modules:lib.rs(router, dashboard, search, prefs),editor.rs,search_params.rs,compartments.rs,conformance.rs,history.rs,tenants.rs,json_view.rs,i18n.rs.templates/layouts/—base.html, the document shell.templates/pages/— full documents, extend a layout.templates/partials/— htmx-swappable fragments, no<html>wrapper;{% include %}d into pages so the first render and the swap emit identical markup.templates/icons/*.svg— Figma exports, fills normalized tocurrentColor, inlined.assets/—htmx.min.js(pinned),app.css,fonts/,logo.png, the sharedbusy.js(#679,window.hfsBusy), the shared unsaved-changes trackerunsaved.js(window.HfsUnsaved.track({ root, form?, read?, cue? }), #1240 — one dirty flag per form, the.tag--unsavedpill, thebeforeunloadguard, andconfirmDiscard(scope)for in-page closes; no storage), the vendored CodeMirror 6 bundle (vendor/codemirror.bundle.js,window.HfsCodeMirror) with its shared mount helpercode-editor.js(window.HfsCodeEditor, #838, also the shared JSON token-color presetjsonHighlight(), #840), the shared guided-form loopeditor-form.js(window.HfsEditorForm.attach(root, host), #843;host.fieldsfor constant extra request fields, #840), the shared editor/guided-form pairingeditor-pair.js(window.HfsEditorPair.mount({ textarea, view?, grid, fields? }), #840, extracted out ofvd-editor.js's original #843 implementation: two-way JSON↔form sync, the validity chip, and the row↔editor cross-highlight), and the per-page scripts:theme.js,editor.js,resources.js,saved-queries.js,batch.js,history.js,nl-search.js,resource-filter.js,conformance-crud.js,vd-editor.js(ViewDefinition editor,/ui/sql/view-definitions— JSON + injected FHIRPath language, highlighting, and lint; hands its mountedEditorViewtoeditor-pair.js),sql-editor.js(SQL pane editor,/ui/sql/queriesand/ui/sql/views),sql-library-details.js(Details JSON editor on those same two pages — plain JSON language and highlighting, no lint; hands its mounted view,hidden: "content"andlegend: "sql-library", toeditor-pair.js, #840, and keepsEditorPair.mount's own{formApi, host}return value exposed aswindow.HfsSqlLibraryDetailsinstead of discarding it, #841), andsql-library-panels.js(the Parameters, #841, and Tables, #842, cards: onhtmx:afterSwapof a#lib-params/#lib- tablescarryingdata-document, hands that text towindow.HfsSqlLibraryDetails.host.setDoc()so a mutation from thedocumentendpoint below lands as one undoable transaction that also refreshes the guided form and re-fires the live run; also autofills the Tables panel's Alias field from the Add table combobox's ownhfs:combobox-selectselection, and, #842/04, opens that same panel with the alias pre-filled when a row's own Declare {name} button is clicked).combobox.jsreinitializes any[data-combobox]field an htmx swap introduces (htmx:afterSwap, #842/04) — needed the moment the unknown-table lint's own OOB refresh of#lib-tablesreplaces the Add table combobox with a fresh, un-enhanced one.
/ui/sql/queries and /ui/sql/views (pages/sql-library.html, one
template keyed by the route's own LibraryKind) each edit Library
resources of their own SQL on FHIR type code (sql-query/sql-view): a
title row, a Details section — the Details JSON editor (sql-library- details.js) beside the same guided-form card View Definitions uses,
content hidden from it since the SQL card below owns that attachment —,
the SQL card itself (sql-editor.js), a Parameters card (#841, SQL Query
only — LibraryKind::declares_parameters is the template's own gate, never
an if on the route's code) with one value field per declared
Library.parameter[use=in] entry, an undeclared-:placeholder hint with a
one-click Declare, and an Add parameter panel that writes a fresh
declaration via POST /ui/sql/{queries,views}/document — and a $sql-run
preview that follows whichever text is current, blocked by a "waiting"
notice while a SQL Query has a declared, required parameter with no value
yet, and a Tables panel (#842, both kinds): #lib-tables (Reads from —
one row per relatedArtifact[type=depends-on] resolved the same way
$sql-run's own graph walk resolves them, with Add table/Remove; Used
by — other Libraries and $sql-export jobs depending on this one) beside
#lib-columns (the last good run's own column list — name, type, and, when
it traces to exactly one resolved ViewDefinition dependency, its origin).
Save fuses the two cards' documents server-side (sql_libraries:: embed_sql); a document whose type code names the other kind is rejected
with a warning, and so is a SQL View document whose Library.parameter[]
is non-empty (its own profile fixes it to 0..0).
Unknown-table lint (#842/04). Ahead of $sql-run, /run checks — in
order — a SQL View's own non-empty parameter[], then whether the SQL
reads a table no declared relatedArtifact[depends-on] label names
(helios_sof::sqlquery::{scan_sql, undeclared_tables}, case-insensitive),
then a SQL Query's own unfilled required parameter — never calling
$sql-run on the second check, so the last good table and Columns stay on
screen (their meta relabelled "last successful run") and the editor gets a
data-diagnostics JSON array (@codemirror/lint's setDiagnostics +
lintGutter()) underlining every unknown table with a hover tooltip and a
gutter mark. The same tables render as their own .tag--failed rows in
Reads from, each with a Declare {name} button that opens Add table
with that exact alias pre-filled. ?…&saved=1 and the document
endpoint's own no-JS re-render apply the identical check before deciding
what to show in place of results.
vd-editor.js also drives completion and quick fixes (#821), server-
backed like the async lint above it — the browser only locates the cursor
in its own syntax tree, POST /ui/sql/view-definitions/complete (kind: "key" for a structural JSON node, kind: "fhirpath" for a partial
expression) answers what fits there. Ctrl-Space opens the popup manually
(typing opens it too); Enter accepts. Each /lint diagnostic's fixes
becomes a button in the hover tooltip and the bottom lint panel — Ctrl+.
applies the one fix under the cursor, or opens the panel when several
apply (F8/Ctrl-Shift-M reach the panel too, lintKeymap); every fix is one
undoable transaction. Saving with at least one uncorrected error (Save,
not Duplicate) pops a plural-correct window.confirm. To regenerate the
vendored bundle after touching entry.js, see crates/ui/vendor/ codemirror/README.md's own ritual — it is never run by cargo build or CI.
theme.js loads without defer, before first paint, to avoid a FOUC —
and, since #843, is what marks <html class="js"> for app.css's .needs-js
utility (a card that renders inline, server-side, on a page's own first paint
and needs a client-side loop wired to it before it shows — View Definitions'
guided-form card, so far). Every other script is defer. Busy/working states
go through hfsBusy for fetch-driven code and hx-disabled-elt for htmx
controls — see crates/ui/README.md § Busy states.
Rules of the road
Enforced by review, and three of them by e2e/tests/no-cdn.spec.ts:
- No HTML in Rust string literals or
format!. All markup lives in templates. - No business/FHIR logic in templates. Templates render data; they don't compute it.
- No new browser-facing JSON API to feed the UI — htmx consumes HTML fragments.
- No inline
<script>blobs. Preferhx-*attributes (Locality of Behaviour); where JS is genuinely needed, add a small pinned asset. Inerttype="application/json"data carriers are the one allowed exception. - No off-origin requests. No CDN, no remote font, no remote image. HFS may run air-gapped, and a runtime CDN is a supply-chain risk.
helios-reststays UI-agnostic — the UI depends on the workspace, never the reverse.- Don't couple templates to a single FHIR version — go through the version-agnostic abstractions.
- Every htmx-backed control needs a real
<a href>/<form>underneath so it works with JavaScript disabled.
To update htmx, replace assets/htmx.min.js with the new pinned release and note
the version bump in the commit message.
CSS: one vocabulary, four layers
assets/app.css is layered — @layer tokens, base, components, pages. Shared
primitives live in components; put in pages only what no other screen wants.
Never invent a second class for an existing primitive (.button next to
.btn is how the Import page drifted, #543) and never restyle a shared control
page-locally. The canonical spellings: .btn/.btn--primary, .card +
.card-head, .page-head__title (the only <h1> class), .table-wrap >
.data-table, .field__*, .addbox, .menu, .notice, .tag, .chip,
.toolbar, .tabs/.tab, .filter-rail, .icon-button. Full table:
crates/ui/README.md § Component vocabulary. e2e/tests/design-system.spec.ts
fails undefined classes, off-canon <h1>s, diverging primary buttons, and
duplicate selectors; every full page must be in e2e/pages/routes.ts, the one
route list all cross-page guards share. New pages start from
templates/pages/_scaffold.html.
Configuration
The UI reads no configuration of its own; hfs passes it in at mount().
| Variable | Effect on the UI |
|---|---|
HFS_DATA_DIR | Spec bundle behind the SearchParameter / CompartmentDefinition viewers |
HFS_TERMINOLOGY_SERVER | Backs /ui/editor/expand (binding lookups in the editor) |
HFS_NL_SEARCH_ENABLED | Registers /ui/search and the NL toggle |
HFS_NL_SEARCH_API_KEY | Whether NL search is configured vs. showing its setup state |
HFS_NL_SEARCH_MODEL | Shown in the setup state |
HFS_OUTBOUND_BEARER_TOKEN | Credentials for the UI's self-call (below) |
HFS_UI_LOGIN_CLIENT_ID | Enables the browser sign-in (#1449). With auth on and this unset, browser-originated FHIR calls are refused and the shell shows a bearer-only notice on every page (#1560); the Tenants and Import routes, whose handlers act on storage directly, answer 401 with that notice instead of acting for an anonymous caller (#1619) |
HFS_DEFAULT_TENANT, HFS_DEFAULT_FHIR_VERSION | Defaults for the sidebar selectors |
Conformance data comes over HTTP, from the server itself
SearchParameter and CompartmentDefinition are not vendored into the UI. The crate fetches them from the server's own FHIR API on the loopback address — storage is the source of truth.
When auth is enabled this self-call needs a valid bearer via
HFS_OUTBOUND_BEARER_TOKEN; without one it is rejected and the conformance pages
degrade to a warning (pages/compartments-degraded.html, covered by
e2e/tests/auth/degraded.spec.ts). A short-lived auto-minted token is the
planned follow-up (crates/auth/src/outbound.rs).
Per-user preferences
Theme, nav state, FHIR version, tenant, saved/recent queries, and — since
#754/#755 — every sidebar rail's rails.<page> record of last/recent
(rail_state) roam in the /_user/settings document (weak ETag, JSON
merge patch, If-Match on write). Tenant-derived keys live under a reserved
byTenant map so a tenant purge can reach them — see /run-hfs-server for
the full semantics. The server renders each rail's "Recently used" group
from recent; Compartments is the one exception — it remembers only last,
no group, since its 4-5 definitions would make one noise. With no settings
store configured there is nothing to render or restore, and every rail opens
on its page default. resource-filter.js now only owns a rail's tooltip and
scroll-to-selection on arrival — recents are server-rendered, not client-side.
i18n
Negotiation order: ?lang= → hfs_lang cookie → Accept-Language (RFC 4647
Lookup) → en. Locales are en, es, de at locales/<locale>/main.ftl
(repo root, embedded at compile time). A key missing from a translation falls back
to English. Tests enforce key-set parity across locales. See docs/multi-language.md.
Testing
Two rings. Both must pass.
Inner ring — Rust, fast (tower::oneshot against the mounted router):
cargo test -p helios-ui
tests/router_http.rs, tests/i18n_http.rs, tests/tenants_http.rs, plus
mod tests in most src/ modules. tenants_http.rs uses a real SQLite store
(test-only sqlite feature on helios-persistence).
Outer ring — Playwright + axe-core (crates/ui/e2e/, self-contained Node;
the cargo workspace is untouched):
cargo build -p helios-hfs --features ui # boot.mjs runs the newest target/{release,debug}/hfs
cd crates/ui/e2e && npm ci && npx playwright install chromium
npx playwright test # all projects
npx playwright test theme # one spec
HFS_E2E_BASE_URL=http://127.0.0.1:8080 npx playwright test # drive a server you started
Specs live in tests/; the Page Object Model lives in pages/, wired onto
test via pages/fixtures.ts — specs import { test, expect } from
../pages/fixtures. pages/api.ts seeds resources over the REST API.
The axe gate is strict: every WCAG 2.2 AA rule, including color-contrast, in
both light and dark, is a hard failure. The nojs project asserts the UI still
works with JavaScript disabled.
CI: ui-tests.yml per PR (SQLite, fast); ui-tests-matrix.yml manual + nightly
across every storage backend.
Gotchas
- Askama fails the build, not the request — a template referencing a missing field is a compile error. That is the point; don't route around it.
rust-embedhasdebug-embedon, so debug builds embed assets too. Editing a file underassets/ortemplates/needs a rebuild to take effect.- Assets are served with
Cache-Control: no-cacheplus a content ETag: unchanged assets 304, rebuilt ones always re-fetch. Don't "fix" a stale asset with a cache-busting query string. AutoVaryLayeremitsVary: HX-Requestso a cache never serves a fragment for a hard navigation. A new handler that readsHX-Requestgets this for free by being on this router — don't hand-roll the header./ui/searchdoes not exist when NL search is disabled; a test that navigates there unconditionally will 404.- Headless operation is
HFS_UI_ENABLED=false, not a Cargo feature. The oldheadlessfeature was gated asnot(feature = "headless")and so was switched on — killing the UI — by--all-features, the selection that builds the released binaries (#975). Never add a negative feature here. - When the UI is not served (feature off, or
HFS_UI_ENABLED=false),/uireturns 404 + OperationOutcome fromui_absent_routes, whose diagnostics say the UI is absent and why. Before #989 a fall-through to the FHIR router readuias a resource type and answered200with an empty searchset; the router's resource-type gate (helios_rest::middleware::resource_type) now refuses any unknown type with404+not-supported, so the stubs exist for the specific message, not for the status.
Signals
- GitHub stars
- 53
- Forks
- 21
- Last commit
- Oct 2026
Advanced
- Item type
- skill
- Key
work-with-ui- Source
- github.com/heliossoftware/hfs
Related picks
Skill · handsontable
The pick for End-to-end testingmstar-e2e
Skill · btspoony
The pick for End-to-end testingcss-animations
Skill · alecs5am
The pick for Cssreset-css
Skill · thedaviddias
The pick for Cssbrowser-use
Skill · browser-use
More in Web & browsingwebapp-testing
Skill · anthropics
More in Web & browsing