AssessNow is a proctored technical assessment platform. The product itself is a Node/Express
application; assessnow.online is the shop window in front of it — one static page whose
only job is to explain the product and hand the visitor off to
recruiter.assessnow.online.
A landing page is the kind of thing people reach for Next.js or SvelteKit to build. I didn't. This log walks through what the site is actually made of: five files, roughly 3,000 lines, no router, no SSR, no component library — and the specific decisions that kept it that small. Each section ends with a practice rep, because a build log you only read is a build log you forget.
1. The stack, and what got left out
Svelte 5 + TypeScript + Vite. Nothing else. The dependency list is entirely dev-time — nothing ships to the browser but the compiled bundle.
src/
├── main.ts 9 lines — mount the app
├── App.svelte 589 lines — the whole page
├── app.css 381 lines — the design system
└── lib/
├── i18n.ts 311 lines — EN/ID translation store
├── HowItWorks.svelte 317 — the only stateful section
├── ProctorSimulator.svelte — (see part 5)
└── CodeSandbox.svelte — (see part 5)
The Svelte 5 entry point is worth a beat, because it changed from Svelte 4. There's no
new App({ target }) constructor any more; components are mounted with a function:
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
What's absent is the actual decision. A single-page marketing site has one route, so a router
is dead weight. There's no data to fetch, so there's nothing for SSR to render that a pre-built static
file doesn't already contain. vite build emits a dist/ of HTML, one JS chunk
and one CSS chunk — deployable to anything that serves files. Reaching for SvelteKit here would have
bought a build server to render a page that never changes.
Scaffold a fresh npm create vite@latest -- --template svelte-ts and delete everything down
to a single mounted component. Then try to name, out loud, one thing SvelteKit would give this site that
you'd actually use. If you can't, you've internalized the decision.
2. A 60-line i18n layer
The site ships in English and Indonesian. The whole translation system is a Svelte store, and it fits on one screen:
export type Lang = 'en' | 'id';
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('lang') : null;
export const lang = writable<Lang>(stored === 'id' ? 'id' : 'en');
lang.subscribe((l) => {
if (typeof document !== 'undefined') document.documentElement.lang = l;
if (typeof localStorage !== 'undefined') localStorage.setItem('lang', l);
});
const dict: Record<Lang, Record<string, string>> = { en, id };
export const t = derived(lang, (l) => (key: string): string => dict[l][key] ?? en[key] ?? key);
/** Non-reactive lookup for code paths outside templates (e.g. log entries). */
export function tNow(key: string): string {
const l = get(lang);
return dict[l][key] ?? en[key] ?? key;
}
Four decisions are buried in there:
-
A derived store that returns a function.
$t('hero.sub')in a template re-renders whenlangchanges, becausetis a store whose value is a lookup function. Swap the language and every string on the page follows — no page reload, no re-render plumbing. -
A two-step fallback chain.
dict[l][key] ?? en[key] ?? key. A missing Indonesian string falls back to English; a missing key renders as the key itself. The failure mode of a typo'd key is an ugly string, never a blank section — which is exactly the trade you want on a page whose entire purpose is copy. -
A separate non-reactive escape hatch.
tNow()exists because imperative code — pushing a timestamped log entry — needs a string now, not a subscription. Mixing the two would mean reactive strings inside data that was never meant to be reactive. -
Persistence as a side effect of subscribing. The
lang.subscribeblock writes tolocalStorageand sets<html lang>— so the language choice survives a reload and screen readers pick the right voice. One subscription, two jobs, zero call sites that have to remember.
The keys are flat and hold whole sentences ('f2.p': 'On-device face presence and…') rather
than interpolated fragments. Indonesian doesn't share English's clause order; giving the translator a
whole sentence lets them restructure it. Fragment-level keys quietly force every language into English
grammar.
Add a third language with two keys and one key deliberately missing. Watch the fallback chain do its
job. Then break it on purpose: make t a plain function instead of a derived store, and
find out why the page stops updating when you toggle the language.
3. Copy as data, layout as a loop
The features section renders six blocks. There are not six blocks of markup:
{#each ['f1', 'f2', 'f3', 'f4', 'f5', 'f6'] as f, i}
<div class="feature-highlight" class:alt={i % 2 === 1}>
<div class="fh-inner">
<div class="fh-index">0{i + 1}</div>
<div>
<h3>{$t(f + '.h')}</h3>
<p>{$t(f + '.p')}</p>
</div>
</div>
</div>
{/each}
The i18n dictionary became the content layer. Copy lives in i18n.ts; the component owns
only the shape. Adding a seventh feature is one array entry and two dictionary keys, and the alternating
background (class:alt={i % 2 === 1}) keeps working without anyone touching CSS.
The headline treatment shows the same idea handling a harder case. The wordmark is two-tone — "AssessNow" — so the heading is split into three keys:
<h2><span>{$t('sec.features.h.pre')}</span><b>{$t('sec.features.h.em')}</b><span>{$t('sec.features.h.post')}</span></h2>
'sec.features.h.pre': 'What Assess',
'sec.features.h.em': 'Now',
'sec.features.h.post': ' Does',
Splitting a sentence across keys is normally the mistake I warned about in part 2. It's justified here for exactly one reason: the split is a visual boundary (a colour change inside a brand name), not a grammatical one, and the brand name doesn't translate. Knowing which rule you're breaking, and why, is the whole skill.
Add a seventh feature card end to end without opening App.svelte's <style>
block. If you find yourself needing to, the abstraction leaked — work out where.
4. The design system is 30 CSS variables
No Tailwind, no component library. app.css defines a palette, three type families, and a
handful of primitives — .btn, .card, .chip, .trust —
and every component composes from those.
:root {
--paper: #ffffff;
--ink: #1b1714;
--ink-soft: #4a423b;
--line: #d8d0c2;
--accent: #b8431f; /* terracotta */
--serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* Components reference --font-mono; alias it so it can't silently fall back. */
--font-mono: var(--mono);
}
That last comment is a scar. A CSS variable that doesn't exist doesn't throw — it silently resolves to
nothing and the browser falls back to Times New Roman. Two components had been written against
--font-mono while the token was named --mono. The fix is the alias; the lesson
is that custom properties fail silently, so the token layer is the one place worth being
pedantic about names.
Three details in that file do disproportionate work:
-
scroll-margin-top: 90pxon.section-block. The header isposition: fixed, so a naive anchor jump parks the section heading underneath it. One property, and every in-page link lands correctly. It's the single most-forgotten line in fixed-header layouts. -
:focus-visiblerather than:focusfor the outline ring. Keyboard users get a visible terracotta ring; mouse users don't get an outline stuck on a button they just clicked. -
A
prefers-reduced-motionblock that flattens every animation and transition to 0.01ms — paired with a check in the JS, because CSS can't reachscrollIntoView:
Smooth scrolling is a vestibular trigger. The CSS media query alone doesn't stop a JS-initiated smooth scroll, so the preference has to be honoured in both places.const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; el.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth' });
Delete the --font-mono alias and reload. Note how nothing errors and nothing looks broken
until you look closely — then restore it. That's what a silent failure feels like, and it's why you now
check the computed style rather than trusting the page.
5. The interactive demo I deleted
The site used to have two live demos. ProctorSimulator.svelte (934 lines) let you click
"switch tab", "second person enters frame", "paste code" and watch a Trust Score drop and an event log
fill up. CodeSandbox.svelte (561 lines) let you edit a sum() function and run
it against three unit tests.
They were the most fun code in the repo, and they were built honestly:
/* Impacts mirror the real integrity weights (server/src/integrity.js):
tab_blur 8 · high_noise 5 · face_missing 10 · paste 12 · multi_face 25.
Log copy claims only what the product does — face counting, not
biometric identification. */
The simulator's penalties are the production penalties. The sandbox's boot log names the real limits (128MB heap, 3s timeout, dynamic evaluation blocked) and then states plainly that the demo runs in your browser while the real thing runs in an isolated server worker. Both were written so that nothing on the page overclaims.
They still came out. Both components are still in src/lib/; App.svelte simply
stopped importing them. The reasoning: this is an integrity product, and a simulated integrity
engine — however carefully hedged — asks the visitor to evaluate a mock. A buyer of a trust product is
the last person you want squinting at a fake. The page they got instead states what the system does and
sends them to the real dashboard.
Keeping the files unimported rather than deleting them is deliberate too: the decision is reversible,
the sim.* and sb.* i18n keys are still in the dictionary, and a tree-shaken
build doesn't ship a byte of it.
Re-mount ProctorSimulator in App.svelte, use it, and then argue the opposite
case in writing: why keeping the demo would have been the right call. If you can't make that argument
well, you didn't actually make a decision — you had a preference.
6. Local state: runes, and where they stop
HowItWorks.svelte is the only stateful section left, and its state is one line:
let activeTab = $state<'recruiter' | 'candidate' | 'report'>('recruiter');
Svelte 5's $state replaces the compiler's old assignment-tracking magic with an explicit
rune, and the union type means a typo'd tab id fails at compile time, not in a dead {#if}
branch that renders nothing.
The dividing line worth internalizing: runes for state one component owns, stores for state that
crosses component boundaries. Language is a store because the header toggles it and every component
reads it. The active tab is a rune because nothing outside HowItWorks has any business
knowing which tab is open. Promoting local state into a global store is the most common way a small app
starts feeling like a large one.
And the small discipline that keeps a rune-based component honest — cleanup:
onMount(() => {
micInterval = setInterval(() => { /* … */ }, 800);
return () => clearInterval(micInterval); // ← the whole point
});
The function returned from onMount runs on destroy. Without it, an interval keeps firing
against a component that no longer exists — the leak that survives every navigation and is invisible
until the tab has been open an hour.
Rebuild HowItWorks's tab switcher from an empty file: three tabs, an aria-current
on the active one, panes swapped with {#if}. Then diff it against the original. The diff is
your actual skill gap — everything you had to look up is the part you'd been letting the compiler (or an
assistant) hold for you.
Read it back
Three questions worth answering from the code, before checking:
-
tis aderivedstore returning a function, not a plain function. Trace what breaks in the template if it were a plain function — and say precisely why Svelte would stop re-rendering. -
i18n.tsguards everylocalStorageanddocumentaccess withtypeof … !== 'undefined'. The site has no SSR. Is the guard dead code, or is it buying something? -
The features loop is keyed by array index (
{#each [...] as f, i}). Under what change to this component would that become a real bug?
The through-line
Every part of this build was a subtraction. No framework, because there's one route. No CSS library, because thirty variables cover it. No demo, because a fake integrity engine undermines a real one. The site is small not because it does little, but because each thing it doesn't do was refused on purpose.
That's the part that doesn't transfer by reading. Do the reps.