Frontend Architecture
How the OhShii Launcher frontend (src/frontend/) is built: the stack, the
JS + JSDoc type-checking model, the TypeScript version policy, the two
canister bridges, Candid wire conventions, auth providers, and the design
system.
This page is the map of how the frontend is put together — read it before
adding a page, component, or canister call. It describes the architecture; the
canonical implementations live in the files it points to
(src/frontend/tsconfig.json,
types/canister-types.js,
utils/canister.js,
components/ui/). When this page and the
code disagree, the code wins — fix this page.
Stack at a glance
Section titled “Stack at a glance”| Concern | Choice | Notes |
|---|---|---|
| UI library | React 18 (^18.2.0) | function components + hooks only |
| Build tool | Vite 7 (^7.0.6) | dev server + production bundle; strips types, does not type-check |
| Source language | Plain JavaScript (.js / .jsx) | no .ts/.tsx application source |
| Types | JSDoc + // @ts-check | type checking, not type compilation |
| Type checker | TypeScript (^5.3.3, lockfile → 5.8.3) | dev-only; never emits — noEmit: true |
| Styling | Tailwind CSS + design tokens | see “Design system” below |
| IC client | @dfinity/agent / candid / principal (^2.x) | actors, certificates, read_state |
There is no separate src/frontend/package.json — the frontend shares the
repo-root package.json and its single node_modules. The frontend tsconfig
lives at src/frontend/tsconfig.json.
Why JavaScript + JSDoc (not TypeScript source)
Section titled “Why JavaScript + JSDoc (not TypeScript source)”Application code is plain .js / .jsx. Types are expressed in JSDoc
comments and checked by tsc in checkJs mode. Files opt in with a
// @ts-check pragma at the top (a few legacy files opt out with
// @ts-nocheck).
// @ts-check/** @typedef {import('../types/canister-types').Campaign} Campaign */
const [campaign, setCampaign] = useState(/** @type {Campaign | null} */ (null));The tsconfig that drives this:
// src/frontend/tsconfig.json (excerpt){ "compilerOptions": { "checkJs": true, // type-check .js/.jsx "allowJs": true, "noEmit": true, // never emit — Vite does the bundling "strict": false, // relaxed during the JSDoc migration "strictNullChecks": false, "moduleResolution": "bundler", "skipLibCheck": true, "paths": { "@/*": ["../src/*"], "@declarations/*": ["../declarations/*"] } }}Consequences worth internalizing:
- Vite never type-checks. A type error does not break
npm run devor the production build. The only thing that catches type errors is the gate below. strictis off. Many null-related errors are intentionally suppressed while the codebase finishes its JSDoc migration. Do not rely on strict-null-checking to catch bugs here.- Actors are
anypast the IDL bridge.tsccannot see a missing canister method — that is a runtime failure. See the two bridges below.
The mandatory check
Section titled “The mandatory check”After any frontend change, from the repo root:
npx tsc --noEmit -p src/frontendThis must be clean before merge. It runs the workspace TypeScript (see the version policy next) and is the single source of truth for “does the frontend type-check”.
TypeScript version policy (read this before trusting a red squiggle)
Section titled “TypeScript version policy (read this before trusting a red squiggle)”The source of truth is the typescript version pinned in the root
package.json devDependencies (currently the 5.x line; the lockfile
resolves 5.8.3). The gate npx tsc --noEmit -p src/frontend runs that
binary from node_modules. If the gate is green, the frontend type-checks —
full stop.
Why your editor may disagree (the phantom-error trap)
Section titled “Why your editor may disagree (the phantom-error trap)”VSCode (and other IDEs) ship their own bundled TypeScript, which is often newer than the workspace version. When the two diverge, the editor can paint a file red with errors that the gate does not report — and that are not real bugs.
Concrete, real recurrence:
utils/canisterModuleHash.js
showed two errors in the editor while tsc -p src/frontend was clean. Root
cause: the workspace TS (5.8.3) treated SharedArrayBuffer as assignable to
ArrayBuffer, but the editor’s bundled TS (6.0.x) brand-differentiates
them (the TS ≥5.7 generic Uint8Array<TArrayBuffer extends ArrayBufferLike>
change). A helper returning bytes.buffer.slice(...) is typed ArrayBufferLike
(ArrayBuffer | SharedArrayBuffer) under the newer compiler, which no longer
satisfies @dfinity/agent’s bare-ArrayBuffer parameters
(ReadStateOptions.paths, Certificate.lookup, Certificate.create). At
runtime these byte sources (TextEncoder.encode, Principal.toUint8Array) are
always ArrayBuffer-backed, so the SharedArrayBuffer branch is a pure
type-level artifact — a phantom error. The fix was a narrow
@returns {ArrayBuffer} + JSDoc cast on the helper.
- The gate is authoritative, not the editor. When the editor shows red but
npx tsc --noEmit -p src/frontendis clean, suspect a version skew before suspecting your code. Reproduce against the workspace version:./node_modules/.bin/tsc --noEmit -p src/frontend. - Make the editor use the workspace version. In VSCode, run
“TypeScript: Select TypeScript Version” → “Use Workspace Version”, or add
.vscode/settings.json:This aligns the language server with the gate and eliminates phantom{ "typescript.tsdk": "node_modules/typescript/lib" }ArrayBufferLike-class errors. (No.vscode/directory is committed today — adding this file is the recommended fix.) - Bumping the workspace TypeScript is a coordinated change. A newer
tscsurfaces stricter checks (theArrayBufferbrand split above is one). If you raise thetypescriptdevDependency, re-run the full gate and fix every new diagnostic in the same change — do not bump and leave the gate red. - Generated declarations must import from
@dfinity/*, never@icp-sdk/core. This is enforced by theDeploy/docker-build.shpre-check — it grepssrc/declarationsand fails the build on any@icp-sdk/corehit. A newerdidc/ bindgen emits@icp-sdk/coreimports that clash nominally with the@dfinity/*types the app actually depends on — same family of version-skew breakage, different surface. When you regenerate declarations, verify the head import lines still read@dfinity/*.
Directory layout (src/frontend/src/)
Section titled “Directory layout (src/frontend/src/)”| Path | Purpose |
|---|---|
index.jsx, App.jsx | SPA entry + top-level routing |
pages/ | Route-level screens (e.g. IcoDetailsPage, OnsVotingPage, DaoDetailsPage, SystemStatusPage) |
components/ | Reusable components; components/ui/ holds the design-system primitives (Card, Modal, Pill, Tabs, CopyButton, Skeleton, …) |
hooks/ | Custom React hooks (e.g. useShieldStatus, useCanisterHealth, useSlidingTabIndicator) |
contexts/ | React context providers (auth / wallet session, etc.) |
utils/ | Non-React helpers. utils/canister.js is the IDL + actor bridge (see below) |
types/ | canister-types.js (type bridge), domain.d.ts (UI/domain/REST types), window.d.ts, candid.js |
config/ | Static config / canister IDs |
workers/ | Web Workers (e.g. the Shield PoW worker) |
assets/, styles/ | CSS, design-token stylesheets, images |
The two bridges (every canister call funnels through both)
Section titled “The two bridges (every canister call funnels through both)”This is the load-bearing contract of the frontend. The canonical implementations are the two files named in the subsections below; the summary:
Backend .did → dfx generate / didc → src/declarations/<canister>/<canister>.did.d.ts (generated, @dfinity/* imports only) → src/frontend/src/types/canister-types.js (TYPE bridge) → src/frontend/src/types/domain.d.ts (UI / domain / REST view-models) → components / pages / utilsBridge 1 — types/canister-types.js (types, compile-time)
Section titled “Bridge 1 — types/canister-types.js (types, compile-time)”Single source of truth for every Candid type the frontend touches. Every
record / variant / result-alias / _SERVICE used outside this file is
re-exported here as a @typedef from the generated .did.d.ts. Components
import the alias from the bridge, never from @declarations/* directly (the
only exception is _SERVICE types used to annotate ActorSubclass<_SERVICE>).
Caught by: npx tsc --noEmit -p src/frontend (missing field, snake_case ↔
camelCase mismatch, missing [] on opt T, bigint/number arithmetic).
Bridge 2 — utils/canister.js (IDL + actors, runtime)
Section titled “Bridge 2 — utils/canister.js (IDL + actors, runtime)”Single source of truth for IDL.Service({...}) factories and getXxxActor(...)
helpers. Every page/component that needs an actor goes through a helper
(getBackendActor, getStorageActor, getLedgerActor,
getOhshiiGovernanceActor, createActorWithPlugSupport, …). Never define an
IDL.Service({...}) inside a page, and never call Actor.createActor
outside this module (the one documented exception is
utils/onsLockCreation.js).
Caught by: nothing automatic. A method missing from the IDL is invisible to
tsc (actors are any past the bridge) and surfaces only at runtime as
TypeError: <minified>.<method> is not a function. This is why widening a flow
(enabling a button, swapping a method, upgrading a canister .did) requires
checking every actor.method(...) the path reaches — not just the one you
edited.
types/domain.d.ts (UI / domain / external types)
Section titled “types/domain.d.ts (UI / domain / external types)”Component props, transformed view-models, REST payloads (e.g. ICPSwap),
browser-only shapes, and AuthSource. It may import Candid types via
import('./canister-types') but never imports @declarations directly.
Candid wire conventions (do not “fix” these)
Section titled “Candid wire conventions (do not “fix” these)”The generated types mirror the wire format exactly:
- Fields are
snake_case.campaign.campaign_id,sponsor.sponsor_code— nevercampaignId. The field name is the contract. opt T→[] | [T]. Access withresult.opt_field[0], not.opt_field.nat/nat64→bigint. Use1000nliterals; never donumberarithmetic on a ledger amount.- Lowercase Candid variant labels (
variant { ok; err }from Motoko / ICPSwap) are case-sensitive and are notOk/Err. Decode/destructure against the exact casing (it bites on the backend Rust side too).
Auth providers & actor creation
Section titled “Auth providers & actor creation”The app supports three auth sources — Internet Identity (II), NFID
(authenticatedAgent), and Plug Wallet (plugSession) — and every canister
call must work for all three. The discipline that keeps them working:
- Use the centralized
getXxxActor(...)helpers inutils/canister.js. Do not reimplementHttpAgent/actor construction in a page. - Never pass a Plug session as
new HttpAgent({ identity })— a Plug session is{ agent, principalId, accountId }, not anIdentity; use its.agent. - Use
getLedgerActor(...)for ICP transfers/approvals — it routes through Plug’screateActorso signing and the canister whitelist work. - For NFID, call canisters with
authenticatedAgent(anAgent), not the legacy identity-like object. - When aggregating “current auth”, prefer the order
plugSession || authenticatedAgent || identity. - Plug does not work for transfers on the local network (certificate verification) — guard those flows and tell the user to use II locally.
Design system
Section titled “Design system”The visual language — design tokens, the section-panel / stat-row / pill /
carved-tile patterns, the CopyButton security-verification modal, colour
roles, CLS-safe async loading — is captured in the primitives under
components/ui/ and the Tailwind config /
design-token stylesheets in assets/ and styles/. Build new UI from those
primitives and match the existing components (TokenInformation,
DaoInformation, TokenPricingChart, DaoDetailsPage) so the launcher and
locker stay visually coherent rather than re-rolling one-off panels.
See also
Section titled “See also”- ARCHITECTURE.md — Canister map and governance scope table.
- WORKFLOWS.md — Create LGE, token, vote, create/execute proposal, backend vs frontend upgrade, self-snapshot flows.
- GOVERNANCE.md — ONS vs SONS, proposal categories, snapshot/restore.
- OHSSHII_LOCKER_INTEGRATION.md — OhShii Locker token list and lock management via ONS proposals, SONS vesting vs Locker.
- LOGGING_SYSTEM.md — Three-tier logging architecture, stable memory log details, frontend monitoring.
- WORLD_ID_VERIFICATION.md — World ID voter verification: flow, privacy model, three-state config, rate limits, governance controls and operator checklist.