Imported from Naz-Ovh/probhat-trainer (
AGENTS.md). Install upstream withnpx skills add Naz-Ovh/probhat-trainer. Copyright stays with the author.
AGENTS.md
Working rules for this repository. These override default behaviour.
Project
Probhat Trainer — a SvelteKit app for learning the Bengali Probhat keyboard layout.
The authoritative brief is PROMPT.md; treat it as the spec and follow it 1:1.
Styling — read this before touching any .svelte file
1. Never write substantial CSS inside a component
Component styles live in their own SCSS partial under src/styles/. The component's
<style> block does nothing but @use that partial:
<style lang="scss">
@use 'components/keyboard';
</style>
Only genuinely critical, genuinely component-specific declarations may be written inline in the component — the kind that would be actively misleading to read anywhere else. General or merely-convenient CSS does not qualify. If you are unsure, it goes in the partial.
2. Style directory layout
src/styles/
_tokens.scss design tokens (CSS custom properties) — light + dark + mode
_mixins.scss a small, shared mixin set
global.scss reset, @font-face, base typography (imported once, in +layout)
components/ one partial per component, named after it
_keyboard.scss
_typing-surface.scss
routes/ one partial per route, when a route needs its own styles
_learn.scss
src/styles is on the Sass load path (css.preprocessorOptions.scss.loadPaths in
vite.config.ts), so partials are referenced by bare path — @use 'components/keyboard',
not a relative ../../styles/... chain.
3. The design language
Ink on paper, with colour reserved for the letterforms. This is a product about Bengali type, so the interface is monochrome and the entire colour budget goes to the things being taught:
- the eight key-classification hues, drawn only as the bottom edge of a keycap
- correct / error while typing
There is no brand accent. The "accent" token is ink, so a primary button is solid black on paper (solid paper on ink in dark mode). Adding a decorative hue would dilute the only place colour carries meaning.
Fixed decisions, do not drift:
| Type | Geist (interface), Geist Mono (all numbers, units, keystroke labels), Noto Sans Bengali. All self-hosted and subset by npm run font. |
| Neutrals | Cool. A warm cream page with a terracotta accent is the house style of every generated app on the internet. |
| Shape | Square. --radius: 0. The only exception is a keycap at 5px, because it is a physical object. There are no pills. |
| Grouping | Hairlines and space. There is deliberately no card or surface mixin. |
| Emphasis | Weight and scale, never colour. The next key inverts to solid ink rather than glowing. |
| Numbers | Always --font-mono with data-numeric for tabular figures, so readings do not jitter. |
| Dashes | No em dash (-) or en dash anywhere user-visible. Use a comma, a colon, or two sentences. |
Never dim text with opacity. Fading a 25px Bengali glyph to 28% puts it at 1.9:1,
which is a legibility failure, not a de-emphasis. Recede by removing emphasis: step the
colour to --ink-3 (AA-verified on every surface), drop the border weight, remove the
stripe.
Every colour pair in _tokens.scss is verified at WCAG AA against every surface it can
land on. If you add a token, verify it the same way before shipping.
4. SCSS, always
Use SCSS (lang="scss", sass-embedded). Never plain CSS, never a CSS-in-JS or utility
framework. There is no component library — bundle size is a product feature here.
5. Selector naming
BEM: .block__element--modifier. Names describe what the thing is, not what it looks
like. Scoping is Svelte's job; do not hand-prefix.
6. Do not over-reuse style
Each component owns its own styles. Share only design tokens and the small mixin set.
Resist extracting a shared .btn / .card layer until something is genuinely repeated
three or more times.
Typing screens are viewport panels, not documents
/practice, /write, /drill and drill lessons are fixed-height workspaces. The
keyboard must stay on screen while the learner types: if they cannot see the board, they
cannot see what they are pressing, which is the mechanic the product rests on.
The rules that make it hold:
- The route owns the height (
@include m.viewport-panel), because only the route knows what else is on the page. The view fills the remainder with.practice--fit. min-block-size: 0on the filling item is load-bearing. A flex item's automatic minimum is its content height, so without it nothing can shrink and the board slides off.- The board absorbs the variation. Content above it is sized to its content and never clipped; the board takes what is left. Never solve a height overflow by clipping the diagnostic, which is the text the learner most needs to read.
- The coach slot under the typing line is permanent: it shows the next keystroke while things are going well and the error diagnosis when they are not. Same slot, same height, so a mistake never resizes anything. Rendering it only on error shoved the board down the page; putting it in the gutter beside the board instead squeezed the board narrow. A slot that is always there costs one row and moves nothing.
- Chrome is merged, not stacked: the corpus picker lives inside the header row, because two full-width strips cost about 70px that the board needs.
There is an e2e test asserting the board stays inside the viewport, with a diagnostic showing, at 1440x740, 1366x640 and 1280x600. Do not delete it; this regressed twice.
SvelteKit 3
This project pins @sveltejs/kit@3.0.0-next.27. SvelteKit 2 patterns do not apply:
- Config lives in
vite.config.ts, passed to thesveltekit()plugin. There is nosvelte.config.js. $lib→#lib, via Node subpath imports, with explicit file extensions:import { x } from '#lib/layout/probhat.ts'.tsconfig.jsonextends$app/tsconfig.$app/storesis gone — use$app/state.$service-workeris gone — use$app/manifest,$app/paths,$app/service-worker.vitePreprocesscomes from@sveltejs/vite-plugin-svelte, not from@sveltejs/kit/vite.- Requires Node ≥ 22.17, TypeScript 6, Svelte ≥ 5.56.4, Vite ≥ 8.0.12.
Stale route types in the editor
resolve() is typed against a route union that svelte-kit sync generates into
node_modules/$app/types/. Most editors do not watch node_modules, so after adding
or removing a route the language server keeps serving the old union and reports phantom
errors like:
Argument of type '["/learn"]' is not assignable to parameter of type '[route: "/"] | ...'
A union containing only "/" is the giveaway: that is the state from before any routes
existed. The source is fine — npm run check runs svelte-kit sync first, so it will be
clean while the editor is still red.
Fix: restart the language server (Zed: editor: restart language server). If it persists,
regenerate from scratch — sync does not prune type directories for deleted routes:
rm -rf .svelte-kit 'node_modules/$app' && npx svelte-kit sync
TypeScript
strict: true. Zero any, zero @ts-ignore. Discriminated unions for state machines.
Branded types for KeyCode, Grapheme, LessonId.
Gotcha learned the hard way: when building a discriminated-union state object, write
the discriminant last in the object literal. Spreading a value that already carries a
status field will silently overwrite the one you set, and TypeScript will not catch it.
Architecture
src/lib/layout/, src/lib/engine/ and src/lib/content/ are pure and
framework-free — they must never import from Svelte. They are plain TypeScript with unit
tests, and they are the product. src/lib/ui/ and src/routes/ are a view over them.
Never hardcode a key mapping twice. src/lib/layout/probhat.ts is the single source of
truth; everything else derives from it.
Testing
Vitest for the pure engines — test these hard. Playwright for one happy-path e2e. Every lesson and corpus string must be proven typeable: the documented keystroke sequence has to replay it byte-exact after NFC normalisation.
Commands
npm run dev npm run build npm run preview
npm run check npm run lint npm run format
npm run test:unit npm run test:e2e
node scripts/build-font.mjs # re-subset the Bengali font