apps/console is the Nuxt application an operator signs into. It registers
applications, rotates credentials, reads the ledger, releases and withdraws
money, administers banks and people, and inspects traces and webhook deliveries.
It talks to nothing but its own Nitro server. Nitro talks to the API. That is the
rule the whole application is arranged around: a credential stops at the
server, and a page that wants data asks a gateway that only knows /api/….
#Its layers
| Layer | Holds | Rule |
|---|---|---|
app/core/ |
The shared runtime: gateways, the list pipeline, layout primitives, form primitives, formatting | Written once. A module that reimplements any of it has forked the design system |
app/modules/<entity>/ |
One entity's model, columns, forms and entity-specific components | Knows its own domain and nothing about anyone else's |
app/pages/ |
One screen each, assembling core and one module |
Holds no fetch logic of its own |
server/api/ |
Nitro, one file per verb | The only thing that may reach the emulator API |
The operator console is written in Spanish. The public landing supports Spanish and English. Code, comments and these documents are in English.
#Public home and access
/ is the public Payment Emulator Lab landing. /applications holds the existing
authenticated application listing. /login is the existing sign-in page and
defaults to /applications after successful sign-in; protected deep links still
use next. All control-plane Nitro routes retain their session requirement.
The landing lives in app/modules/public/landing/components/. Its typed ES/EN
catalog is app/modules/public/landing/i18n/catalog.ts, mounted under
public.landing by Nuxt I18n. useAppLocale() is the application locale boundary;
Nuxt I18n owns the SSR-readable emulator_locale cookie. publicLocalePath()
generates localized public links and SEO alternates. See the
i18n architecture. The existing useColorScheme() cookie (emulator_scheme, dark
by default) and html.dark drive both palettes without a second theme strategy.
Landing CSS is namespaced and does not change the operator palette.
Three.js loads only when the decorative client scene enters the viewport. The scene caps DPR at 1.5 and rendering at 30 fps, pauses offscreen and in hidden tabs, renders a static view with reduced motion, and disposes its resources on unmount. An SSR card remains visible without JavaScript or WebGL. Scenario and trace panels are explicitly illustrative and do not call the API.
Documentation links open the local documentation portal,
built from the bilingual Markdown guides. No GitHub access is required.
NUXT_PUBLIC_SITE_URL is empty by
default; set it to the deployed public origin and every absolute address the
site emits — canonical, hreflang alternates, Open Graph image, robots.txt
and sitemap.xml — follows it. Left empty, each request answers for the host
it arrived on. No production hostname is assumed for local installations.
#Public addresses
The landing has one public address per language: /
in Spanish and /en in English. The route decides the language, so each
version can be linked and indexed on its own; the emulator_locale cookie only
remembers which one the reader chose. Both canonicalise to themselves and
declare the other through hreflang. /sitemap.xml lists both landings and the
published guides under /docs/es and /docs/en. Everything behind the login
answers X-Robots-Tag: noindex.
Run npm run dev:console from the repository root to preview the landing on
port 3000. It needs no API or database to render. The login and authenticated
console require the configured API and session password as before.
With the console running and NUXT_SESSION_PASSWORD configured, run
npm run smoke:landing. It checks public SSR in both languages and themes, login
links, protected page redirects and anonymous Nitro rejection without an API or
database. Set CONSOLE_URL if the console uses another origin.
#core/ — the design system
There is exactly one design system: PrimeVue with the Aura preset, tuned in
app/theme.ts. A second component library, a second icon set or a hand-rolled
replacement for something PrimeVue already does are all defects. The wrappers
below exist to make the correct thing the easy thing, not to hide PrimeVue.
#Forms
-
RField— one field: its label, its control, its help and its error. It mints the id, wiresaria-describedby, and decides how the control takes its name. This is the file that knows the traps, and there are three:- Naming.
InputText,Password,TextareaandToggleSwitchmount a real<input>, so<label for>names them.Selectrenders a<span role="combobox">, which aforcannot name at all — those fields pass:labelable="false"and:aria-labelledby="field.labelledBy". A hand-written label/control pair only looks associated. - Components that swallow attributes.
InputNumberhas noinputProps, so anaria-describedbywritten on the component lands on the wrapping span and the inner input is left unnamed. It is reached with:pt="{ pcInputText: { root: { 'aria-describedby': field.describedBy } } }".InputChipsdoes takeinputIdandinputProps, so a plain label works. - Height. See below.
- Naming.
-
One height, one type size. Every single-line control in a field is
--control-htall and 14px, asserted inRField. PrimeVue does not give them the same: a Select comes out 42px against an InputText's 39, an<input>with no font-size of its own inherits the browser's 16px while the panel is 14, and an input group holding a colour swatch is taller than both. Left alone, a row of fields is not a row. -
The help is a tooltip on the label row, not a paragraph under the field. A
?next to the label, opening on hover and on focus. That is what makes two fields side by side the same height. Nothing is lost to a screen reader: the text still exists in the document, hidden, carrying the id thataria-describedbyhas always pointed at. The error stays in the flow — an operator whose form was just refused should not have to hunt for the reason..tmp/fields-audit.mjsis the check: it opens every form, groups fields by the row they sit on, and fails any row whose controls differ in height or any help whose text no control references. -
RFormSection— a group of fields. Rendersfieldset+legendwhen it is named and a plaindivwhen it is not, so an unnamed group does not announce an empty legend.min-columndrives the responsive grid. -
RFormActions— the row of buttons, so submit and cancel sit the same way on every screen. -
useValidation(form, rules)— mirrors what the API's DTOs already declare. It is deliberately not a second source of truth: the server still refuses what it refuses; this only decides when the operator finds out. Errors stay hidden until a field is left or the form is submitted, because a form that turns red while you are typing the first character is a form arguing with you. A rule is(value, form) => string | null, so a cross-field rule is an inline arrow, not a framework feature.
#Listings
useEntityCollection— the whole list pipeline for one entity: params, fetching, paging, sorting, filtering, searching, deleting, and optional URL sync so a filtered view is a link. No page builds a request by hand.RTable— the table itself, plus a column picker, a loading skeleton, and two distinct empty states: a listing nobody has filled yet, and a filter that matched nothing. They are not the same message.<module>.columns.ts— each module declares its own columns. A column that claimssortablemust name a field the endpoint declared sortable, or the API answersunsortable_fieldrather than quietly ignoring it. See the list contract.
#Layout and shell
AppSidebar/AppTopbar— the shell. The sidebar collapses to icons with tooltips; both readcore/navigation.ts.navigation.ts— the grouped, role-filtered navigation. Hiding an entry a role cannot open is courtesy; the protection is that the API answers403and the route middleware refuses. But a panel that offers pages you cannot open is a panel that looks broken.RPageHeader— title, one line saying what the screen is for, and the primary action.RStat— a dense figure tile, deliberately not aCard.RTour— the guided tour host. Steps are registered per page withuseHelpTour().register(...); a step whose target is not on the page is dropped rather than pointing at nothing.
#Feedback and failure
useNotify—succeeded/failed, over PrimeVue's Toast.useConfirmAction—destructive(...), over ConfirmDialog. Nothing in the console useswindow.confirm, and no destructive action is one click.REmptyState— icon, title, one line of why, and the way out.useAppError().explain(error, localizedFallback)— maps semantic REST codes/status to safe localized copy. Inline failures use computed presentation; persistent error notifications use getters so language changes update existing messages without repeating the request or exposing backend error text.
#Data access
createRestGateway({ resource })— one resource's verbs, built from its path. Every path is a Nitro route on this application, never the emulator API. During server rendering it usesuseRequestFetch, because$fetchdoes not forward the incoming session cookie and a server-rendered listing would otherwise be answered401by our own server.core/data/contracts/— the shared list-params shape, and the serializer that maps it onto the query the API reads. Declared once; a new listing inherits the wire format instead of inventing one.
#Appearance
app/theme.ts— the Aura preset, tuned. It is loaded throughprimevue.importThemeand not throughoptions.theme: a preset named as a string goes through Nuxt's runtime-config serialisation, PrimeVue emits empty variable blocks, and everyvar(--p-*)in the panel resolves to nothing. The symptom is a completely unstyled application, and it is not obvious from the configuration that anything is wrong.useColorScheme— light and dark, persisted in a cookie so the server renders the same scheme the browser is about to show.darkModeSelectoris.darkon<html>.assets/css/console.css— the tokens: spacing scale, radii, the content width, surface and text colours for both schemes. Arbitrary padding, radii or colours in a component are a defect; the token exists.
#Adding a screen
modules/<entity>/<entity>.model.ts— the interface andcreateRestGateway({ resource: '/api/<entity>' }).modules/<entity>/<entity>.columns.ts— the columns, matching what the endpoint declared sortable and filterable.server/api/<entity>/…— one file per verb,proxyListfor the listing.pages/<entity>.vue—RPageHeader,useEntityCollection,RTable, and a form built fromRFormSection+RField.core/navigation.ts— the entry, withroleswhen a role cannot open it.- A tour, with
useHelpTour().register(listViewTour({ … })).
#Verifying a change
Render it. npm run typecheck --workspace @payment-emulator/console catches
types and nothing about whether the panel looks right; grepping served HTML
catches even less. Sign in, screenshot the screen in both colour schemes, and
exercise the write path — the failures this console has actually had (an
unstyled theme, a label pointing at a span, an input group overlapping its
button, a blank page for a missing record) were all invisible to every check
except looking.