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 is authoritative.
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.
Example: newer TypeScript releases (≥5.7) distinguish SharedArrayBuffer
from ArrayBuffer in the generic Uint8Array<TArrayBuffer extends ArrayBufferLike>.
Under such an editor, a helper returning bytes.buffer.slice(...) is typed
ArrayBufferLike and no longer satisfies @dfinity/agent’s bare-ArrayBuffer
parameters, so the editor reports an error the workspace compiler does not. At
runtime those byte sources are always ArrayBuffer-backed; a narrow
@returns {ArrayBuffer} JSDoc annotation on the helper resolves it.
- 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. - 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 import from
@dfinity/*, not@icp-sdk/core. Newerdidc/ bindgen releases emit@icp-sdk/coreimports, which clash nominally with the@dfinity/*types the app depends on. The declaration generator (npm run generate:did) rewrites them to@dfinity/*and fails if any@icp-sdk/coreimport survives; the Docker build does the same forsons_governance. When you regenerate declarations, check that the import lines 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 → didc, via `npm run generate:did` → 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
Section titled “Candid wire conventions”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 two auth sources — Internet Identity (II) and Plug
Wallet (plugSession) — and every canister call must work for both. NFID
Wallet sign-in is not offered: the wallet list has no NFID
option, and a session IdentityKit restores from storage is closed on load with a
one-time notice (utils/nfidSunset.js); authenticatedAgent is always empty.
NFID is out because, in the IdentityKit ACCOUNTS mode the app used, every call
(reads included) goes through the wallet as an ICRC-49 call sent as an update,
and the query protection refuses any ingress update to a query method before
execution.
The wallet popup shows only Internet Identity 2.0 by default; Plug and legacy II
appear with ?wallets=all or the persisted opt-in (utils/walletOptions.js).
Plug is kept but not recommended: its future compatibility, voting included, is
not guaranteed. User-facing explanation: the “Wallets and Sign-in” user guide.
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. - 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.
- GOOGLE_VERIFICATION.md — Google verification (Standard): the Internet-Identity attribute-bundle mechanism, one Google account per wallet, the address-normalisation rules, privacy model, governance controls and escape hatches.
- TELEGRAM_VERIFICATION.md — Telegram verification (Strong): the flow, the privacy model, and the veto-override attestation it now carries.
- WORLD_ID_VERIFICATION.md — World ID voter verification (Strong, legacy): flow, privacy model, configuration states including the
accepts_new_proofsretirement flag, threshold-ECDSA RP signatures, rate limits and governance controls. - DECIDEID_VERIFICATION.md — DecideID voter verification (Strong, legacy): configuration states including the
accepts_new_proofsretirement flag, the VC and OIDC paths, privacy model, rate limits and pause levers.