ui/, embedded in the Go binary. Server-rendered pages cover bootstrap and direct preview/download responses. This guide owns frontend implementation contracts; Web app owns user actions and labels, and the API reference owns wire contracts.
Source layout
Build and embedding
Use:core/ui/spa/dist is generated and never tracked. Its manifest binds the
generated bytes to ui/src, ui/public, index.html, package.json,
bun.lock, and vite.config.js; it also requires the web manifest, hashed
entry script, and distributed third-party notices. A new file emitted anywhere
under dist is included automatically. A new build input outside the listed
paths must be added to core/uiassets.sourceInputs in the same change. The
default development Go build verifies that manifest and serves the generated
directory from disk. Missing or stale assets panic during server assembly with
instructions to run make ui. Production paths compile with embedded_ui,
where go:embed requires the generated tree at compile time.
core/uiassets owns deterministic source/bundle hashing and bundle-contract
validation. hack/ui-assets is the build command used by Make, CI, and Docker;
runtime code imports only the verifier, never the command.
GitHub Actions builds the browser assets once from the immutable target revision
and passes the verified artifact to tagged Go test and cross-build jobs. Release
binaries consume the same revision-named artifact. Docker builds the UI in its
pinned Bun stage and copies only ui/dist into the Go stage; Bun and
node_modules do not enter either runtime image. All paths preserve a single
offline binary and serve third-party-notices.txt with the application.
Settings reads build_version and optional build_revision from the existing
/api/whoami session response. The executable supplies this identity once at
startup through app.Options from release linker values or Go VCS metadata,
not ui/package.json. Shared server assembly does not change browser ownership,
routes, or the embedded bundle.
Do not add a separate frontend version, version request, or update checker.
Tagged production builds show only the version in Settings; development builds
also show the source revision. The CLI’s developmentVersion owns the default
source version line when no release version was injected.
App.svelte loads every route, including login and dashboard, through the existing
Lazy.svelte component. There are no route
re-export wrappers. A native Rolldown group keeps the shell, the API transport
and first-paint operations (api-core.js), filing context and their static
dependencies together. Shell modules import from api-core.js; screens import
from api.js, which re-exports it. A separate group combines small document
controls (including the shared type-to-select picker), the screen API operations
(api.js), link display, clipboard/query helpers, upload-list refresh state and
the shared configuration link inventory; other shared dependencies use normal
chunking.
The shared password-unlock indicator belongs to this group and reads the
existing encryption state exposed by document list and detail responses.
The QR encoder remains a separate on-demand import. My account defers archive configuration and loads
mailbox code only for members who can manage mailboxes.
Keep the shell at or
below 36 KiB gzip (35 KiB for the English-only shell plus the localization
runtime) and remeasure the actual route dependency set after UI changes.
Measure the current production graph rather than assuming historical route sizes.
Settings, research, and QR code generation remain deferred. Filing context lives
in the shell; the system membership editor loads with archive configuration.
Static assets are shared normally by the browser, not by an application cache.
ui/src/bundle.test.js builds the production graph in memory and checks the
36 KiB initial budget, document request count, deferred features, and static
dependency cycles. Production browser tests use
PLAYWRIGHT_PRODUCTION=1 bun run e2e; preview serves its built assets locally
and proxies only backend routes.
Routing and loading
router.svelte.js owns hash state; App.svelte chooses routes and persistent
navigation. Screens support reload, Back/Forward and narrow layouts through this
router, without a second navigation registry.
Filing systems and request lifetimes
GET /api/jd/systems is authoritative. systems.svelte.js stores only its
accessible list, introduced state, current code and account/system generation.
Before first prefixed import, the empty list preserves unprefixed UI and URLs;
introduction is not a browser preference. Filing systems owns import and
storage semantics; Permissions owns authorization.
App.svelte waits for identity and the catalog before mounting collection screens
or requesting tree/counters. An unqualified route selects the accessible original
system, otherwise the first enterable system, and writes system=CODE before
collection reads. An explicit unavailable system never falls back. No-access
screens offer profile/sign-out without document-bearing settings requests.
An unqualified numeric document link loads the authorized document intrinsically;
its returned system_code binds the route, sidebar and subsequent metadata/blob
requests. Search and Omnibox alone recognize canonical SYS.AC
category addresses through filing-address.js, call /api/jd/resolve, and route
to Documents with the returned runtime category ID. A 404 and noncanonical text
both follow ordinary Search.
Display the server’s non-unique jd_address, while private numeric links remain
the exact-document route. Category pickers stay two-level within the system.
Native links and go() use the scoped hash helper. Switching drops foreign
filters/IDs and increments generation. The shell keys its scoped subtree by that
generation, clearing tree, counters, selections, Reveal, upload/share/pairing/
import/OAuth dialogs and parked research. Expansion state uses
suchi.jd.open.<user>.<system>; theme is presentation-only. Route abort/version
guards still reject superseded reads within one generation.
API helpers capture complete URLs before requests and reject older account/system
responses. Single-flight GET keys include generation and full URL. Blob links and
text taxonomy exports use the same captured destination; exports share transport,
logout guards and structured errors. A current-system system_unavailable clears
the surface. Continuing upload or pairing-cleanup errors from a previous system
cannot invalidate the current one. All new upload entry points, including window
drops, require the route’s system to be ready.
Dependencies stay one-way: api.js → api-core.js → systems.svelte.js, and
system-routing.js → api-core.js.
Catalog refresh/route entry live in the first module; synchronous scope helpers
live in the last, which has no API dependency. Browser guards prevent stale
display; the server enforces access.
SystemSettings.svelte edits the current name and complete explicit membership
set for admins after introduction. Browser directory pages do not own that set:
off-page/inactive grants survive until removed explicitly. Admin access is implicit
and read-only; codes are permanent and there is no delete-system control. Filing
configuration, metadata, rules and mailboxes are scoped; identity, watched-folder
configuration, OCR/models and backups remain visibly server-wide.
Screen-level behavior lives in document screens and
settings and account screens.
API conventions
Components call namedapi.js operations, which own fetch and error parsing
through api-core.js. Add an operation to api-core.js only when a shell module
needs it on first paint. net.js handles local-endpoint validation. Add and test new server contracts
before using them in a screen. API field names may stay technical; visible labels
follow Web app.
Interface wording
Import shared values instead of duplicating them. Update the owning feature guide,
Web app and relevant browser regression when labels/behavior change.
Localization
Browser messages live inui/src/i18n/locales/<locale>.json; server-rendered
bootstrap, sign-in, setup, and share-page messages live in
core/i18n/strings/<locale>.yaml. core/i18n.Registry negotiates persisted user
preference, suchi_locale, and Accept-Language for server pages. The SPA uses
the same persisted preference, then the locale cookie and browser languages.
An unsupported locale falls back to English. A missing translated browser
message falls back to its English entry, then to the stable message key.
Keys use <feature>.<lowerCamelName>. Keep an existing key stable when English
wording changes. Count-sensitive messages add CLDR category suffixes such as
.one and .other; locale-only .zero, .two, .few, and .many entries
are allowed when that language requires them. Placeholders are named, not
positional, and every locale must preserve the English placeholder set.
lib/i18n.svelte.js owns reactive locale state and interpolation.
lib/locale-runtime.js discovers and validates deferred catalogs.
lib/format.js owns Intl date, number, list, collation, and plural helpers.
Catalog metadata supplies ltr or rtl; applying a locale updates both
<html lang> and <html dir>. Do not assemble sentences with translated
fragments, lowercase translated labels to make grammar fit, or choose English
plural suffixes in components.
Adding a locale requires a complete browser catalog and matching server catalog.
Run bun test src/lib/i18n.test.js for catalog parity, placeholder, fallback,
plural, and locale-format checks; use a real browser smoke run to verify the
profile selector and document root attributes.
Security
Login/demo authentication uses HttpOnly cookies. Local-password sign-in keeps the password only while a stagedtwo_factor_required exchange reveals the
authenticator/recovery-code field; account changes, success, navigation, and
destroy clear both credentials. The SPA does not persist session, mailbox,
provider, account-password, authenticator, enrollment, recovery-code, token, or
pairing secrets. TwoFactorSettings.svelte keeps enrollment material and
one-time recovery codes in component memory, clears them across failure/exit/
account change, and requires explicit acknowledgement before closing the
one-time recovery-code display. LinkQR.svelte keeps HTTP(S) as its default
allowlist; authenticator setup explicitly admits only otpauth.
Successful email or two-factor session rotation advances the request generation,
invalidating unfinished reads/retries. AdminTwoFactorResetDialog.svelte clears
the acting administrator’s password and factor on every outcome. Concurrent
demo writes share one upgrade; scratch identity remains restricted for API and
direct resources. Cookie mutations pass server same-origin/Fetch Metadata checks.
Serve previews through dedicated endpoints with restrictive CSP. Render ordinary
text through Svelte escaping; sanitize marked-up search snippets
before display. Destructive confirmations use ConfirmDialog.svelte, backed by
the native <dialog> element for Escape, focus return, and an inert background;
screens never call window.confirm. Browser visibility never substitutes for
server authorization.
Adding or changing a screen
- Reuse named API operations, existing route registries and shared controls.
- Keep screen state in
routes/; cover loading, empty, failure and permission states. - Check desktop/mobile, keyboard behavior and text overflow.
- Update the owning user/architecture guide and run relevant API integration tests.
- Run
make ui-check; generated embedded assets remain ignored.
filing systems cases cover delayed reads, account races,
captured uploads, deep links/history, no-access controls, first import, membership
paging/name changes, pairing/share/OAuth cleanup and parked research. These verify
compiled browser lifetimes, not server isolation: real-server checks must also
exercise membership, document ACLs, delegated credentials and import/render work.