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.

Practice — 15 min

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 when lang changes, because t is 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.subscribe block writes to localStorage and 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.

Practice — 20 min

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.

Practice — 15 min

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: 90px on .section-block. The header is position: 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-visible rather than :focus for 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-motion block that flattens every animation and transition to 0.01ms — paired with a check in the JS, because CSS can't reach scrollIntoView:
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    el.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth' });
    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.
Practice — 15 min

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.

Practice — 20 min

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.

Practice — 20 min

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:

  • t is a derived store 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.ts guards every localStorage and document access with typeof … !== '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.