Helios

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.yaml so portal and Mini App share components.
  • node_modules hard-linked between projects — single source on disk.
  • Faster CI cold installs than npm (~3× on the same lockfile).
  • bun evaluated 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.html route: ≤ 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.

TokenLightDark
color.bg.base#FAFAFA#0B0B0E
color.bg.raised#FFFFFF#141418
color.bg.sunken#F3F3F4#08080A
color.bg.surface#FFFFFF#16161B
color.bg.overlayrgba(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-icons CLI: a curated set of icon ids is pinned in src/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+) and iconCustomEmojiId (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 a Parameter JSON 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={...}> — uses visx or recharts depending 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-motion halves 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.ts
  • apps/miniapp/src/generators/
  • ADR-0007 (portal sharing the same React tree)