-
-
Power
-
+
+ {#if show.secondaryEffort}
+
+
+
+
+
+
+ {/if}
+ {#if show.streamCharts}
+
+
+ Power
+
+
+
+ Gradient
+
+
-
- Gradient
-
-
-
+ {/if}
@@ -489,7 +524,7 @@
.route {
flex: 2 1 0;
- min-height: 130px;
+ min-height: var(--route-min);
padding: 0 var(--edge);
overflow: hidden;
}
@@ -562,17 +597,34 @@
padding-left: 0;
}
+ /*
+ * Column counts come from the measurement (ui/src/lib/viewport.ts) rather
+ * than from breakpoints restated here. Two places deciding what "narrow"
+ * means is how a layout ends up correct on a laptop and broken on a phone.
+ */
.primary {
display: grid;
- grid-template-columns: 1.15fr 1fr 1fr 1fr 0.8fr;
+ grid-template-columns: repeat(var(--cols-primary), minmax(0, 1fr));
gap: var(--gap);
padding: 1.1rem var(--edge) 0.9rem;
align-items: end;
}
+ /* The hero owns its own row wherever the grid is too narrow to give it a
+ column of its own — below three columns, sharing a row with a `big`
+ readout clips one or the other. */
+ .hero-cell {
+ grid-column: span 1;
+ min-width: 0;
+ }
+
+ :global([data-size='compact']) .hero-cell {
+ grid-column: 1 / -1;
+ }
+
.detail {
display: grid;
- grid-template-columns: repeat(5, 1fr);
+ grid-template-columns: repeat(var(--cols-detail), minmax(0, 1fr));
gap: var(--gap);
padding: 0 var(--edge) 1rem;
border-bottom: 1px solid var(--hairline);
@@ -581,7 +633,7 @@
.effort {
flex: 1 1 0;
display: grid;
- grid-template-columns: repeat(7, minmax(0, auto)) 1fr;
+ grid-template-columns: repeat(var(--cols-effort), minmax(0, 1fr));
grid-template-rows: auto minmax(0, 1fr);
align-items: start;
gap: var(--gap);
@@ -591,6 +643,13 @@
overflow: hidden;
}
+ /* With the sparklines gone there is no second row to reserve, and the effort
+ numbers should sit against the control bar rather than float above a gap. */
+ :global([data-size='compact']) .effort {
+ flex: 0 0 auto;
+ grid-template-rows: auto;
+ }
+
.spacer {
display: none;
}
@@ -617,15 +676,41 @@
overflow: hidden;
}
- @media (max-width: 1150px) {
- .primary {
- grid-template-columns: 1fr 1fr;
- }
- .detail {
- grid-template-columns: repeat(3, 1fr);
- }
- .effort {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
+ /*
+ * Header, on a phone. The chip row and the two buttons cannot sit beside a
+ * route title on 412 px, so the title takes the first line and the chips wrap
+ * under it, left-aligned — `margin-left: auto` would push a wrapped row into
+ * the right-hand gutter.
+ */
+ :global([data-size='compact']) header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.5rem;
+ padding: 0.6rem var(--edge) 0.4rem;
+ }
+
+ :global([data-size='compact']) .chips {
+ margin-left: 0;
+ justify-content: flex-start;
+ }
+
+ /* The route summary line is the first thing to go: it repeats what the route
+ chart shows, and on a phone it costs a whole line of the ride screen. */
+ :global([data-size='compact']) .who p {
+ display: none;
+ }
+
+ :global([data-size='compact']) .launch {
+ flex-direction: column;
+ align-items: stretch;
+ margin: 0.6rem var(--edge) 0.2rem;
+ padding: 0.9rem 1rem;
+ }
+
+ /* Full width, because on a phone this is the only thing on screen worth
+ tapping and a thumb should not have to find it. */
+ :global([data-size='compact']) .start {
+ justify-content: center;
+ width: 100%;
}
diff --git a/ui/src/lib/viewport.svelte.ts b/ui/src/lib/viewport.svelte.ts
new file mode 100644
index 0000000..bef1661
--- /dev/null
+++ b/ui/src/lib/viewport.svelte.ts
@@ -0,0 +1,94 @@
+/**
+ * The measuring half of the screen-size work; `viewport.ts` holds the rules.
+ *
+ * One store, mounted once from `main.ts`, that watches the viewport and
+ * publishes the resulting plan two ways: as reactive state for the components
+ * that need to branch in markup, and as CSS custom properties plus data
+ * attributes on `` for everything that can be expressed in a stylesheet.
+ */
+import { cssVariables, planLayout, type LayoutPlan, type ViewportMeasurement } from './viewport';
+
+/**
+ * Read the viewport.
+ *
+ * `visualViewport` rather than `innerWidth`/`innerHeight` where it exists,
+ * because on Android those two lie: they report the layout viewport, which
+ * ignores the on-screen keyboard and the collapsing URL bar. The rider never
+ * sees a keyboard mid-ride, but they do see the bar, and a layout planned
+ * against a height 60 px taller than the one being painted puts the control
+ * bar under the edge of the screen.
+ */
+export function measure(): ViewportMeasurement {
+ const vv = typeof window !== 'undefined' ? window.visualViewport : null;
+ return {
+ width: vv?.width ?? window.innerWidth,
+ height: vv?.height ?? window.innerHeight,
+ dpr: window.devicePixelRatio || 1,
+ // `pointer: coarse` is the honest question — "is the primary input a
+ // finger" — where a user-agent sniff is a guess. A touchscreen laptop
+ // reports `fine`, correctly: it has a keyboard and a trackpad.
+ touch: window.matchMedia?.('(pointer: coarse)').matches ?? false,
+ };
+}
+
+class ViewportStore {
+ /** The last measurement taken, published for diagnostics and tests. */
+ measurement = $state
({ width: 1440, height: 900, dpr: 1, touch: false });
+ /** The plan derived from it. Components read this; nothing writes it. */
+ plan = $state(planLayout({ width: 1440, height: 900, dpr: 1, touch: false }));
+
+ private detach: (() => void) | null = null;
+
+ /**
+ * Start watching. Returns a teardown so a test (or a future multi-window
+ * shell) can stop it; the app itself never does.
+ */
+ observe(): () => void {
+ this.detach?.();
+ this.apply();
+
+ // A frame's grace after a rotation: Android reports the new orientation
+ // before the webview has been resized to match, so measuring on the event
+ // itself yields the *old* dimensions with the new orientation — briefly a
+ // portrait layout on a landscape screen.
+ let frame = 0;
+ const schedule = () => {
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => this.apply());
+ };
+
+ window.addEventListener('resize', schedule);
+ window.addEventListener('orientationchange', schedule);
+ window.visualViewport?.addEventListener('resize', schedule);
+
+ this.detach = () => {
+ cancelAnimationFrame(frame);
+ window.removeEventListener('resize', schedule);
+ window.removeEventListener('orientationchange', schedule);
+ window.visualViewport?.removeEventListener('resize', schedule);
+ this.detach = null;
+ };
+ return this.detach;
+ }
+
+ /** Measure, plan, publish. */
+ apply(): void {
+ const m = measure();
+ const plan = planLayout(m);
+ this.measurement = m;
+ this.plan = plan;
+
+ const root = document.documentElement;
+ for (const [name, value] of Object.entries(cssVariables(plan))) {
+ root.style.setProperty(name, value);
+ }
+ // Data attributes rather than classes: a stylesheet can then say
+ // `[data-size='compact']` and read like the plan it came from.
+ root.dataset.size = plan.sizeClass;
+ root.dataset.height = plan.heightClass;
+ root.dataset.orientation = plan.orientation;
+ root.dataset.input = m.touch ? 'touch' : 'pointer';
+ }
+}
+
+export const viewport = new ViewportStore();
diff --git a/ui/src/lib/viewport.test.ts b/ui/src/lib/viewport.test.ts
new file mode 100644
index 0000000..c9ce914
--- /dev/null
+++ b/ui/src/lib/viewport.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest';
+import { cssVariables, heightClassFor, planLayout, sizeClassFor } from './viewport';
+import type { ViewportMeasurement } from './viewport';
+
+/**
+ * Real devices, in CSS pixels. These are the measurements the layout has to
+ * survive, so they are the test cases — the point of `planLayout` being pure is
+ * that "does BikeControl fit on a Pixel 7" is a question answerable in CI, on a
+ * machine with no phone attached (G-4).
+ */
+const DEVICES = {
+ pixel7Portrait: { width: 412, height: 915, dpr: 2.625, touch: true },
+ pixel7Landscape: { width: 915, height: 412, dpr: 2.625, touch: true },
+ smallPhone: { width: 360, height: 640, dpr: 3, touch: true },
+ tablet: { width: 1280, height: 800, dpr: 2, touch: true },
+ desktop: { width: 1440, height: 900, dpr: 1, touch: false },
+ desktop4k: { width: 2560, height: 1440, dpr: 2, touch: false },
+ shortWindow: { width: 1280, height: 420, dpr: 1, touch: false },
+} satisfies Record;
+
+/**
+ * The legibility contract from FR-9.5, restated as numbers. Nothing the planner
+ * does may put a readout under these, on any device.
+ */
+const FLOOR = { hero: 40, big: 28, mid: 18, small: 14, label: 10, sub: 11 };
+
+describe('sizeClassFor', () => {
+ it('puts every phone width in the compact band', () => {
+ expect(sizeClassFor(360)).toBe('compact');
+ expect(sizeClassFor(412)).toBe('compact');
+ expect(sizeClassFor(599)).toBe('compact');
+ });
+
+ it('bands the rest on the Material boundaries', () => {
+ expect(sizeClassFor(600)).toBe('medium');
+ expect(sizeClassFor(899)).toBe('medium');
+ expect(sizeClassFor(900)).toBe('expanded');
+ expect(sizeClassFor(1279)).toBe('expanded');
+ expect(sizeClassFor(1280)).toBe('large');
+ });
+});
+
+describe('heightClassFor', () => {
+ it('bands on the heights that change what fits', () => {
+ expect(heightClassFor(412)).toBe('short');
+ expect(heightClassFor(640)).toBe('medium');
+ expect(heightClassFor(915)).toBe('tall');
+ });
+});
+
+describe('planLayout', () => {
+ it('never puts a readout below its legibility floor', () => {
+ for (const [name, m] of Object.entries(DEVICES)) {
+ const { type } = planLayout(m);
+ for (const key of Object.keys(FLOOR) as (keyof typeof FLOOR)[]) {
+ expect(type[key], `${name}.${key}`).toBeGreaterThanOrEqual(FLOOR[key]);
+ }
+ }
+ });
+
+ it('keeps the primary readouts inside the viewport width', () => {
+ // A readout is roughly `hero` wide per digit-pair plus its gutter. The
+ // check that matters is the coarse one: columns × floor width must fit,
+ // or the row wraps and the hero number is clipped.
+ for (const [name, m] of Object.entries(DEVICES)) {
+ const plan = planLayout(m);
+ const needed =
+ plan.columns.primary * plan.type.hero + (plan.columns.primary - 1) * plan.gapPx + 2 * plan.edgePx;
+ expect(needed, `${name} primary row`).toBeLessThanOrEqual(m.width);
+ }
+ });
+
+ it('drops the sparklines on a phone rather than drawing a smear', () => {
+ expect(planLayout(DEVICES.pixel7Portrait).show.streamCharts).toBe(false);
+ expect(planLayout(DEVICES.pixel7Landscape).show.streamCharts).toBe(false);
+ expect(planLayout(DEVICES.desktop).show.streamCharts).toBe(true);
+ });
+
+ it('keeps the route chart on a phone in portrait — it is the screen', () => {
+ expect(planLayout(DEVICES.pixel7Portrait).show.routeChart).toBe(true);
+ });
+
+ it('gives up the route chart only when both axes are gone', () => {
+ // A phone in landscape is 412 px tall: the route chart plus the numbers do
+ // not both fit, and the numbers win.
+ expect(planLayout({ width: 568, height: 320, dpr: 3, touch: true }).show.routeChart).toBe(false);
+ // A short *wide* window still has room for it.
+ expect(planLayout(DEVICES.shortWindow).show.routeChart).toBe(true);
+ });
+
+ it('hides keyboard hints when there is no keyboard', () => {
+ expect(planLayout(DEVICES.pixel7Portrait).show.keyHints).toBe(false);
+ expect(planLayout(DEVICES.desktop).show.keyHints).toBe(true);
+ });
+
+ it('meets the 48 px touch target minimum on touch devices', () => {
+ expect(planLayout(DEVICES.pixel7Portrait).touchTargetPx).toBeGreaterThanOrEqual(48);
+ expect(planLayout(DEVICES.tablet).touchTargetPx).toBeGreaterThanOrEqual(48);
+ });
+
+ it('narrows the primary grid as the screen narrows, monotonically', () => {
+ const widths = [360, 412, 600, 900, 1280, 1920];
+ const columns = widths.map((width) => planLayout({ width, height: 900, dpr: 2, touch: false }).columns.primary);
+ for (let i = 1; i < columns.length; i++) {
+ expect(columns[i], `${widths[i]}px`).toBeGreaterThanOrEqual(columns[i - 1]);
+ }
+ });
+
+ it('reports orientation from the measurement, not from the platform', () => {
+ expect(planLayout(DEVICES.pixel7Portrait).orientation).toBe('portrait');
+ expect(planLayout(DEVICES.pixel7Landscape).orientation).toBe('landscape');
+ // Square is portrait by the >= rule; the point is that it is defined.
+ expect(planLayout({ width: 800, height: 800, dpr: 1, touch: false }).orientation).toBe('portrait');
+ });
+
+ it('survives a zero-sized viewport on the first frame', () => {
+ const plan = planLayout({ width: 0, height: 0, dpr: 1, touch: false });
+ expect(Number.isFinite(plan.type.hero)).toBe(true);
+ expect(plan.type.hero).toBeGreaterThanOrEqual(FLOOR.hero);
+ expect(plan.columns.primary).toBeGreaterThan(0);
+ });
+
+ it('does not grow type without bound on a very large display', () => {
+ const huge = planLayout({ width: 7680, height: 4320, dpr: 1, touch: false });
+ expect(huge.type.hero).toBeLessThanOrEqual(116);
+ });
+});
+
+describe('cssVariables', () => {
+ it('emits every value with a unit or as a bare count, never NaN', () => {
+ const vars = cssVariables(planLayout(DEVICES.pixel7Portrait));
+ for (const [name, value] of Object.entries(vars)) {
+ expect(value, name).not.toContain('NaN');
+ if (name.startsWith('--cols-')) {
+ expect(Number(value), name).toBeGreaterThan(0);
+ } else {
+ expect(value, name).toMatch(/^\d+px$/);
+ }
+ }
+ });
+});
diff --git a/ui/src/lib/viewport.ts b/ui/src/lib/viewport.ts
new file mode 100644
index 0000000..3bc1465
--- /dev/null
+++ b/ui/src/lib/viewport.ts
@@ -0,0 +1,239 @@
+/**
+ * Screen size as a measured input, not a guess (FR-9.5, G-4).
+ *
+ * The desktop layout was built for a 1440×900 window and expressed its type
+ * scale in `vw`. On a phone that falls apart twice over: `7vw` of a 412 px
+ * viewport is 29 px, well under what is readable from the bars, and five
+ * side-by-side readouts do not fit across 412 px at *any* type size. Shrinking
+ * is not the answer to a small screen — showing less is.
+ *
+ * So this module turns a measurement into an explicit plan: type sizes in
+ * pixels, column counts, and which sections earn their space. It is a pure
+ * function of the measurement, which is what makes the behaviour testable
+ * without a browser — see viewport.test.ts. `viewport.svelte.ts` does the
+ * measuring and publishes the result to CSS.
+ *
+ * ## Why the sizes are absolute, not relative
+ *
+ * A number has to subtend enough visual angle to be read from the riding
+ * position. Physical size on screen is `cssPx / pxPerInch`, and the angle is
+ * that over the viewing distance. Desktop is ~96 CSS px per inch at about a
+ * metre; Android's CSS pixel is the density-independent pixel, ~160 per inch,
+ * and a phone clipped to the bars sits at roughly 0.6 m. The two corrections
+ * very nearly cancel:
+ *
+ * (160/96) × (0.6/1.0) ≈ 1.0
+ *
+ * which means the *same CSS pixel size* is about as readable on a bar-mounted
+ * phone as on a desktop monitor. That is the justification for the floors
+ * below being plain pixel numbers with no per-platform correction — and for
+ * treating a small screen as a content problem rather than a scaling one.
+ */
+
+/** What we actually measure. */
+export interface ViewportMeasurement {
+ /** Viewport width in CSS pixels. */
+ width: number;
+ /** Viewport height in CSS pixels. */
+ height: number;
+ /** `devicePixelRatio` — recorded for diagnostics; the plan does not use it. */
+ dpr: number;
+ /** Whether the primary input is touch. Drives minimum hit-target size. */
+ touch: boolean;
+}
+
+/**
+ * Width bands, on the Material window-size-class boundaries. They are stated in
+ * CSS pixels, which on Android *is* the dp the boundaries were defined in.
+ */
+export type SizeClass = 'compact' | 'medium' | 'expanded' | 'large';
+
+/**
+ * Height bands. The ride screen is a vertical stack of six sections, so height
+ * decides what survives far more often than width does.
+ */
+export type HeightClass = 'short' | 'medium' | 'tall';
+
+export type Orientation = 'portrait' | 'landscape';
+
+export interface LayoutPlan {
+ sizeClass: SizeClass;
+ heightClass: HeightClass;
+ orientation: Orientation;
+
+ /** Type sizes in CSS pixels. */
+ type: {
+ hero: number;
+ big: number;
+ mid: number;
+ small: number;
+ label: number;
+ sub: number;
+ };
+
+ /** Column counts for the ride screen's three readout grids. */
+ columns: {
+ primary: number;
+ detail: number;
+ effort: number;
+ };
+
+ /**
+ * Sections that are dropped rather than squeezed. Each is a deliberate
+ * judgement about what a rider still needs when there is no room.
+ */
+ show: {
+ /** The route profile — the hero of the screen, and the last thing to go. */
+ routeChart: boolean;
+ /** Elevation / climbing / covered / ascended / elapsed. */
+ detailRow: boolean;
+ /** Average, normalised, work, burned — post-ride numbers, mid-ride noise. */
+ secondaryEffort: boolean;
+ /** The rolling power and gradient sparklines. */
+ streamCharts: boolean;
+ /** The keyboard hints on buttons: meaningless without a keyboard. */
+ keyHints: boolean;
+ };
+
+ /** Minimum interactive target, in CSS pixels. */
+ touchTargetPx: number;
+ /** Horizontal page padding. */
+ edgePx: number;
+ /** Grid gutter. */
+ gapPx: number;
+ /** Floor for the route chart, below which it is not worth drawing. */
+ routeChartMinPx: number;
+}
+
+/** The window the desktop layout was designed against. */
+const REFERENCE = { width: 1440, height: 900 };
+
+/**
+ * Legibility floors, in CSS pixels: the point below which a readout stops
+ * doing its job from a riding position. Nothing here scales past them — when
+ * the space is not there, `show` gives something up instead.
+ */
+const FLOOR = { hero: 40, big: 28, mid: 18, small: 14, label: 10, sub: 11 };
+const BASE = { hero: 58, big: 42, mid: 26, small: 17, label: 11, sub: 13 };
+const CEILING = { hero: 116, big: 78, mid: 45, small: 27, label: 13, sub: 15 };
+
+export function sizeClassFor(width: number): SizeClass {
+ if (width < 600) return 'compact';
+ if (width < 900) return 'medium';
+ if (width < 1280) return 'expanded';
+ return 'large';
+}
+
+export function heightClassFor(height: number): HeightClass {
+ if (height < 480) return 'short';
+ if (height < 760) return 'medium';
+ return 'tall';
+}
+
+const clamp = (lo: number, v: number, hi: number) => Math.min(hi, Math.max(lo, v));
+
+/**
+ * Turn a measurement into a layout.
+ *
+ * Deliberately total: every input, including a zero-sized viewport during the
+ * first frame, produces a usable plan rather than a NaN that would propagate
+ * into a CSS custom property and blank the screen.
+ */
+export function planLayout(m: ViewportMeasurement): LayoutPlan {
+ const width = Math.max(1, Math.round(m.width));
+ const height = Math.max(1, Math.round(m.height));
+
+ const sizeClass = sizeClassFor(width);
+ const heightClass = heightClassFor(height);
+ const orientation: Orientation = height >= width ? 'portrait' : 'landscape';
+
+ // Scale on whichever axis is tighter: a tall narrow phone is constrained by
+ // width, a laptop in a short window by height, and taking the minimum means
+ // neither can push type past the space that exists for it.
+ const fit = Math.min(width / REFERENCE.width, height / REFERENCE.height);
+ const scale = clamp(0.5, fit, 2);
+
+ const sized = (key: keyof typeof BASE) =>
+ Math.round(clamp(FLOOR[key], BASE[key] * scale, CEILING[key]));
+
+ const compact = sizeClass === 'compact';
+ const portrait = orientation === 'portrait';
+ const short = heightClass === 'short';
+
+ // Columns. On compact the primary readouts go two-up: three would put the
+ // hero ETA under 40 px to fit, and the floors are not negotiable.
+ const primary = compact ? 2 : sizeClass === 'medium' ? 3 : sizeClass === 'expanded' ? 4 : 5;
+ const detail = compact ? 3 : sizeClass === 'medium' ? 4 : 5;
+ const effort = compact ? 3 : sizeClass === 'medium' ? 4 : 7;
+
+ /*
+ * What gets dropped, and in what order. The rule is that anything the rider
+ * cannot act on mid-ride goes before anything they can.
+ *
+ * - The sparklines are history; the live numbers above them are not. They
+ * go first, and on any short viewport, because a 60 px chart is a smear.
+ * - The secondary effort block (average / normalised / work / burned) is
+ * what the summary screen is for. It goes second.
+ * - The detail row survives longer: "climbing left" is the question a rider
+ * on a hill is actually asking.
+ * - The route chart is the screen's whole point and only goes when there is
+ * genuinely no room — a phone held in landscape, where it would leave
+ * nothing for the numbers.
+ * - Key hints are noise on a touch device: there is no keyboard to press.
+ */
+ const show = {
+ routeChart: !(short && compact),
+ detailRow: !short && !(compact && portrait && height < 700),
+ secondaryEffort: !compact && !short,
+ streamCharts: !compact && !short && height >= 700,
+ keyHints: !m.touch,
+ };
+
+ return {
+ sizeClass,
+ heightClass,
+ orientation,
+ type: {
+ hero: sized('hero'),
+ big: sized('big'),
+ mid: sized('mid'),
+ small: sized('small'),
+ label: sized('label'),
+ sub: sized('sub'),
+ },
+ columns: { primary, detail, effort },
+ show,
+ // Android's own accessibility guidance is a 48 dp minimum, and a CSS pixel
+ // is a dp there. A rider wearing gloves, out of the saddle, is exactly the
+ // case that number exists for.
+ touchTargetPx: m.touch ? 48 : 32,
+ edgePx: compact ? 14 : Math.round(clamp(16, 44 * scale, 44)),
+ gapPx: compact ? 10 : Math.round(clamp(12, 24 * scale, 24)),
+ routeChartMinPx: short ? 90 : compact ? 110 : 130,
+ };
+}
+
+/**
+ * The plan as CSS custom properties, for the stylesheets to consume.
+ *
+ * Publishing the numbers rather than re-deriving them in `@media` rules is the
+ * point: a media query cannot see the touch flag, and two rules that disagree
+ * about where "compact" starts is a bug waiting to be found on a phone.
+ */
+export function cssVariables(plan: LayoutPlan): Record {
+ return {
+ '--type-hero': `${plan.type.hero}px`,
+ '--type-big': `${plan.type.big}px`,
+ '--type-mid': `${plan.type.mid}px`,
+ '--type-small': `${plan.type.small}px`,
+ '--type-label': `${plan.type.label}px`,
+ '--type-sub': `${plan.type.sub}px`,
+ '--cols-primary': String(plan.columns.primary),
+ '--cols-detail': String(plan.columns.detail),
+ '--cols-effort': String(plan.columns.effort),
+ '--touch-min': `${plan.touchTargetPx}px`,
+ '--edge': `${plan.edgePx}px`,
+ '--gap': `${plan.gapPx}px`,
+ '--route-min': `${plan.routeChartMinPx}px`,
+ };
+}
diff --git a/ui/src/main.ts b/ui/src/main.ts
index 646be01..7fdddf8 100644
--- a/ui/src/main.ts
+++ b/ui/src/main.ts
@@ -1,8 +1,14 @@
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
+import { viewport } from './lib/viewport.svelte';
const target = document.getElementById('app');
if (!target) throw new Error('#app mount point missing from index.html');
+// Before the first paint: the stylesheet's type sizes and column counts are
+// custom properties this fills in, so mounting first would flash a desktop
+// layout on a phone.
+viewport.observe();
+
export default mount(App, { target });