Frontends
Two ways to put a UI in front of a JFast backend. Pick by whether you want a build step.
--kind web |
--kind spa |
|
|---|---|---|
| Renders | Server-side Jinja2 + HTMX | Vue 3 or React, client-side |
| Build step | none | Vite |
| Lives | inside the backend service | its own service |
| Good for | admin panels, internal tools, CRUD | rich client state, offline, mobile-ish UX |
--kind web is covered in modules.md. This page is about
--kind spa.
Create one
jfast new service admin --kind spa --frontend vue
cd admin
npm install
npm run dev
React instead:
jfast new service portal --kind spa --frontend react
Angular is not generated. See "Angular" at the bottom.
Pick a look: --template
A frontend is generated in one of two looks. The choice changes only the
files that draw — the stylesheet, the base components, the layout, the two
shipped screens and the page jfast new view writes. Router, stores, the
axios instance, the 401 handling and the generator markers are one shared set,
so everything else on this page holds for both.
--template |
What it looks like |
|---|---|
nexora (default) |
Liquid glass, from the Nexora design system: frosted panels over a black studio (and a light one), a glass sidebar, an island top bar, Plus Jakarta Sans with JetBrains Mono for figures, and a WebGL liquid ribbon behind everything. The home page is a small dashboard — KPI cards, a table of registered views, a next-steps column — built only from real values: /health, its round trip, and the sidebar |
classic |
Plain Tailwind v4 panels on a neutral surface with one crimson accent. No web fonts, no WebGL. Exactly what every frontend looked like before looks existed |
jfast new service admin --kind spa --frontend vue # nexora
jfast new service admin --kind spa --frontend react --template classic
jfast start shop --template classic # the default stack
jfast init # asks "Which look?"
An unknown value is refused before anything is written, naming the two that
exist. --template on a service that is not --kind spa is refused too: only
a frontend has a look.
Views follow the look
The choice is recorded once, in the project's .jfast-template stamp
(frontend_template). jfast new view reads it, so a page generated a year
later is drawn like the screens around it. --template on jfast new view
overrides the stamp; a project with no stamp at all — made before stamps, or
not by jfast — gets the classic page and a note saying so, because a nexora
page would name classes that project does not have.
What Nexora adds
| Path | Holds |
|---|---|
src/nexora/nexora.css |
The design system: the palette, light and dark tokens, and the card-panel, btn-modern, badge-status, kpi-card, erp-table, nx-* classes. Imported into Tailwind's components layer, so a utility next to one of its classes still wins |
src/nexora/accent.js |
The accent: six presets, deriving the whole palette from one colour, remembering the viewer's pick |
src/nexora/brand.js |
The product name on the wordmark |
src/nexora/background.js |
Whether the ribbon runs at all |
src/nexora/backdrop.js |
The background the viewer picked: 3D, 2D or off |
src/nexora/sidebar.js |
Whether the sidebar is folded away on a wide screen |
src/nexora/liquid.js |
The ribbon, on three.js |
src/components/AccentPicker.*, LiquidBackground.* |
The two components the classic look does not have |
The ribbon costs nothing when it cannot help. three is an npm
dependency, never a CDN script, and lands in a chunk of its own (about 130 kB
gzipped) that is fetched after the first paint. Under
prefers-reduced-motion it is never loaded, and switching that setting on
stops it. Without WebGL 2 the still CSS frame that is always underneath stays
on screen. A hidden tab renders nothing. It is mounted once at the app root,
not in the layout, so navigating does not rebuild the scene.
The name is VITE_APP_NAME, exactly as written, or jfastframework when
it is empty. Change it in .env for npm run dev and .env.production for
npm run build. jfast workspace env rewrites .env, so set it there again
after running it.
The accent is one colour, and glow, deep shade, button text and two text-safe shades are derived from it — the text shades are moved just far enough to read at 4.5:1 on each theme, so an amber or a slate accent does not produce unreadable eyebrows. Three places set it, first one wins:
- the viewer, with the swatch next to the theme toggle: Ruby, Blue, Emerald,
Violet, Amber, Slate or any custom colour, kept in this browser under
<service>:accentand replayed before first paint byindex.html; VITE_ACCENT="#3B82F6"in.env/.env.production— quoted, because an unquoted#starts a comment in those files;- the
--c-*triplets at the top ofsrc/nexora/nexora.css.
Buttons, glows, badges, every brand-* utility and the ribbon follow.
The background is chosen in the same popover as the accent: 3D is the
ribbon, 2D the still frame under it -- and three.js is never downloaded --
and Off the plain page colour. Switching away from 3D destroys a running
scene; switching back loads it. VITE_BACKGROUND=liquid|still|none in .env
/ .env.production is the project's default, liquid when empty; the
viewer's choice, kept under <service>:backdrop, wins over it. Both are
applied by index.html before the first paint, so "off" does not flash the
still frame on reload.
The menu button opens the drawer below 960px and, above, folds the sidebar
away so the page takes the full width. Folded stays folded across pages and
reloads (<service>:sidebar), and a folded sidebar is hidden from keyboard
focus, not only moved off screen.
JFast Suite, as a reference
With --agent-docs, a nexora frontend also gets the nexora-reference skill:
a copy of JFast Suite -- the static pages the look was drawn from: a landing, a
CRM console, a payment gateway, a social feed, operations tables, a widget
gallery -- in .jfast/skills/nexora-reference/suite/. Serve that folder and
open it before building a screen the project does not have yet. The skill says
how to bring a pattern across: reuse a shipped component if one fits, move the
CSS into src/nexora/nexora.css with the project's tokens, make it a
component. The suite is never linked from the app, and its images (about
400 kB of WebP) are demo art.
Asking for a different look
The shipped look is a starting point. For the other one, generate with
--template; for a look jfast does not ship, restyle the tokens and the
components in src/components/, keeping their props, since generated pages
call them. An agent working in the project is told the same thing: the
design skill written by --agent-docs describes the look the project was
generated with, and opens by saying that the look the user asks for wins over
it.
The API URL is already right
.env is written from jfast.workspace.toml:
VITE_API_URL=http://localhost:8030
That points at the gateway when the workspace has one and at the single backend
when it does not. The generated home page calls /health through it on first
load and shows the result, so a wrong value surfaces immediately instead of on
your first real feature.
After adding a backend (or the day a gateway appears):
jfast workspace env
Adding a module
This is the part modelled on the generator you already had:
jfast new view Facturas
src/ModuloFacturas/
├── Components/
│ ├── Modals/
│ └── Tables/
├── Pages/FacturasView.vue
├── Routes/router.js
└── Services/facturas.service.js
then it registers the module in two places:
// src/router/index.js
import { ModuloFacturas } from '@/ModuloFacturas/Routes/router.js'
...
...ModuloFacturas,
/*nuevaRuta*/
// src/menuAside.js
import { mdiHomeOutline, mdiViewDashboardOutline } from '@mdi/js'
...
{
to: '/facturas',
icon: mdiViewDashboardOutline,
label: 'Facturas',
},
/*nuevoModulo*/
The framework is detected from the project, so you do not repeat
--frontend react inside a React project. So is the look: the page is drawn
in nexora or classic according to the project's .jfast-template (see
Views follow the look).
The markers
Keep /*nuevaRuta*/ and /*nuevoModulo*/. Three properties are guaranteed,
and each one is a failure mode that is otherwise silent:
| Property | Without it |
|---|---|
| Idempotent — a guard string is checked first | Two routes and two sidebar entries per re-run |
| Loud — a missing file or marker raises with the path | A blank page and no explanation |
| Marker-preserving — the marker is written back after the block | The second module has nowhere to go |
A reformatted marker (/* nuevaRuta */) still matches: matching strictly would
turn a prettier run into a silent no-op.
Naming
Facturas → ModuloFacturas, /facturas, facturas.service.js.
BillingAccount → ModuloBillingAccount, /billing-account, and the sidebar
label reads Billing Account.
Base components
Both frontends ship the same set, so the two are the same product rather than two projects that happen to share a backend.
| Component | What it handles for you |
|---|---|
BaseButton |
primary / outline / ghost / danger, two sizes, and a loading state that also disables it |
BaseInput |
Label, error text, hint, disabled — it is meant to be impossible to ship a bare unlabelled input |
BaseModal |
Backdrop blur, Escape and backdrop click, focus trapped while open and returned to the trigger on close |
BaseBadge |
Status colours, with the word always shown |
SkeletonLoader |
The shape of the content that is coming |
EmptyState |
A heading, a sentence, and the action that creates the first one |
ToastHost |
Renders the toast queue; mounted once at the app root |
A destructive button is outlined, never solid. Solid red is already taken by "proceed", and a screen where the accent means two opposite things has no accent.
The mobile drawer locks body scroll, the same way BaseModal does and for
the same reason: it covers the page, so a swipe meant for the menu otherwise
scrolls the article underneath and takes the menu with it. It restores the
previous value rather than clearing it, so a modal already holding the lock
keeps it, and it closes itself past md — where the sidebar is static, there
is nothing to close and the lock would just be a page that will not scroll.
The two states people forget
Loading is not the word "Loading". SkeletonLoader takes props for lines
and shape so it can be made the shape of what is arriving — which is what stops
the layout jumping when it does.
Empty is not an empty page. EmptyState says what would be there and
offers the action that creates it. A blank table reads as a bug.
Toasts
// Vue
import { useToast } from '@/composables/useToast.js'
const toast = useToast()
toast.success('Invoice created')
// React
import useToast from '@/hooks/useToast.js'
const toasts = useToast((state) => state.toasts)
// Anywhere that cannot call a hook — an interceptor, a loader, a guard:
import { toast } from '@/stores/notification.store.js'
toast.error('Could not reach the server')
One store, two doors. A toast raised deep inside a service and one raised in a component land in the same list, in order, under the same timers. Two stores would mean a toast raised from a service goes into a list the host does not render — nothing on screen, and no error either.
Timers live outside the store and are cleared on manual dismiss. A dismissed toast still has a timeout aimed at it; left alive, every manual dismiss leaks one that later writes state for a toast that is gone.
State
| Vue | React | |
|---|---|---|
| Library | Pinia | Zustand |
| Auth | src/stores/auth.store.js |
same path |
| Notifications | src/stores/notification.store.js |
same path |
Both ship wired up: Pinia is installed on the app, Zustand is in
package.json, and the auth store persists its token the same way
services/api.js already reads it — one source of truth, named in a comment in
the file.
When not to use a store. State that no other component reads belongs in the component. A store is for something two unrelated places need: the layout reads the user, axios needs the token. That is two, so it is a store.
Sessions, and what happens on a 401
Access tokens are short-lived. Both frontends ship the two halves that need:
| Where | Covers | |
|---|---|---|
| Route guard | src/router/index.js / index.jsx |
navigating to a page while signed out |
| 401 interceptor | src/services/api.js |
the token expiring on a page already open |
A guard on its own is not enough. It runs on navigation, and nothing navigates while four panels on the current screen quietly fail.
Routes are private unless they say otherwise. PUBLIC_BY_DEFAULT at the
top of the router is the one line to change, and it ships false: the backend
signs people in with the accounts plugin (accounts.md), so
every page sits behind the sign-in page, including the ones jfast new view
adds later. A page meant for everyone opts out with meta: { public: true }
(Vue) or handle: { public: true } (React). The React guard wraps the whole
route list rather than each route, so a module added at /*nuevaRuta*/ is
covered without the generator knowing anything about authentication.
A frontend generated for a workspace with no accounts backend
(frontend_accounts = false, see below) keeps the old default instead: public,
with routes opting in through requiresAuth, and none of the account pages --
a sign-in form in front of everything that nothing could satisfy would be worse.
LOGIN_ROUTE is exported from auth.store.js, because api.js needs it too
and a second copy of '/login' is a second place to forget.
The interceptor, and the bug the obvious version has
On a 401 it refreshes once and replays the request. Three things in it are not optional:
One refresh for the whole burst. Four panels loading together produce four
401s. Four refreshes present the same refresh token four times — they are
rotated, so three of those are spent, and a backend that reads replay as theft
revokes the session. refreshOnce() hands every caller the same promise.
A refresh that is itself refused ends the session. It clears the store and
does a whole-page navigation to LOGIN_ROUTE with ?next=, once, however many
requests failed together. Retrying a refused refresh is the loop.
The replayed request needs the new token put on it, and this is the part that
looks like it already works. Refreshing updates where the token normally
comes from — api.defaults.headers in Vue. Defaults do not reach a config that
already carries Authorization, and the replayed config carries one: axios
merged the old token in when the request was first built. So the refresh
returns 200, the retries go back out with the token that just expired, and the
app renders as logged in with every panel empty:
/invoices 401 Bearer A1
/clients 401 Bearer A1
/projects 401 Bearer A1
/notifications 401 Bearer A1
refresh 200 -> A2
/invoices 401 Bearer A1 <-- refreshed, retried, same dead token
/clients 401 Bearer A1
/projects 401 Bearer A1
/notifications 401 Bearer A1
Vue fixes it by setting original.headers.Authorization before the replay.
React does not need that line, and for a reason worth knowing rather than by
luck: its request interceptor sets the header per request from localStorage,
and the replay runs through it again. That holds only while the assignment
stays unconditional — wrap it in if (!config.headers.Authorization) and React
has the identical bug.
The account pages
What the accounts plugin offers, the frontend has a page for. Each one is in
src/views/, loaded on demand, and drawn in one frame, AuthShell -- the
card in classic, the hero and glass panel in nexora -- so the pages are
shared between the two looks and only the frame differs.
| Route | Page | Calls |
|---|---|---|
/login |
LoginView |
POST /auth/login, then /auth/login/mfa when a code is owed |
/register |
RegisterView |
POST /auth/register |
/verify-email?token= |
VerifyEmailView |
POST /auth/verify |
/forgot-password |
ForgotPasswordView |
POST /auth/password/forgot |
/reset-password?token= |
ResetPasswordView |
POST /auth/password/reset |
/mfa/enrol |
MfaEnrolView |
/auth/mfa/setup and /confirm with the sign-in's MFA token |
/account/security |
SecurityView |
MFA on and off, new recovery codes, sign out everywhere |
Signing in fetches the user. accounts answers /auth/login with tokens
only, so the auth store (Vue) or services/auth.service.js (React) stores
them and then calls GET /auth/account; user holds what that returns
(email, display_name, roles, permissions, email_verified,
mfa_enabled). A sign-in can also stop short of a session: login() resolves
to { status: 'signed-in' }, { status: 'mfa', mfaToken } -- the page asks
for the code -- or { status: 'enrol', mfaToken }, when a role of the account
requires MFA and it has none, and the page sends the user to set it up.
Links a page offers follow the backend. "Forgot your password?", "Create
one" and the MFA card appear only when GET /auth/features says the feature is
on, so a link never leads to a 404.
Tokens from emails leave the address bar. The verification and reset pages
read ?token=, replace the URL without it, and post it once -- it is not left
in the history or sent on as a Referer. The MFA token of a sign-in travels in
router state, never in the URL. The two email paths must match
[plugin.accounts] verify_email_path and reset_password_path.
A wrong password is not an expired session. The account calls carry
skipAuthRefresh, and the 401 interceptor leaves them alone: without it, a
mistyped password would trigger a refresh and sign the person out of the form
they are filling in. Errors carry the backend's code (email_not_verified,
mfa_code_invalid, mfa_token_invalid, token_invalid) next to the message,
and the pages branch on the code, never on the wording.
No QR code is drawn for MFA: that would be a dependency for one image. The
otpauth:// link opens the authenticator on a phone, and the key is shown to
type anywhere else. Add a QR library to MfaSetupPanel if your users expect one.
?next is followed only when it is a path, never an absolute URL -- it comes
from the address bar.
For the installer: frontend_accounts. The template variable that decides
all of the above. Missing, it is true. Pass false when no backend in the
workspace enables accounts.
Light and dark
Three states, not two:
<html> |
Result |
|---|---|
| no attribute | follow the operating system |
data-theme="dark" |
dark, whatever the system says |
data-theme="light" |
light, whatever the system says |
Tailwind's stock dark: only reads the system, which leaves someone who wants
the other one with no way to say so. src/style.css redefines the variant to
check an explicit choice first:
@custom-variant dark {
&:where([data-theme="dark"], [data-theme="dark"] *) { @slot; }
@media (prefers-color-scheme: dark) {
&:where(:root:not([data-theme="light"]), :root:not([data-theme="light"]) *) { @slot; }
}
}
The :not([data-theme="light"]) in the second half is the part worth
understanding: without it, an explicit light choice loses to a system set to
dark and the switch appears to work in one direction only.
The toggle is ThemeToggle, in the header of LayoutAuthenticated. It
flips what is currently on screen and pins it. Beside it, and only once the
choice is pinned, sits a second button — followSystem(), labelled "Follow the
system theme". That one is not decoration: the toggle can only ever pin light
or dark, so without it the first click takes the third state away for good
and the app is back to the two-state switch this design exists to avoid. The
choice is stored under <service>:theme.
resolved is shared, not per-caller. Both useTheme() implementations keep
theme and resolved at module scope — a Vue computed, a value React
recomputes on every render behind a subscriber set. Building resolved inside
the composable is the version that looks right and is not: one click updates
the component that handled it and nothing else, so a header toggle and a
settings switch on the same page end up showing opposite icons while <html>
carries only one of them. A test for this needs two components, not one — a
single toggle asserting data-theme flips is asserting the half that was never
broken.
The flash is handled. index.html carries twelve inline lines that read
the stored choice and set the attribute before the first paint. Applying the
theme after mount instead means one frame in the wrong colour on every reload,
which is the single most noticeable defect a theme switch can have.
Surfaces are tokens, not colours
@theme inline {
--color-surface: var(--ui-surface); /* the page */
--color-panel: var(--ui-panel); /* cards, sidebar, header */
--color-elevated: var(--ui-elevated); /* hover, skeletons, code */
--color-line: var(--ui-line); /* every border */
--color-ink: var(--ui-ink); /* primary text */
--color-ink-soft: var(--ui-ink-soft); /* secondary */
--color-ink-faint: var(--ui-ink-faint);
}
inline is what makes it work: the generated utilities emit
var(--ui-surface) rather than resolving at build time, so redefining seven
variables under [data-theme="dark"] flips the whole interface.
What that buys is visible in the components — not one of them carries a
dark: class. bg-panel is correct on both themes. A component that needs a
light value and a dark value on every line does not have a design system, it
has two hardcoded themes that drift apart the first time somebody edits one.
The one exception is status colour: success, warning, danger, info on
BaseBadge and ToastHost. There the hue is the meaning, so it cannot come
from a shared surface token. Those are built as a 10% tint of one hue plus a
dark: on the text only, because a tint reads correctly on either surface
where a solid bg-emerald-50 does not.
color-scheme is set alongside the tokens, so scrollbars, date pickers and the
caret — drawn by the browser, never touched by a class — match the rest.
Conventions the templates enforce
One axios instance. src/services/api.js holds the base URL, the timeout
(VITE_API_TIMEOUT, 60 000 ms by default -- an endpoint that calls a language
model routinely takes twenty seconds, and a shorter timeout turns a slow answer
into an error the user retries, paying for the call twice),
the 401 handling above, and an interceptor that turns the backend's RFC 7807
detail into error.message. Without it every component shows "Request failed
with status code 409" instead of "Invoice INV-1 already exists". The one
exception is plain, exported from the same file: same configuration, no
interceptors, and /auth/refresh is all it is for — a refresh that went
through api would have its own 401 answered by another refresh.
HTTP in Services/, never in components. The generated page calls
listFacturas(); it owns loading and error state, the service owns the request.
Tailwind v4 with tokens. One @import "tailwindcss" and the @theme
blocks in src/style.css. Reach for bg-panel, border-line and text-ink
rather than a neutral ramp: a component that names zinc-200 directly is one
the theme cannot reach, and two components that pick different ramps is how a
UI ends up looking subtly wrong with nothing identifiably broken in it. See
.jfast/skills/design-system/SKILL.md.
Nexora keeps the same utilities — src/style.css points bg-panel, text-ink
and the brand-* ramp at its own tokens — and adds its component classes on
top, so a page written for either look still takes its colours from tokens.
Icons without a runtime. @mdi/js ships path strings only; BaseIcon
renders one into an SVG. No icon font, tree-shaken to what you import.
Vue specifics: <script setup> and the Composition API, never Options API;
components PascalCase.vue; route-level pages end in View.vue.
React specifics: function components and hooks; components PascalCase.jsx;
route-level pages end in View.jsx.
Verified how
Be precise about this, because it matters for how much you should trust it.
Tested in CI: every file renders; the module structure lands where it
should; router and sidebar are patched correctly and idempotently; Vue's {{ }}
and JSX's braces survive scaffolding; the framework is detected from the
project. And, since the base components landed, npm install followed by
npm run build on both generated frontends — scripts/smoke_components.sh,
which also asserts that ToastHost is mounted somewhere, that there is exactly
one toast store, that the bundle contains a way back to system and a
refresh-on-401, and that the drawer locks body scroll. Rendering a template
proves the braces were right; it does not prove a component imports something
that exists.
Exercised against a stubbed backend, in Node, by hand: three useTheme()
callers and one click, asserting all three agree afterwards; and four
concurrent 401s against the real api.js and auth.store.js with an axios
adapter for a server, asserting one refresh and four 200s, then the same with
a refresh that is itself refused. Both were written first and both failed on
the previous templates — the first with
["Switch to dark theme", "Switch to light theme", "Switch to light theme"],
the second with four retries carrying the token that had just expired. These
are throwaway harnesses, not a suite: they are not in CI.
Exercised in a real browser, once, by hand: the theme switch on both generated frontends — light, dark, an explicit light choice against a system set to dark, the setting surviving a reload, and the mobile drawer opening. Computed styles were read back rather than eyeballed. That is one run on one browser, not a suite: it is not in CI and it will not catch a regression.
Both looks, both frameworks, in CI: scripts/smoke_frontend.sh generates
Vue and React in nexora and in classic, runs jfast new view in each, and
builds them; it also checks that three.js stays out of the entry chunk and that
the build honours VITE_ACCENT. The nexora screens, the ribbon, the accent
picker and both themes were looked at in a browser by hand when they were
written — once, not in CI.
The account pages: CI renders both frameworks in both looks and checks
the routes, the default, and every endpoint the store calls. npm install and
npm run build were run by hand on all four when the pages were written. The
backend flows they call are tested over HTTP (tests/test_accounts_flows.py);
the pages themselves have not been clicked through in a browser against a
running backend.
Not tested: npm run dev as an interactive session, the account pages in a
browser, and anything else about how it looks. The build passing means it compiles, not that
a modal traps focus correctly with a real screen reader.
Angular
Not generated, deliberately. A hand-rolled angular.json and builder config
that has never been run by ng serve is worse than no scaffold: it looks
finished and fails in a way that is hard to attribute.
If you want Angular today: ng new the project yourself, then keep the same
Modulo<Name> structure by hand. Generator support is PLAN.md phase 3, and it
should land only alongside a CI job that actually builds the output.