ADR-0008 — Frontend design system, fullscreen Mini App, pnpm
**Status.** Accepted.
Status. Accepted. Date. 2026-05-18.
Context
We need a frontend that is at once a Telegram Mini App (fullscreen-capable) and a web portal at our own domain. Both must feel modern, restrained, and exactly the same visually — different chrome, identical surface. We avoid emoji glyphs entirely; every iconographic element is an SVG curated through the better-icons ecosystem so they share weight, grid and stroke.
A second goal: the codebase must scale to dozens of pages without copy-paste. We build generators for the predictable parts (forms from the parameters manifest, tables, charts), and curated primitives for the rest.
Decision — package manager
pnpm 9.x for the Mini App workspace.
- Strictly correlated
pnpm-workspace.yamlso portal and Mini App share components. node_moduleshard-linked between projects — single source on disk.- Faster CI cold installs than npm (~3× on the same lockfile).
bunevaluated and rejected — TG WebApp SDK and some optimisation plugins (vite-plugin-compression2) hit edge cases with Bun's resolver; the perf delta is not worth the instability for v1.
Decision — build pipeline
apps/miniapp/
├── pnpm-workspace.yaml
├── vite.config.ts
├── tsconfig.json
├── index.html # Mini App
├── portal.html # Web portal (loads Telegram Login SDK)
├── public/
│ ├── parameters-manifest.json
│ └── icons/ # SVG sprite
└── src/
├── shell/ # MiniAppShell, PortalShell
├── pages/
├── components/ # primitives
├── generators/ # form / table / chart factories
├── api/, hooks/, store/, theme/, i18n/, util/
Build emits Brotli + Gzip pre-compressed assets via vite-plugin-compression2:
compression({ algorithm: "brotliCompress", threshold: 1024, deleteOriginalAssets: false }),
compression({ algorithm: "gzip", threshold: 1024, deleteOriginalAssets: false }),
Bundle budgets enforced in CI:
index.htmlroute: ≤ 200 kB Brotli (initial chunk).- Any single route chunk: ≤ 150 kB Brotli.
- Image: AVIF first, WebP fallback.
Decision — design tokens
src/theme/tokens.ts is the single source of truth. The token shape is:
{
color: {
bg: { base, raised, sunken, surface, overlay },
fg: { primary, secondary, muted, inverted },
accent: { default, hover, active, mute },
brand: { aurora, dusk, ember, mist },
status: { good, warn, bad, info },
border: { subtle, default, strong },
focus,
},
space: { 0..12 in 4px increments },
radius: { none, sm, md, lg, full },
shadow: { none, sm, md, lg, glow },
motion: { fast, base, slow, ease-out, ease-in-out },
type: { font-family, scale, line-height, weight },
z: { base, dropdown, modal, toast, top },
density: { compact, default, roomy },
}
Tokens are emitted as CSS custom properties (:root { --color-bg-base: ...; }) and as a typed tokens.ts export so React components reference them without strings.
Light and dark variants are two [data-theme="light"|"dark"] blocks on :root; the Telegram Mini App auto-binds data-theme from Telegram.WebApp.colorScheme, the portal toggles via a button.
Palette — "Helios"
Restrained, monochrome-leaning, with a single warm accent.
| Token | Light | Dark |
|---|---|---|
color.bg.base | #FAFAFA | #0B0B0E |
color.bg.raised | #FFFFFF | #141418 |
color.bg.sunken | #F3F3F4 | #08080A |
color.bg.surface | #FFFFFF | #16161B |
color.bg.overlay | rgba(0,0,0,0.6) | rgba(0,0,0,0.7) |
color.fg.primary | #111114 | #F5F5F7 |
color.fg.secondary | #4A4A52 | #9E9EA8 |
color.fg.muted | #8A8A93 | #6C6C74 |
color.accent.default | #D97757 | #E08E6A |
color.accent.hover | #C56849 | #EDA888 |
color.brand.aurora | #7BB2A4 | #7BB2A4 |
color.brand.dusk | #5C5C8A | #A0A0D6 |
color.brand.ember | #D97757 | #E08E6A |
color.brand.mist | #C9C9CF | #3A3A42 |
color.status.good | #2A8F66 | #7BD0A5 |
color.status.warn | #B17821 | #E2B872 |
color.status.bad | #B3322B | #E37068 |
color.border.subtle | #E8E8EC | #22222A |
color.border.default | #D5D5DB | #2C2C36 |
Typography:
- Display: "InterDisplay var", system fallback.
- Body: "Inter var",
font-feature-settings: "ss01","cv11". - Mono: "JetBrains Mono var", for code, IDs, command-like artefacts.
Numeric scale (rem): 0.75, 0.875, 1, 1.125, 1.25, 1.5, 1.875, 2.25, 3.
Decision — no emoji, all SVG
- No emoji glyphs anywhere in user-visible strings (the Mini App or the portal). The bot's templates may carry text symbols (
◉,◇,▸) per the existing pack — those are owner-tunable. - Iconography uses the
better-iconsCLI: a curated set of icon ids is pinned insrc/theme/icons.ts; the CLI fetches SVGs at build time into a sprite (public/icons/sprite.svg) so we ship one network request. - Inline SVG only when icon needs runtime tinting (CSS
currentColor). - No
<emoji>characters in JSX.
Decision — Mini App fullscreen + Bot API 8.0+ capabilities
The Mini App shell calls, in order:
const wa = window.Telegram.WebApp;
wa.ready();
wa.expand();
if (wa.isVersionAtLeast?.("8.0")) {
wa.requestFullscreen?.();
wa.lockOrientation?.();
}
wa.disableVerticalSwipes?.();
wa.enableClosingConfirmation?.();
wa.setHeaderColor?.("secondary_bg_color");
wa.setBackgroundColor?.("bg_color");
We support, behind a feature gate per page:
- MainButton / SecondaryButton with
hasShineEffect(8.0+) andiconCustomEmojiId(9.5+). - SettingsButton, BackButton — wired to the React router (
navigate(-1)). - CloudStorage for cross-device per-client preferences (e.g. last selected tab).
- SecureStorage for short-lived sensitive data (API token preview after creation).
- DeviceStorage for ephemeral UI state.
- HapticFeedback for non-emoji "feedback" (button presses, toasts), behind a setting.
- Fullscreen events (
fullscreenChanged,safeAreaChanged) wired into the shell so layout honours device notches and the Telegram navbar. - shareMessage / openTelegramLink / requestContact / requestWriteAccess behind feature flags.
For Bot API third-party validation we use Ed25519 verify with the production public key (e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d).
Decision — generators
src/generators/ contains:
<ParameterForm parameter={p} value={v} onChange={...}>— auto-renders fields from aParameterJSON entry (number with min/max, string with regex, enum, json editor, secret toggle). Powers every "Settings" page.<DataTable columns={...} rows={...} virtualized={true}>— TanStack Table, virtual scroll for big lists, server-driven pagination.<TimeSeriesChart series={...} window={...}>— usesvisxorrechartsdepending on density; lazy-loaded.<Heatmap data={...}>— hour-of-day × day-of-week.<Diff before={...} after={...}>— character-level diff for message edits (ADR-0006), highlighting added / removed.
Every generator accepts a density prop and respects design tokens — no magic colours.
Decision — accessibility & motion
- Contrast ≥ AA, AAA where reasonable.
- Focus rings always visible.
prefers-reduced-motionhalves all transitions; key animations switch to opacity-only.- All interactive components keyboard-operable.
Decision — analytics in the UI
Charts are tier-aware: Free shows the 60 s cached snapshot, Pro shows 5-min rollups, Premium+ live-streams (ADR-0010). The same <TimeSeriesChart> component renders all three, swapping its data hook.
Consequences
- One codebase, two surfaces, one design language.
- New pages cost minutes via generators.
- Brotli pre-compression cuts initial payload ~30 % vs gzip.
- No emoji means rendering is identical across every device and font.
See also
apps/miniapp/src/theme/tokens.ts,src/theme/icons.tsapps/miniapp/src/generators/- ADR-0007 (portal sharing the same React tree)