diff --git a/apps/web-client/.env.example b/apps/web-client/.env.example
new file mode 100644
index 0000000..f22cddd
--- /dev/null
+++ b/apps/web-client/.env.example
@@ -0,0 +1 @@
+PUBLIC_API_URL=http://localhost:8080
diff --git a/apps/web-client/Dockerfile b/apps/web-client/Dockerfile
new file mode 100644
index 0000000..e453752
--- /dev/null
+++ b/apps/web-client/Dockerfile
@@ -0,0 +1,25 @@
+FROM oven/bun:1 AS builder
+WORKDIR /app
+ENV NODE_ENV=development
+
+COPY package.json ./
+COPY packages packages
+COPY apps/web-client apps/web-client
+
+RUN bun install
+
+WORKDIR /app/apps/web-client
+RUN bun run build
+
+FROM oven/bun:1-slim
+WORKDIR /app
+ENV NODE_ENV=production
+
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/packages ./packages
+COPY --from=builder /app/apps/web-client ./apps/web-client
+
+WORKDIR /app/apps/web-client
+EXPOSE 3000
+CMD ["bun", "./build/index.js"]
diff --git a/apps/web-client/package.json b/apps/web-client/package.json
new file mode 100644
index 0000000..97a8587
--- /dev/null
+++ b/apps/web-client/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@pistation/web-client",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 5173",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+ },
+ "dependencies": {
+ "@excalidraw/excalidraw": "^0.17.6",
+ "@pistation/client-core": "workspace:*",
+ "@pistation/shared-types": "workspace:*",
+ "@pistation/ui": "workspace:*",
+ "livekit-client": "^2.7.2",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@iconify/svelte": "^4.0.2",
+ "@sveltejs/adapter-node": "^5.2.9",
+ "@sveltejs/kit": "^2.9.0",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "~5.6.2",
+ "vite": "^6.0.3"
+ }
+}
diff --git a/apps/web-client/src/app.css b/apps/web-client/src/app.css
new file mode 100644
index 0000000..0229ce4
--- /dev/null
+++ b/apps/web-client/src/app.css
@@ -0,0 +1,64 @@
+@import "tailwindcss";
+
+@source "../../../packages/ui/src";
+
+@theme {
+ --color-surface-0: #0b0d10;
+ --color-surface-1: #14181d;
+ --color-surface-2: #1c2229;
+ --color-surface-3: #262e37;
+ --color-ink-0: #f4f6f8;
+ --color-ink-1: #a8b3c0;
+ --color-ink-2: #6b7885;
+ --color-accent: #4f7cff;
+ --color-accent-strong: #3563e9;
+ --color-danger: #ff3b52;
+ --color-success: #21c17a;
+ --radius-none: 0px;
+ --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace;
+}
+
+* {
+ border-radius: 0 !important;
+}
+
+html,
+body {
+ height: 100%;
+ background-color: var(--color-surface-0);
+ color: var(--color-ink-0);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+}
+
+button {
+ cursor: pointer;
+}
+
+input,
+textarea,
+select,
+button {
+ outline: none;
+ font-family: inherit;
+}
+
+input:focus-visible,
+textarea:focus-visible,
+button:focus-visible {
+ box-shadow: inset 0 0 0 2px var(--color-accent);
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-surface-1);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-surface-3);
+}
diff --git a/apps/web-client/src/app.html b/apps/web-client/src/app.html
new file mode 100644
index 0000000..23c36cf
--- /dev/null
+++ b/apps/web-client/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/web-client/src/hooks.server.ts b/apps/web-client/src/hooks.server.ts
new file mode 100644
index 0000000..4aa391e
--- /dev/null
+++ b/apps/web-client/src/hooks.server.ts
@@ -0,0 +1,10 @@
+import type { HandleServerError } from "@sveltejs/kit";
+
+export const handleError: HandleServerError = ({ error, event }) => {
+ const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
+ console.error(`[error] ${event.request.method} ${event.url.pathname}\n${detail}`);
+
+ return {
+ message: "Something went wrong rendering this page."
+ };
+};
diff --git a/apps/web-client/src/lib/api.ts b/apps/web-client/src/lib/api.ts
new file mode 100644
index 0000000..f726e50
--- /dev/null
+++ b/apps/web-client/src/lib/api.ts
@@ -0,0 +1,234 @@
+import type {
+ AdminLoginResponse,
+ CreateKioskResponse,
+ JoinResponse,
+ Kiosk,
+ KioskDetailResponse,
+ KioskListResponse,
+ OrganizationBranding,
+ SessionRefreshResponse
+} from "@pistation/shared-types";
+
+import { apiBaseUrl } from "./config";
+
+export { apiBaseUrl };
+
+export class ApiError extends Error {
+ readonly status: number;
+ readonly code: string;
+
+ constructor(status: number, code: string, message: string) {
+ super(message);
+ this.status = status;
+ this.code = code;
+ }
+}
+
+interface RequestOptions {
+ method?: string;
+ body?: unknown;
+ token?: string | null;
+}
+
+async function request(path: string, options: RequestOptions = {}): Promise {
+ const headers: Record = {};
+ if (options.body !== undefined) headers["content-type"] = "application/json";
+ if (options.token) headers["authorization"] = `Bearer ${options.token}`;
+
+ let response: Response;
+ try {
+ response = await fetch(`${apiBaseUrl}${path}`, {
+ method: options.method ?? "GET",
+ headers,
+ body: options.body === undefined ? undefined : JSON.stringify(options.body)
+ });
+ } catch {
+ throw new ApiError(0, "network", "Could not reach the PiStation server.");
+ }
+
+ if (response.status === 204) return undefined as T;
+
+ const payload = await response.json().catch(() => null);
+
+ if (!response.ok) {
+ const code = (payload as { error?: string })?.error ?? "unknown";
+ const message = (payload as { message?: string })?.message ?? "Something went wrong.";
+ throw new ApiError(response.status, code, message);
+ }
+
+ return payload as T;
+}
+
+export function joinRoom(pin: string, displayName: string): Promise {
+ return request("/api/join", {
+ method: "POST",
+ body: { pin, displayName }
+ });
+}
+
+export function refreshSession(sessionId: string): Promise {
+ return request("/api/session/refresh", {
+ method: "POST",
+ body: { sessionId }
+ });
+}
+
+export function adminLogin(email: string, password: string): Promise {
+ return request("/api/admin/login", {
+ method: "POST",
+ body: { email, password }
+ });
+}
+
+export function listKiosks(token: string): Promise {
+ return request("/api/admin/kiosks", { token });
+}
+
+export function createKiosk(
+ token: string,
+ name: string,
+ location: string
+): Promise {
+ return request("/api/admin/kiosks", {
+ method: "POST",
+ body: { name, location },
+ token
+ });
+}
+
+export function getKiosk(token: string, kioskId: string): Promise {
+ return request(`/api/admin/kiosks/${kioskId}`, { token });
+}
+
+export function getBranding(): Promise {
+ return request("/api/organization");
+}
+
+export function saveBranding(
+ token: string,
+ branding: OrganizationBranding
+): Promise {
+ return request("/api/admin/organization", {
+ method: "PUT",
+ body: { branding },
+ token
+ });
+}
+
+export async function uploadLogo(token: string, file: File): Promise<{ imageUrl: string }> {
+ let response: Response;
+
+ try {
+ response = await fetch(`${apiBaseUrl}/api/admin/organization/logo`, {
+ method: "PUT",
+ headers: { "content-type": file.type, authorization: `Bearer ${token}` },
+ body: file
+ });
+ } catch {
+ throw new ApiError(0, "network", "Could not reach the PiStation server.");
+ }
+
+ const payload = await response.json().catch(() => null);
+
+ if (!response.ok) {
+ const message = (payload as { message?: string })?.message ?? "Upload failed.";
+ throw new ApiError(response.status, "upload", message);
+ }
+
+ return payload as { imageUrl: string };
+}
+
+export function updateKiosk(
+ token: string,
+ kioskId: string,
+ name: string,
+ location: string
+): Promise {
+ return request(`/api/admin/kiosks/${kioskId}`, {
+ method: "PATCH",
+ body: { name, location },
+ token
+ });
+}
+
+export function deleteKiosk(token: string, kioskId: string): Promise {
+ return request(`/api/admin/kiosks/${kioskId}`, { method: "DELETE", token });
+}
+
+export function saveKioskLayout(
+ token: string,
+ kioskId: string,
+ layout: unknown
+): Promise {
+ return request(`/api/admin/kiosks/${kioskId}/layout`, {
+ method: "PUT",
+ body: { layout },
+ token
+ });
+}
+
+export async function uploadWallpaper(
+ token: string,
+ kioskId: string,
+ file: File
+): Promise<{ imageUrl: string }> {
+ let response: Response;
+
+ try {
+ response = await fetch(`${apiBaseUrl}/api/admin/kiosks/${kioskId}/wallpaper`, {
+ method: "PUT",
+ headers: {
+ "content-type": file.type,
+ authorization: `Bearer ${token}`
+ },
+ body: file
+ });
+ } catch {
+ throw new ApiError(0, "network", "Could not reach the PiStation server.");
+ }
+
+ const payload = await response.json().catch(() => null);
+
+ if (!response.ok) {
+ const message = (payload as { message?: string })?.message ?? "Upload failed.";
+ throw new ApiError(response.status, "upload", message);
+ }
+
+ return payload as { imageUrl: string };
+}
+
+export async function uploadKioskPackage(
+ token: string,
+ file: File
+): Promise<{ downloadUrl: string; sizeBytes: number }> {
+ let response: Response;
+
+ try {
+ response = await fetch(`${apiBaseUrl}/api/admin/packages/kiosk`, {
+ method: "PUT",
+ headers: {
+ "content-type": "application/vnd.debian.binary-package",
+ authorization: `Bearer ${token}`
+ },
+ body: file
+ });
+ } catch {
+ throw new ApiError(0, "network", "Could not reach the PiStation server.");
+ }
+
+ const payload = await response.json().catch(() => null);
+
+ if (!response.ok) {
+ const message = (payload as { message?: string })?.message ?? "Upload failed.";
+ throw new ApiError(response.status, "upload", message);
+ }
+
+ return payload as { downloadUrl: string; sizeBytes: number };
+}
+
+export function rotateEnrollment(
+ token: string,
+ kioskId: string
+): Promise<{ enrollmentToken: string }> {
+ return request(`/api/admin/kiosks/${kioskId}/enrollment`, { method: "POST", token });
+}
diff --git a/apps/web-client/src/lib/branding.svelte.ts b/apps/web-client/src/lib/branding.svelte.ts
new file mode 100644
index 0000000..ca0b179
--- /dev/null
+++ b/apps/web-client/src/lib/branding.svelte.ts
@@ -0,0 +1,28 @@
+import type { OrganizationBranding } from "@pistation/shared-types";
+import { withBrandingDefaults } from "@pistation/shared-types";
+
+import { getBranding } from "./api";
+
+/// Shared so the layout can paint the theme and pages can read the copy, without every
+/// page fetching branding for itself.
+export const branding = $state<{ value: OrganizationBranding }>({
+ value: withBrandingDefaults(null)
+});
+
+let hasRequested = false;
+
+export async function ensureBranding(): Promise {
+ if (hasRequested) return;
+ hasRequested = true;
+
+ try {
+ branding.value = withBrandingDefaults(await getBranding());
+ } catch {
+ // Defaults are already in place, so a server that is not up yet just means the
+ // stock palette and copy.
+ }
+}
+
+export function applyBranding(loaded: OrganizationBranding): void {
+ branding.value = withBrandingDefaults(loaded);
+}
diff --git a/apps/web-client/src/lib/components/AnnotationToolbar.svelte b/apps/web-client/src/lib/components/AnnotationToolbar.svelte
new file mode 100644
index 0000000..6828be4
--- /dev/null
+++ b/apps/web-client/src/lib/components/AnnotationToolbar.svelte
@@ -0,0 +1,144 @@
+
+
+
+ {#each tools as tool}
+
selectTool(tool.id)}
+ title={tool.label}
+ aria-label={tool.label}
+ class="flex h-10 w-10 items-center justify-center transition-colors"
+ class:bg-accent={isToolActive(tool.id)}
+ class:text-white={isToolActive(tool.id)}
+ class:text-ink-1={!isToolActive(tool.id)}
+ class:hover:bg-surface-3={!isToolActive(tool.id)}
+ >
+
+
+ {/each}
+
+
{
+ controller.isPointerMode = !controller.isPointerMode;
+ if (controller.isPointerMode) controller.isEraser = false;
+ }}
+ title="Pointer, others see where you are pointing"
+ aria-label="Pointer"
+ class="flex h-10 w-10 items-center justify-center transition-colors"
+ class:bg-accent={controller.isPointerMode}
+ class:text-white={controller.isPointerMode}
+ class:text-ink-1={!controller.isPointerMode}
+ class:hover:bg-surface-3={!controller.isPointerMode}
+ >
+
+
+
+
{
+ controller.isEraser = !controller.isEraser;
+ if (controller.isEraser) controller.isPointerMode = false;
+ }}
+ title="Eraser"
+ aria-label="Eraser"
+ class="flex h-10 w-10 items-center justify-center transition-colors"
+ class:bg-accent={controller.isEraser}
+ class:text-white={controller.isEraser}
+ class:text-ink-1={!controller.isEraser}
+ class:hover:bg-surface-3={!controller.isEraser}
+ >
+
+
+
+
+
+
+ Thickness
+
+ (controller.strokeStyle = {
+ ...controller.strokeStyle,
+ width: Number((event.target as HTMLInputElement).value) / 1000
+ })}
+ class="w-28"
+ aria-label="Line thickness"
+ />
+ {thicknessValue}
+
+
+
+
+
+ {#each colors as color}
+
(controller.strokeStyle = { ...controller.strokeStyle, color })}
+ title={`Colour ${color}`}
+ aria-label={`Colour ${color}`}
+ class="h-8 w-8 transition-transform"
+ class:scale-90={controller.strokeStyle.color !== color}
+ style={`background-color: ${color}`}
+ >
+ {/each}
+
+
+
+
controller.clearAnnotations()}
+ title="Clear annotations"
+ aria-label="Clear annotations"
+ class="flex h-10 items-center gap-2 px-3 text-sm text-ink-1 transition-colors hover:bg-surface-3"
+ >
+
+ Clear
+
+
diff --git a/apps/web-client/src/lib/components/ConnectionBadge.svelte b/apps/web-client/src/lib/components/ConnectionBadge.svelte
new file mode 100644
index 0000000..4bd102c
--- /dev/null
+++ b/apps/web-client/src/lib/components/ConnectionBadge.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+ {current.label}
+
diff --git a/apps/web-client/src/lib/components/JoinCard.svelte b/apps/web-client/src/lib/components/JoinCard.svelte
new file mode 100644
index 0000000..ff0137c
--- /dev/null
+++ b/apps/web-client/src/lib/components/JoinCard.svelte
@@ -0,0 +1,81 @@
+
+
+
+
+
{label}
+
It rotates every minute
+
+
+
+
+
+ Your name
+ required
+
+
+
+
+ {#if errorMessage}
+
+
+ {errorMessage}
+
+ {/if}
+
+
+ {#if isJoining}
+
+ Connecting
+ {:else}
+
+ Join screen
+ {/if}
+
+
diff --git a/apps/web-client/src/lib/components/ParticipantMenu.svelte b/apps/web-client/src/lib/components/ParticipantMenu.svelte
new file mode 100644
index 0000000..cd46255
--- /dev/null
+++ b/apps/web-client/src/lib/components/ParticipantMenu.svelte
@@ -0,0 +1,62 @@
+
+
+
+
(isOpen = !isOpen)}
+ aria-expanded={isOpen}
+ class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
+ >
+
+ {participants.length}
+
+
+
+ {#if isOpen}
+
+
In this room
+
+
+ {#each participants as participant (participant.participantId)}
+ {@const isBroadcasting = participant.isSharing || participant.isCameraOn}
+
+
+
+ {participant.displayName}
+
+ {#if participant.isSelf}
+ you
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
diff --git a/apps/web-client/src/lib/components/PinInput.svelte b/apps/web-client/src/lib/components/PinInput.svelte
new file mode 100644
index 0000000..3d69aff
--- /dev/null
+++ b/apps/web-client/src/lib/components/PinInput.svelte
@@ -0,0 +1,73 @@
+
+
+
+ {#each slots as digit, index}
+ handleInput(index, event)}
+ onkeydown={(event) => handleKeydown(index, event)}
+ onfocus={(event) => (event.target as HTMLInputElement).select()}
+ />
+ {/each}
+
diff --git a/apps/web-client/src/lib/components/admin/AppearancePanel.svelte b/apps/web-client/src/lib/components/admin/AppearancePanel.svelte
new file mode 100644
index 0000000..dee31c5
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/AppearancePanel.svelte
@@ -0,0 +1,197 @@
+
+
+
+
Appearance
+
+
+ Background colour
+
+ onChange({ ...layout, backgroundColor: (event.target as HTMLInputElement).value })}
+ class="h-9 w-16 bg-surface-2"
+ />
+
+
+
+ Text colour
+
+ onChange({ ...layout, foregroundColor: (event.target as HTMLInputElement).value })}
+ class="h-9 w-16 bg-surface-2"
+ />
+
+
+
+
+ Widget panel opacity {Math.round((layout.widgetOpacity ?? 0.5) * 100)} percent
+
+
+ onChange({
+ ...layout,
+ widgetOpacity: Number((event.target as HTMLInputElement).value) / 100
+ })}
+ class="w-full"
+ />
+
+
+
+
+
Wallpapers
+ {#if images.length > 0}
+
{images.length}
+ {/if}
+
+
+ {#if images.length === 0}
+
No wallpaper set
+ {:else}
+
+ {#each images as image, index (image)}
+
+
+
removeImage(image)}
+ aria-label={`Remove wallpaper ${index + 1}`}
+ class="absolute top-0 right-0 flex h-6 w-6 items-center justify-center bg-surface-0/80 text-danger opacity-0 transition-opacity group-hover:opacity-100"
+ >
+
+
+
+ {/each}
+
+ {/if}
+
+ {#if uploadError}
+
{uploadError}
+ {/if}
+
+
+
+
fileInput?.click()}
+ disabled={isUploading}
+ class="flex items-center justify-center gap-2 bg-surface-1 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3 disabled:text-ink-2"
+ >
+ {#if isUploading}
+
+ Uploading
+ {:else}
+
+ Add images
+ {/if}
+
+
+
PNG, JPEG, WEBP or GIF, up to 8 MB each.
+
+ {#if images.length > 1}
+
+
+ Change every {layout.background.rotationSeconds ?? 60} seconds
+
+
+ patchBackground({
+ rotationSeconds: Number((event.target as HTMLInputElement).value)
+ })}
+ class="w-full"
+ />
+
+ {/if}
+
+ {#if images.length > 0}
+
+ Fit
+
+ patchBackground({
+ fit: (event.target as HTMLSelectElement).value as "cover" | "contain"
+ })}
+ class="w-full bg-surface-1 px-3 py-2 text-sm"
+ >
+ Cover
+ Contain
+
+
+
+
+
+ Dim {Math.round(layout.background.dim * 100)} percent
+
+
+ patchBackground({ dim: Number((event.target as HTMLInputElement).value) / 100 })}
+ class="w-full"
+ />
+
+ {/if}
+
+
diff --git a/apps/web-client/src/lib/components/admin/InstallCommand.svelte b/apps/web-client/src/lib/components/admin/InstallCommand.svelte
new file mode 100644
index 0000000..ae6c896
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/InstallCommand.svelte
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ Set up {kioskName || "this kiosk"}
+
+
+
+
+ Run this once on the Raspberry Pi over SSH, with sudo as shown. It installs the kiosk,
+ enlarges swap, tunes video for the board it finds, and reboots straight into the display.
+ The token works only until the Pi enrolls. Needs the 64 bit Raspberry Pi OS.
+
+
+
+ {command}
+
+
+
+
+
+
+ The Pi must be able to reach {apiBaseUrl}. Upload a kiosk build on the admin home page first,
+ otherwise the script has nothing to install.
+
+
diff --git a/apps/web-client/src/lib/components/admin/KioskStats.svelte b/apps/web-client/src/lib/components/admin/KioskStats.svelte
new file mode 100644
index 0000000..0633046
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/KioskStats.svelte
@@ -0,0 +1,200 @@
+
+
+{#if !metrics}
+ No stats reported yet.
+{:else if compact}
+
+ {#if metrics.cpuPercent !== null}
+
+
+ {metrics.cpuPercent.toFixed(0)}%
+
+ {/if}
+
+ {#if memoryPercent !== null}
+
+
+ {memoryPercent.toFixed(0)}%
+
+ {/if}
+
+ {#if metrics.wifi}
+ {@const strength = signalLabel(metrics.wifi.signalDbm)}
+
+
+ {STRENGTH_WORDS[strength]}
+
+ {/if}
+
+ {#if metrics.temperatureCelsius !== null}
+
+
+ {metrics.temperatureCelsius.toFixed(0)}°C
+
+ {/if}
+
+{:else}
+
+ {#if isStale}
+
+
+ These figures are more than two minutes old.
+
+ {/if}
+
+ {#if metrics.cpuPercent !== null}
+
+
+
+
+ CPU
+
+ {metrics.cpuPercent.toFixed(0)}%
+
+
+
+ {/if}
+
+ {#if memoryPercent !== null}
+
+
+
+
+ Memory
+
+
+ {formatBytes(metrics.memoryUsedBytes)} of {formatBytes(metrics.memoryTotalBytes)}
+
+
+
+
+ {/if}
+
+ {#if metrics.wifi}
+ {@const strength = signalLabel(metrics.wifi.signalDbm)}
+ {@const percent = signalPercent(metrics.wifi.signalDbm)}
+
+
+
+
+ Wi-Fi
+
+
+ {STRENGTH_WORDS[strength]}
+ {percent}%
+
+
+
+
+ {signalAdvice(metrics.wifi.signalDbm)} · {metrics.wifi.signalDbm.toFixed(0)} dBm on
+ {metrics.wifi.interface}
+
+
+ {:else}
+
+
+
+ Network
+
+ Wired or no wireless adapter
+
+ {/if}
+
+ {#if metrics.temperatureCelsius !== null}
+
+
+
+ Temperature
+
+ = 75 ? "text-danger" : "text-ink-2"}>
+ {metrics.temperatureCelsius.toFixed(1)}°C
+
+
+ {/if}
+
+
+
+
+ Uptime
+
+ {formatUptime(metrics.uptimeSeconds)}
+
+
+{/if}
diff --git a/apps/web-client/src/lib/components/admin/NightModePanel.svelte b/apps/web-client/src/lib/components/admin/NightModePanel.svelte
new file mode 100644
index 0000000..cd82d69
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/NightModePanel.svelte
@@ -0,0 +1,98 @@
+
+
+
diff --git a/apps/web-client/src/lib/components/admin/TimeZoneField.svelte b/apps/web-client/src/lib/components/admin/TimeZoneField.svelte
new file mode 100644
index 0000000..8d68de2
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/TimeZoneField.svelte
@@ -0,0 +1,77 @@
+
+
+
+ {label}
+
+ onChange((event.target as HTMLSelectElement).value)}
+ class="w-full bg-surface-2 px-3 py-2 text-sm text-ink-0"
+ >
+ {emptyLabel} ({localTimeZone()})
+
+ {#if !isKnown}
+ {value}
+ {/if}
+
+ {#each groups as [region, regionZones]}
+
+ {#each regionZones as zone}
+ {labelFor(zone)}
+ {/each}
+
+ {/each}
+
+
+ {#if preview}
+ Currently {preview} there
+ {/if}
+
diff --git a/apps/web-client/src/lib/components/admin/WidgetBuilder.svelte b/apps/web-client/src/lib/components/admin/WidgetBuilder.svelte
new file mode 100644
index 0000000..163c5a6
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/WidgetBuilder.svelte
@@ -0,0 +1,322 @@
+
+
+
+
+
Widget builder
+
+
+ New
+
+
+
+
+ {#each definitions as definition (definition.definitionId)}
+ (selectedId = definition.definitionId)}
+ class="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
+ class:bg-accent={selectedId === definition.definitionId}
+ class:text-white={selectedId === definition.definitionId}
+ class:bg-surface-2={selectedId !== definition.definitionId}
+ class:text-ink-1={selectedId !== definition.definitionId}
+ >
+ {definition.name}
+
+ {/each}
+
+
+ {#if selected}
+
+
+
+ Name
+
+ updateDefinition({ name: (event.target as HTMLInputElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+
+ Accent
+
+ updateDefinition({ accentColor: (event.target as HTMLInputElement).value })}
+ class="h-10 w-16 bg-surface-2"
+ />
+
+ removeDefinition(selected.definitionId)}
+ class="self-end bg-surface-2 px-4 py-2 text-sm text-danger hover:bg-surface-3"
+ >
+ Delete
+
+
+
+
+
+
+ {#each WIDGET_BLOCK_KINDS as kind}
+ addBlock(kind)}
+ class="bg-surface-2 px-3 py-2 text-sm text-ink-1 capitalize hover:bg-surface-3"
+ >
+ + {kind}
+
+ {/each}
+
+
+
+ {#each selected.blocks as block, index (block.blockId)}
+
+
+ {block.kind}
+ moveBlock(index, -1)}
+ aria-label="Move up"
+ class="flex h-7 w-7 items-center justify-center bg-surface-1 text-ink-1 hover:bg-surface-3"
+ >
+
+
+ moveBlock(index, 1)}
+ aria-label="Move down"
+ class="flex h-7 w-7 items-center justify-center bg-surface-1 text-ink-1 hover:bg-surface-3"
+ >
+
+
+ removeBlock(block.blockId)}
+ aria-label="Remove block"
+ class="flex h-7 w-7 items-center justify-center bg-surface-1 text-danger hover:bg-surface-3"
+ >
+
+
+
+
+ {#if block.kind === "heading" || block.kind === "text"}
+
+ updateBlock(block.blockId, {
+ template: (event.target as HTMLInputElement).value
+ })}
+ class="w-full bg-surface-1 px-3 py-2 text-sm"
+ />
+ {:else if block.kind === "metric"}
+
+
+ updateBlock(block.blockId, {
+ labelTemplate: (event.target as HTMLInputElement).value
+ })}
+ class="bg-surface-1 px-3 py-2 text-sm"
+ />
+
+ updateBlock(block.blockId, {
+ valueTemplate: (event.target as HTMLInputElement).value
+ })}
+ class="bg-surface-1 px-3 py-2 text-sm"
+ />
+
+ updateBlock(block.blockId, { unit: (event.target as HTMLInputElement).value })}
+ class="bg-surface-1 px-3 py-2 text-sm"
+ />
+
+ {:else if block.kind === "list"}
+
+
+ updateBlock(block.blockId, {
+ sourcePath: (event.target as HTMLInputElement).value
+ })}
+ class="bg-surface-1 px-3 py-2 text-sm"
+ />
+
+ updateBlock(block.blockId, {
+ itemTemplate: (event.target as HTMLInputElement).value
+ })}
+ class="bg-surface-1 px-3 py-2 text-sm"
+ />
+
+ updateBlock(block.blockId, {
+ maxItems: Number((event.target as HTMLInputElement).value)
+ })}
+ class="bg-surface-1 px-3 py-2 text-sm"
+ />
+
+ {:else if block.kind === "image"}
+
+ updateBlock(block.blockId, {
+ urlTemplate: (event.target as HTMLInputElement).value
+ })}
+ class="w-full bg-surface-1 px-3 py-2 text-sm"
+ />
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
diff --git a/apps/web-client/src/lib/components/admin/WidgetGridEditor.svelte b/apps/web-client/src/lib/components/admin/WidgetGridEditor.svelte
new file mode 100644
index 0000000..2bc5141
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/WidgetGridEditor.svelte
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+ {#if drag}
+ {#each Array(WIDGET_GRID_COLUMNS * WIDGET_GRID_ROWS) as _, index}
+
+ {/each}
+ {/if}
+
+ {#each visibleWidgets as widget (widget.widgetId)}
+ {@const placement = placementFor(widget)}
+
beginDrag(event, widget, "move")}
+ onpointermove={updateDrag}
+ onpointerup={endDrag}
+ onpointercancel={endDrag}
+ onkeydown={(event) => nudge(event, widget)}
+ onfocus={() => onSelect(widget.widgetId)}
+ >
+
+ {widget.kind}
+
+
+ beginDrag(event, widget, "resize")}
+ onpointermove={updateDrag}
+ onpointerup={endDrag}
+ onpointercancel={endDrag}
+ >
+
+ {/each}
+
+
diff --git a/apps/web-client/src/lib/components/admin/WidgetList.svelte b/apps/web-client/src/lib/components/admin/WidgetList.svelte
new file mode 100644
index 0000000..89786a5
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/WidgetList.svelte
@@ -0,0 +1,101 @@
+
+
+
+
Widgets
+
+ {#if widgets.length === 0}
+
No widgets yet.
+ {/if}
+
+
+ {#each widgets as widget, index (widget.widgetId)}
+
+ onSelect(widget.widgetId)}
+ class="flex-1 text-left text-sm capitalize"
+ class:text-white={selectedWidgetId === widget.widgetId}
+ class:text-ink-1={selectedWidgetId !== widget.widgetId}
+ class:opacity-40={!widget.enabled}
+ >
+ {widget.kind}
+
+ {widget.placement.columnSpan} by {widget.placement.rowSpan}
+
+
+
+ toggle(widget.widgetId)}
+ aria-label={widget.enabled ? "Hide widget" : "Show widget"}
+ class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
+ >
+
+
+
+ move(index, -1)}
+ aria-label="Move earlier"
+ class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
+ >
+
+
+
+ move(index, 1)}
+ aria-label="Move later"
+ class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
+ >
+
+
+
+ remove(widget.widgetId)}
+ aria-label="Delete widget"
+ class="flex h-7 w-7 items-center justify-center text-danger hover:bg-surface-3"
+ >
+
+
+
+ {/each}
+
+
diff --git a/apps/web-client/src/lib/components/admin/WidgetSettingsPanel.svelte b/apps/web-client/src/lib/components/admin/WidgetSettingsPanel.svelte
new file mode 100644
index 0000000..a13f66f
--- /dev/null
+++ b/apps/web-client/src/lib/components/admin/WidgetSettingsPanel.svelte
@@ -0,0 +1,409 @@
+
+
+
+
+
{widget.kind}
+
+
+ onChange({ ...widget, enabled: (event.target as HTMLInputElement).checked })}
+ />
+ Shown
+
+
+
+
+
+
+
+ {#each [
+ { key: "column", label: "Col", max: WIDGET_GRID_COLUMNS },
+ { key: "row", label: "Row", max: WIDGET_GRID_ROWS },
+ { key: "columnSpan", label: "Width", max: WIDGET_GRID_COLUMNS },
+ { key: "rowSpan", label: "Height", max: WIDGET_GRID_ROWS }
+ ] as field}
+
+ {field.label}
+
+ patchPlacement({
+ [field.key]: Number((event.target as HTMLInputElement).value)
+ })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+ {/each}
+
+
+
+
Style
+
+
+ Padding {style.padding}
+
+ patchStyle({ padding: Number((event.target as HTMLInputElement).value) })}
+ class="w-full"
+ />
+
+
+
+
+
Horizontal
+
+ {#each ALIGN_OPTIONS as option}
+ patchStyle({ align: option.value })}
+ aria-label={option.label}
+ title={option.label}
+ class="flex h-9 flex-1 items-center justify-center transition-colors"
+ class:bg-accent={style.align === option.value}
+ class:text-white={style.align === option.value}
+ class:bg-surface-1={style.align !== option.value}
+ class:text-ink-1={style.align !== option.value}
+ >
+
+
+ {/each}
+
+
+
+
+
Vertical
+
+ {#each ALIGN_OPTIONS as option}
+ patchStyle({ verticalAlign: option.value })}
+ aria-label={option.label}
+ title={option.label}
+ class="flex h-9 flex-1 items-center justify-center transition-colors"
+ class:bg-accent={style.verticalAlign === option.value}
+ class:text-white={style.verticalAlign === option.value}
+ class:bg-surface-1={style.verticalAlign !== option.value}
+ class:text-ink-1={style.verticalAlign !== option.value}
+ >
+
+
+ {/each}
+
+
+
+
+
+
Panel colour
+
+ {#if style.backgroundColor}
+ patchStyle({ backgroundColor: "" })}
+ class="bg-surface-1 px-3 py-1.5 text-xs text-ink-1 hover:bg-surface-3"
+ >
+ Reset
+
+ {/if}
+
+ patchStyle({ backgroundColor: (event.target as HTMLInputElement).value })}
+ class="h-9 w-14 bg-surface-1"
+ />
+
+
+
+
+
+ Panel opacity
+ {style.opacity === null ? "follows the layout" : `${Math.round(style.opacity * 100)} percent`}
+
+
+
+ patchStyle({ opacity: Number((event.target as HTMLInputElement).value) / 100 })}
+ class="w-full"
+ />
+ {#if style.opacity !== null}
+ patchStyle({ opacity: null })}
+ class="shrink-0 bg-surface-1 px-3 py-1.5 text-xs text-ink-1 hover:bg-surface-3"
+ >
+ Reset
+
+ {/if}
+
+
+
+
+ {#if widget.kind === "clock"}
+
patchSettings({ timeZone })}
+ />
+
+
+
+ patchSettings({ showSeconds: (event.target as HTMLInputElement).checked })}
+ />
+ Seconds
+
+
+
+ patchSettings({ showDate: (event.target as HTMLInputElement).checked })}
+ />
+ Date
+
+
+
+ patchSettings({ hour12: (event.target as HTMLInputElement).checked })}
+ />
+ 12 hour
+
+
+ {:else if widget.kind === "weather"}
+
+
+ Latitude
+
+ patchSettings({ latitude: Number((event.target as HTMLInputElement).value) })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+
+ Longitude
+
+ patchSettings({ longitude: Number((event.target as HTMLInputElement).value) })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+
+
+ Location label
+
+ patchSettings({ locationLabel: (event.target as HTMLInputElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+
+ Units
+ patchSettings({ units: (event.target as HTMLSelectElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ >
+ Celsius
+ Fahrenheit
+
+
+ {:else if widget.kind === "pin"}
+
+ Label
+ patchSettings({ label: (event.target as HTMLInputElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+
+
+ patchSettings({ showJoinUrl: (event.target as HTMLInputElement).checked })}
+ />
+ Show join address
+
+ {:else if widget.kind === "text"}
+
+ Heading
+ patchSettings({ heading: (event.target as HTMLInputElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+
+ Body
+
+
+ {:else if widget.kind === "image"}
+
+ Image URL
+ patchSettings({ imageUrl: (event.target as HTMLInputElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+
+ Fit
+ patchSettings({ fit: (event.target as HTMLSelectElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ >
+ Cover
+ Contain
+
+
+ {:else if widget.kind === "agenda"}
+
+ Heading
+ patchSettings({ heading: (event.target as HTMLInputElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ />
+
+
+ Items, one per line
+
+
+ {:else if widget.kind === "custom"}
+
+ Definition
+
+ patchSettings({ definitionId: (event.target as HTMLSelectElement).value })}
+ class="w-full bg-surface-2 px-3 py-2 text-sm"
+ >
+ Choose a custom widget
+ {#each definitions as definition}
+ {definition.name}
+ {/each}
+
+
+ {/if}
+
diff --git a/apps/web-client/src/lib/config.ts b/apps/web-client/src/lib/config.ts
new file mode 100644
index 0000000..91d172a
--- /dev/null
+++ b/apps/web-client/src/lib/config.ts
@@ -0,0 +1,3 @@
+import { env } from "$env/dynamic/public";
+
+export const apiBaseUrl = (env.PUBLIC_API_URL ?? "http://localhost:8080").replace(/\/$/, "");
diff --git a/apps/web-client/src/lib/meta.ts b/apps/web-client/src/lib/meta.ts
new file mode 100644
index 0000000..8cc316c
--- /dev/null
+++ b/apps/web-client/src/lib/meta.ts
@@ -0,0 +1,17 @@
+export const SITE_NAME = "PiStation";
+
+export const SITE_DESCRIPTION =
+ "Turn any TV or monitor into a screen the whole room can use. Type the code on screen to " +
+ "present, annotate live, or open a shared whiteboard. Self hosted on hardware you own.";
+
+export const SITE_KEYWORDS = [
+ "screen sharing",
+ "wireless presentation",
+ "raspberry pi kiosk",
+ "digital signage",
+ "self hosted",
+ "whiteboard",
+ "livekit"
+].join(", ");
+
+export const THEME_COLOR = "#0b0d10";
diff --git a/apps/web-client/src/lib/room.svelte.ts b/apps/web-client/src/lib/room.svelte.ts
new file mode 100644
index 0000000..2f14116
--- /dev/null
+++ b/apps/web-client/src/lib/room.svelte.ts
@@ -0,0 +1,546 @@
+import type {
+ ControlEvent,
+ DataEnvelope,
+ DataTopic,
+ NormalizedPoint,
+ RoomMode,
+ StrokeStyle,
+ TopicPayloadMap,
+ WhiteboardElement,
+ WhiteboardEvent
+} from "@pistation/shared-types";
+import { DEFAULT_STROKE_STYLE, isTopic, mergeWhiteboardElements } from "@pistation/shared-types";
+import type { AnnotationState, ConnectionStatus } from "@pistation/client-core";
+import {
+ applyAnnotationEvent,
+ createAnnotationState,
+ eraseAtPoint,
+ makeStrokeId,
+ prunePointers,
+ publishEnvelope,
+ RoomConnection
+} from "@pistation/client-core";
+import {
+ LocalTrackPublication,
+ LocalVideoTrack,
+ RemoteParticipant,
+ RemoteTrack,
+ RemoteTrackPublication,
+ RoomEvent,
+ Track
+} from "livekit-client";
+
+import { refreshSession } from "./api";
+import type { StoredSession } from "./session";
+
+export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser";
+
+export interface RoomParticipant {
+ participantId: string;
+ displayName: string;
+ isSelf: boolean;
+ isSharing: boolean;
+ isCameraOn: boolean;
+}
+
+export interface CameraFeed {
+ participantId: string;
+ displayName: string;
+ track: RemoteTrack;
+}
+
+export class RoomController {
+ status = $state("idle");
+ mode = $state("idle");
+ annotations = $state(createAnnotationState());
+ whiteboardElements = $state([]);
+ screenTrack = $state(null);
+ localScreenTrack = $state(null);
+ localCameraTrack = $state(null);
+ cameraFeeds = $state([]);
+ isSharing = $state(false);
+ isCameraOn = $state(false);
+ facingMode = $state<"user" | "environment">("environment");
+ participants = $state([]);
+ sharingParticipantName = $state(null);
+ errorMessage = $state(null);
+
+ tool = $state("pen");
+ strokeStyle = $state({ ...DEFAULT_STROKE_STYLE });
+ isEraser = $state(false);
+ isPointerMode = $state(false);
+
+ private session: StoredSession;
+ private connection: RoomConnection | null = null;
+ private activeStrokeId: string | null = null;
+ private pendingPoints: NormalizedPoint[] = [];
+ private flushTimer: ReturnType | null = null;
+
+ constructor(session: StoredSession) {
+ this.session = session;
+ }
+
+ get canPresent(): boolean {
+ return this.session.role !== "viewer";
+ }
+
+ get participantId(): string {
+ return this.session.participantId;
+ }
+
+ get kioskName(): string {
+ return this.session.kioskName;
+ }
+
+ get role(): string {
+ return this.session.role;
+ }
+
+ async connect(): Promise {
+ this.connection = new RoomConnection(
+ async () => {
+ const refreshed = await refreshSession(this.session.sessionId);
+ return {
+ livekitUrl: this.session.livekitUrl,
+ accessToken: refreshed.accessToken
+ };
+ },
+ {
+ onStatus: (status) => {
+ this.status = status;
+ },
+ onEnvelope: (envelope) => this.handleEnvelope(envelope),
+ onReconnected: () => this.requestRoomState()
+ }
+ );
+
+ this.bindTrackEvents();
+ await this.connection.start();
+ this.requestRoomState();
+ this.startPointerPruning();
+ }
+
+ async disconnect(): Promise {
+ if (this.flushTimer) clearInterval(this.flushTimer);
+ this.flushTimer = null;
+ await this.connection?.stop();
+ this.connection = null;
+ }
+
+ private bindTrackEvents(): void {
+ const room = this.connection?.room;
+ if (!room) return;
+
+ room
+ .on(
+ RoomEvent.TrackSubscribed,
+ (
+ track: RemoteTrack,
+ publication: RemoteTrackPublication,
+ participant: RemoteParticipant
+ ) => {
+ if (track.kind !== Track.Kind.Video) return;
+
+ if (publication.source === Track.Source.ScreenShare) {
+ this.screenTrack = track;
+ }
+
+ if (publication.source === Track.Source.Camera) {
+ this.cameraFeeds = [
+ ...this.cameraFeeds.filter((feed) => feed.track !== track),
+ {
+ participantId: participant.identity,
+ displayName: participant.name || participant.identity,
+ track
+ }
+ ];
+ }
+
+ this.refreshParticipants();
+ }
+ )
+ .on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {
+ if (this.screenTrack === track) this.screenTrack = null;
+ this.cameraFeeds = this.cameraFeeds.filter((feed) => feed.track !== track);
+ this.refreshParticipants();
+ })
+ .on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
+ if (publication.source === Track.Source.ScreenShare) {
+ this.isSharing = true;
+ this.localScreenTrack = (publication.track as LocalVideoTrack) ?? null;
+ }
+
+ if (publication.source === Track.Source.Camera) {
+ this.isCameraOn = true;
+ this.localCameraTrack = (publication.track as LocalVideoTrack) ?? null;
+ }
+
+ this.refreshParticipants();
+ })
+ .on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
+ if (publication.source === Track.Source.ScreenShare) {
+ this.isSharing = false;
+ this.localScreenTrack = null;
+ }
+
+ if (publication.source === Track.Source.Camera) {
+ this.isCameraOn = false;
+ this.localCameraTrack = null;
+ }
+
+ this.refreshParticipants();
+ })
+ .on(RoomEvent.ParticipantConnected, () => this.refreshParticipants())
+ .on(RoomEvent.ParticipantDisconnected, () => this.refreshParticipants())
+ .on(RoomEvent.Connected, () => this.refreshParticipants());
+ }
+
+ private refreshParticipants(): void {
+ const room = this.connection?.room;
+ if (!room) {
+ this.participants = [];
+ this.sharingParticipantName = null;
+ return;
+ }
+
+ const publishes = (
+ participant: { trackPublications: Map },
+ source: Track.Source
+ ) =>
+ [...participant.trackPublications.values()].some(
+ (publication) => publication.source === source
+ );
+
+ const self: RoomParticipant = {
+ participantId: this.session.participantId,
+ displayName: this.session.displayName,
+ isSelf: true,
+ isSharing: this.isSharing,
+ isCameraOn: this.isCameraOn
+ };
+
+ const others = [...room.remoteParticipants.values()].map((participant) => ({
+ participantId: participant.identity,
+ displayName: participant.name || participant.identity,
+ isSelf: false,
+ isSharing: publishes(participant, Track.Source.ScreenShare),
+ isCameraOn: publishes(participant, Track.Source.Camera)
+ }));
+
+ this.participants = [self, ...others];
+ this.sharingParticipantName = this.broadcaster?.displayName ?? null;
+ }
+
+ /// The room has a single broadcast slot. Whoever holds it, with either a screen or a
+ /// camera, holds it until they stop.
+ get broadcaster(): RoomParticipant | null {
+ return (
+ this.participants.find((participant) => participant.isSharing || participant.isCameraOn) ??
+ null
+ );
+ }
+
+ get isSomeoneElseSharing(): boolean {
+ const holder = this.broadcaster;
+ return Boolean(holder && !holder.isSelf);
+ }
+
+ /// One source at a time, per room and per person, so a phone camera cannot quietly ride
+ /// alongside a screen share.
+ get canStartScreenShare(): boolean {
+ return !this.isSomeoneElseSharing && !this.isCameraOn;
+ }
+
+ get canStartCamera(): boolean {
+ return !this.isSomeoneElseSharing && !this.isSharing;
+ }
+
+ get blockedReason(): string | null {
+ const holder = this.broadcaster;
+ if (holder && !holder.isSelf) {
+ const what = holder.isSharing ? "sharing a screen" : "sharing a camera";
+ return `${holder.displayName} is ${what}`;
+ }
+ return null;
+ }
+
+ get participantCount(): number {
+ return this.participants.length;
+ }
+
+ async startScreenShare(): Promise {
+ const room = this.connection?.room;
+ if (!room) return;
+
+ if (!this.canStartScreenShare) {
+ this.errorMessage =
+ this.blockedReason ?? "Turn your camera off before sharing your screen.";
+ return;
+ }
+
+ try {
+ this.errorMessage = null;
+ await room.localParticipant.setScreenShareEnabled(true, {
+ audio: false,
+ contentHint: "detail"
+ });
+ this.setMode("presentation");
+ } catch {
+ this.errorMessage = "Screen sharing was blocked or cancelled.";
+ }
+ }
+
+ /// Publishes the device camera. Phones default to the rear lens, which is what people
+ /// point at a whiteboard or a room, and can be flipped without dropping the track.
+ async startCamera(): Promise {
+ const room = this.connection?.room;
+ if (!room) return;
+
+ if (!this.canStartCamera) {
+ this.errorMessage =
+ this.blockedReason ?? "Stop sharing your screen before turning the camera on.";
+ return;
+ }
+
+ try {
+ this.errorMessage = null;
+ await room.localParticipant.setCameraEnabled(true, {
+ facingMode: this.facingMode,
+ resolution: { width: 1280, height: 720 }
+ });
+ } catch {
+ this.errorMessage = "Could not use the camera. Check the site has permission.";
+ this.isCameraOn = false;
+ }
+ }
+
+ async stopCamera(): Promise {
+ const room = this.connection?.room;
+ if (!room) return;
+ await room.localParticipant.setCameraEnabled(false);
+ }
+
+ async flipCamera(): Promise {
+ this.facingMode = this.facingMode === "environment" ? "user" : "environment";
+ if (!this.isCameraOn) return;
+
+ const track = this.localCameraTrack;
+ if (!track) return;
+
+ try {
+ await track.restartTrack({ facingMode: this.facingMode });
+ } catch {
+ this.errorMessage = "This device only has one camera.";
+ }
+ }
+
+ async stopScreenShare(): Promise {
+ const room = this.connection?.room;
+ if (!room) return;
+ await room.localParticipant.setScreenShareEnabled(false);
+ this.setMode("idle");
+ }
+
+ setMode(mode: RoomMode): void {
+ if (!this.canPresent) return;
+ this.mode = mode;
+ this.send("control", { type: "mode.set", mode });
+ }
+
+ beginStroke(point: NormalizedPoint): void {
+ if (this.isPointerMode) {
+ this.movePointer(point);
+ return;
+ }
+
+ if (this.isEraser) {
+ this.eraseAt(point);
+ return;
+ }
+
+ const strokeId = makeStrokeId();
+ this.activeStrokeId = strokeId;
+ this.pendingPoints = [];
+
+ const event = {
+ type: "stroke.start" as const,
+ strokeId,
+ tool: this.tool,
+ style: { ...this.strokeStyle },
+ point
+ };
+
+ this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
+ this.send("annotation", event);
+ }
+
+ extendStroke(point: NormalizedPoint): void {
+ if (this.isPointerMode) {
+ this.movePointer(point);
+ return;
+ }
+
+ if (this.isEraser) {
+ this.eraseAt(point);
+ return;
+ }
+ if (!this.activeStrokeId) return;
+
+ const event = {
+ type: "stroke.append" as const,
+ strokeId: this.activeStrokeId,
+ points: [point]
+ };
+
+ this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
+ this.pendingPoints.push(point);
+ this.flushPendingPoints();
+ }
+
+ endStroke(): void {
+ if (this.isPointerMode) return;
+ if (!this.activeStrokeId) return;
+ const strokeId = this.activeStrokeId;
+ this.activeStrokeId = null;
+ this.flushPendingPoints(true);
+
+ const event = { type: "stroke.end" as const, strokeId };
+ this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
+ this.send("annotation", event);
+ }
+
+ movePointer(point: NormalizedPoint | null): void {
+ this.send(
+ "annotation",
+ { type: "pointer.move", point, color: this.strokeStyle.color },
+ false
+ );
+ }
+
+ clearAnnotations(): void {
+ const event = { type: "canvas.clear" as const };
+ this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
+ this.send("annotation", event);
+ }
+
+ pushWhiteboardElements(elements: WhiteboardElement[]): void {
+ if (elements.length === 0) return;
+ this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, elements);
+ this.send("whiteboard", { type: "whiteboard.patch", elements });
+ }
+
+ private eraseAt(point: NormalizedPoint): void {
+ const strokeIds = eraseAtPoint(this.annotations, point, 0.02);
+ if (strokeIds.length === 0) return;
+
+ const event = { type: "stroke.erase" as const, strokeIds };
+ this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
+ this.send("annotation", event);
+ }
+
+ private flushPendingPoints(force = false): void {
+ if (this.pendingPoints.length === 0) return;
+ if (!force && this.pendingPoints.length < 3) return;
+ if (!this.activeStrokeId && !force) return;
+
+ const strokeId = this.activeStrokeId;
+ const points = this.pendingPoints;
+ this.pendingPoints = [];
+ if (!strokeId) return;
+
+ this.send("annotation", { type: "stroke.append", strokeId, points }, false);
+ }
+
+ private requestRoomState(): void {
+ this.send("control", { type: "room.state.request" });
+ this.send("whiteboard", { type: "whiteboard.request" });
+ }
+
+ private startPointerPruning(): void {
+ this.flushTimer = setInterval(() => {
+ this.annotations = prunePointers(this.annotations, Date.now());
+ }, 1000);
+ }
+
+ private send(
+ topic: T,
+ payload: TopicPayloadMap[T],
+ reliable = true
+ ): void {
+ const room = this.connection?.room;
+ if (!room) return;
+ publishEnvelope(room, topic, this.session.participantId, payload, reliable);
+ }
+
+ private handleEnvelope(envelope: DataEnvelope): void {
+ if (envelope.senderId === this.session.participantId) return;
+
+ if (isTopic(envelope, "annotation")) {
+ this.annotations = applyAnnotationEvent(
+ this.annotations,
+ envelope.payload,
+ envelope.senderId
+ );
+ return;
+ }
+
+ if (isTopic(envelope, "control")) {
+ this.handleControlEvent(envelope.payload);
+ return;
+ }
+
+ if (isTopic(envelope, "whiteboard")) {
+ this.handleWhiteboardEvent(envelope.payload);
+ }
+ }
+
+ private handleControlEvent(event: ControlEvent): void {
+ if (event.type === "mode.set") {
+ this.mode = event.mode;
+ return;
+ }
+
+ if (event.type === "room.state") {
+ this.mode = event.state.mode;
+ return;
+ }
+
+ if (event.type === "room.state.request" && this.isSharing) {
+ this.send("control", {
+ type: "room.state",
+ state: {
+ roomName: this.session.roomName,
+ mode: this.mode,
+ presenterId: this.session.participantId,
+ annotationsLocked: false,
+ updatedAt: Date.now()
+ }
+ });
+ }
+ }
+
+ private handleWhiteboardEvent(event: WhiteboardEvent): void {
+ if (event.type === "whiteboard.patch") {
+ this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
+ return;
+ }
+
+ if (event.type === "whiteboard.snapshot") {
+ this.whiteboardElements = event.elements;
+ return;
+ }
+
+ if (event.type === "whiteboard.clear") {
+ this.whiteboardElements = [];
+ return;
+ }
+
+ if (event.type === "whiteboard.request" && this.whiteboardElements.length > 0) {
+ this.send("whiteboard", {
+ type: "whiteboard.snapshot",
+ elements: this.whiteboardElements,
+ backgroundColor: "#0b0d10"
+ });
+ }
+ }
+}
diff --git a/apps/web-client/src/lib/session.ts b/apps/web-client/src/lib/session.ts
new file mode 100644
index 0000000..43729aa
--- /dev/null
+++ b/apps/web-client/src/lib/session.ts
@@ -0,0 +1,60 @@
+import type { JoinResponse } from "@pistation/shared-types";
+import { browser } from "$app/environment";
+
+const SESSION_KEY = "pistation.session";
+const ADMIN_KEY = "pistation.admin";
+
+export type StoredSession = JoinResponse;
+
+export function saveSession(session: StoredSession): void {
+ if (!browser) return;
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
+}
+
+export function loadSession(): StoredSession | null {
+ if (!browser) return null;
+ const raw = sessionStorage.getItem(SESSION_KEY);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as StoredSession;
+ } catch {
+ return null;
+ }
+}
+
+export function clearSession(): void {
+ if (!browser) return;
+ sessionStorage.removeItem(SESSION_KEY);
+}
+
+export interface StoredAdmin {
+ accessToken: string;
+ email: string;
+ expiresAt: number;
+}
+
+export function saveAdmin(admin: StoredAdmin): void {
+ if (!browser) return;
+ localStorage.setItem(ADMIN_KEY, JSON.stringify(admin));
+}
+
+export function loadAdmin(): StoredAdmin | null {
+ if (!browser) return null;
+ const raw = localStorage.getItem(ADMIN_KEY);
+ if (!raw) return null;
+ try {
+ const admin = JSON.parse(raw) as StoredAdmin;
+ if (admin.expiresAt <= Date.now()) {
+ localStorage.removeItem(ADMIN_KEY);
+ return null;
+ }
+ return admin;
+ } catch {
+ return null;
+ }
+}
+
+export function clearAdmin(): void {
+ if (!browser) return;
+ localStorage.removeItem(ADMIN_KEY);
+}
diff --git a/apps/web-client/src/routes/+layout.svelte b/apps/web-client/src/routes/+layout.svelte
new file mode 100644
index 0000000..97da37e
--- /dev/null
+++ b/apps/web-client/src/routes/+layout.svelte
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
diff --git a/apps/web-client/src/routes/+page.svelte b/apps/web-client/src/routes/+page.svelte
new file mode 100644
index 0000000..420ba79
--- /dev/null
+++ b/apps/web-client/src/routes/+page.svelte
@@ -0,0 +1,262 @@
+
+
+
+ {title}
+
+
+
+
+
+
+{#if isJoinOnly}
+
+
+ {#if logoUrl}
+
+ {:else}
+
+ {/if}
+
{branding.name}
+ {#if branding.description}
+
{branding.description}
+ {/if}
+
+
+
+
+{:else}
+
+
+
+
+
+
+
+
+ {branding.headline}
+
+
+
{branding.description}
+
+
+
+
+
+
+
+
+
How it works
+
+ Three steps, no setup for the people joining.
+
+
+
+ {#each steps as step, index}
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
{step.title}
+
{step.body}
+
+ {/each}
+
+
+
+
+
+ Features
+
+ Everything the room needs on one screen.
+
+
+
+ {#each features as feature}
+
+
+
{feature.title}
+
{feature.body}
+
+ {/each}
+
+
+
+
+
+
+
Self host
+
+ Your screens, your server, your data.
+
+
+ PiStation ships as a Docker Compose stack: a LiveKit media server, a Rust backend and
+ this site. Media travels directly between devices on your own network, so a
+ presentation never touches anyone else's infrastructure.
+
+
+
+ {#each ["One command to bring the whole stack up", "SQLite for storage, no external database", "Add as many kiosks and rooms as you like", "Every kiosk reports its own CPU, memory and signal strength"] as line}
+
+
+ {line}
+
+ {/each}
+
+
+
+
+
Get started
+
cp infra/.env.example infra/.env
+docker compose -f infra/docker-compose.yml up -d
+
+ Then sign in to the admin panel, add a kiosk, and copy its enrollment token onto the Pi.
+
+
+
+
+
+
+
+
+ {branding.name}{branding.footerNote ? ` · ${branding.footerNote}` : ""}
+
+
+
+
+
+{/if}
diff --git a/apps/web-client/src/routes/admin/+page.svelte b/apps/web-client/src/routes/admin/+page.svelte
new file mode 100644
index 0000000..1eff841
--- /dev/null
+++ b/apps/web-client/src/routes/admin/+page.svelte
@@ -0,0 +1,435 @@
+
+
+
+ Admin · PiStation
+
+
+
+{#if !admin}
+
+
+
+
+ Back to PiStation
+
+
+
+
+
PiStation admin
+
Sign in to manage kiosks
+
+
+ {#if errorMessage}
+
+
+ {errorMessage}
+
+ {/if}
+
+
+
+
+{:else}
+
+
+
+ {#if errorMessage}
+
+
+ {errorMessage}
+
+ {/if}
+
+ {#if issuedToken}
+
+
+ (issuedToken = null)}
+ class="self-start bg-surface-2 px-4 py-2 text-sm text-ink-1 hover:bg-surface-3"
+ >
+ Done
+
+
+ {/if}
+
+
+
+
+
Kiosk build
+
+
+
+ packageInput?.click()}
+ disabled={isUploadingPackage}
+ class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3 disabled:text-ink-2"
+ >
+ {#if isUploadingPackage}
+
+ Uploading
+ {:else}
+
+ Upload .deb
+ {/if}
+
+
+
+
+ The installer script downloads this package onto each Pi. Upload the arm64 build produced
+ by the release workflow, then every kiosk is one command to set up.
+
+
+ {#if packageMessage}
+ {packageMessage}
+ {/if}
+
+
+
+ Kiosks
+
+ {#if kiosks.length === 0}
+
+ No kiosks yet. Add one below to get an enrollment token.
+
+ {/if}
+
+
+ {#each kiosks as kiosk (kiosk.kioskId)}
+
+
+
+ {#if editingId === kiosk.kioskId}
+
+ {:else}
+
+
{kiosk.name}
+
+ {kiosk.location || "No location"} · last seen {formatLastSeen(kiosk.lastSeenAt)}
+
+ {#if kiosk.status === "online" && kiosk.metrics}
+
+
+
+ {/if}
+
+
+
startRename(kiosk)}
+ aria-label={`Rename ${kiosk.name}`}
+ class="flex h-9 w-9 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
+ >
+
+
+
+
+
+ Widgets
+
+
+
removeKiosk(kiosk)}
+ aria-label={`Delete ${kiosk.name}`}
+ class="flex h-9 w-9 items-center justify-center bg-surface-2 text-danger transition-colors hover:bg-surface-3"
+ >
+
+
+ {/if}
+
+ {/each}
+
+
+
+
+
+{/if}
diff --git a/apps/web-client/src/routes/admin/kiosks/[kioskId]/+page.svelte b/apps/web-client/src/routes/admin/kiosks/[kioskId]/+page.svelte
new file mode 100644
index 0000000..5a3fbb0
--- /dev/null
+++ b/apps/web-client/src/routes/admin/kiosks/[kioskId]/+page.svelte
@@ -0,0 +1,391 @@
+
+
+
+ {kiosk ? `${kiosk.name} · Admin · PiStation` : "Kiosk · Admin · PiStation"}
+
+
+
+
+
+
+ {#if statusMessage}
+
+
+ {statusMessage}
+
+ {/if}
+
+ {#if errorMessage}
+
+
+ {errorMessage}
+
+ {/if}
+
+ {#if layout}
+
+
+
+
+
+ Preview
+
+ (isPreviewingNight = !isPreviewingNight)}
+ class="flex items-center gap-2 px-3 py-2 text-sm transition-colors"
+ class:bg-accent={isPreviewingNight}
+ class:text-white={isPreviewingNight}
+ class:bg-surface-2={!isPreviewingNight}
+ class:text-ink-1={!isPreviewingNight}
+ >
+
+ Night mode
+
+
+
+ {#if isPreviewingNight}
+
+
+
+ {:else}
+
(selectedWidgetId = widgetId)}
+ onWidgetsChange={replaceWidgets}
+ />
+
+ Drag a widget to move it, drag its corner to resize. With one selected, the arrow
+ keys nudge it and shift with the arrow keys resizes.
+
+ {/if}
+
+
+
+
Add a widget
+
+ {#each BUILTIN_WIDGET_KINDS as kind}
+ addWidget(kind)}
+ class="bg-surface-2 px-4 py-3 text-sm text-ink-1 capitalize transition-colors hover:bg-surface-3"
+ >
+ + {kind}
+
+ {/each}
+
+
+
+
+
+
+
+ (selectedWidgetId = widgetId)}
+ onChange={replaceWidgets}
+ />
+
+ {#if selectedWidget}
+ removeWidget(selectedWidget.widgetId)}
+ />
+ {:else}
+
+ Select a widget to edit its settings.
+
+ {/if}
+
+
+
+
+ Device stats
+
+
+
+
+
+
+ (layout = updated)}
+ onUpload={handleWallpaperUpload}
+ {isUploading}
+ {uploadError}
+ />
+
+ (layout = updated)} />
+
+ {#if newEnrollmentToken}
+
+ {/if}
+
+
+
Enrollment
+
+ Rotating issues a fresh token and a new install command for re-imaging this Pi.
+
+
+ Rotate enrollment token
+
+
+
+
+ {/if}
+
diff --git a/apps/web-client/src/routes/admin/organization/+page.svelte b/apps/web-client/src/routes/admin/organization/+page.svelte
new file mode 100644
index 0000000..a43ed26
--- /dev/null
+++ b/apps/web-client/src/routes/admin/organization/+page.svelte
@@ -0,0 +1,391 @@
+
+
+
+ Organisation · Admin · PiStation
+
+
+
+
+
+
+ {#if statusMessage}
+
+
+ {statusMessage}
+
+ {/if}
+
+ {#if errorMessage}
+
+
+ {errorMessage}
+
+ {/if}
+
+ {#if branding}
+ {@const current = branding}
+
+
+ Identity
+
+
+ Organisation name
+ patch({ name: (event.target as HTMLInputElement).value })}
+ class="w-full bg-surface-2 px-4 py-3 text-ink-0"
+ />
+
+
+
+
+ {#if logoPreview}
+
+ {:else}
+
+
+
+ {/if}
+
+
+
+
logoInput?.click()}
+ disabled={isUploading}
+ class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 hover:bg-surface-3 disabled:text-ink-2"
+ >
+ {#if isUploading}
+
+ Uploading
+ {:else}
+
+ Upload logo
+ {/if}
+
+
+ {#if current.logoUrl}
+
patch({ logoUrl: "" })}
+ class="px-3 py-2 text-sm text-danger hover:bg-surface-2"
+ >
+ Reset
+
+ {/if}
+
+
+
+ Accent colour
+
+ patch({ accentColor: (event.target as HTMLInputElement).value })}
+ class="h-10 w-16 bg-surface-2"
+ />
+
+
+
+
+
+
+
+
+
Theme
+ patch({ theme: { ...DEFAULT_THEME } })}
+ class="bg-surface-2 px-3 py-2 text-sm text-ink-1 hover:bg-surface-3"
+ >
+ Reset
+
+
+
+
+ These colours apply across the whole website, including the join screen and the admin
+ panel.
+
+
+
+ {#each THEME_FIELDS as field}
+
+ {field.label}
+
+ patch({
+ theme: {
+ ...current.theme,
+ [field.key]: (event.target as HTMLInputElement).value
+ }
+ })}
+ class="h-9 w-16 bg-surface-1"
+ />
+
+ {/each}
+
+
+
+
+
+ {/if}
+
diff --git a/apps/web-client/src/routes/room/+page.svelte b/apps/web-client/src/routes/room/+page.svelte
new file mode 100644
index 0000000..0629b33
--- /dev/null
+++ b/apps/web-client/src/routes/room/+page.svelte
@@ -0,0 +1,298 @@
+
+
+
+ {controller ? `${controller.kioskName} · PiStation` : "Room · PiStation"}
+
+
+
+{#if controller}
+ {@const current = controller}
+
+
+
+
+
+
{current.kioskName}
+
+ {current.role === "presenter" ? "Presenting" : "Viewing"}
+
+
+
+
+
+
+
+
+
+
+
+ {current.isSharing ? "Stop sharing" : "Share screen"}
+
+
+
+
+ {current.isCameraOn ? "Stop camera" : "Camera"}
+
+
+ {#if current.isCameraOn}
+ current.flipCamera()}
+ aria-label="Switch camera"
+ title="Switch camera"
+ class="flex h-9 w-9 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
+ >
+
+
+ {/if}
+
+
+
+ Whiteboard
+
+
+ (isAnnotating = !isAnnotating)}
+ disabled={current.mode === "whiteboard"}
+ class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:bg-surface-2 disabled:text-ink-2"
+ class:bg-accent={isAnnotating && current.mode !== "whiteboard"}
+ class:text-white={isAnnotating && current.mode !== "whiteboard"}
+ class:bg-surface-2={!isAnnotating}
+ class:text-ink-1={!isAnnotating}
+ >
+
+ Annotate
+
+
+
+
+ Leave
+
+
+
+
+ {#if current.errorMessage}
+
+
+ {current.errorMessage}
+
+ {/if}
+
+
+
+
+ {#if current.mode === "whiteboard"}
+
current.pushWhiteboardElements(changed)}
+ />
+ {:else if current.localScreenTrack}
+
+
+
+ You are sharing this screen
+
+ current.beginStroke(point)}
+ onStrokeExtend={(point) => current.extendStroke(point)}
+ onStrokeEnd={() => current.endStroke()}
+ onPointerMove={(point) => current.movePointer(point)}
+ />
+ {:else if current.screenTrack}
+
+ current.beginStroke(point)}
+ onStrokeExtend={(point) => current.extendStroke(point)}
+ onStrokeEnd={() => current.endStroke()}
+ onPointerMove={(point) => current.movePointer(point)}
+ />
+ {:else if cameraTiles.length > 0}
+ 1}
+ >
+ {#each cameraTiles as tile (tile.key)}
+
+
+
+
+ {tile.label}
+
+
+ {/each}
+
+ {:else}
+
+
+
+
Nothing on screen yet
+
+ Share your screen, turn on a camera, or open the whiteboard to begin.
+
+
+
+ {/if}
+
+
+ {#if drawingNow.length > 0}
+
+
+ {drawingNow.join(", ")}
+ {drawingNow.length === 1 ? "is" : "are"} drawing
+
+ {/if}
+
+ {#if isAnnotating && current.mode !== "whiteboard"}
+
+ {/if}
+
+
+
+{:else}
+
+
+
+{/if}
diff --git a/apps/web-client/static/logo.svg b/apps/web-client/static/logo.svg
new file mode 100644
index 0000000..166d7b2
--- /dev/null
+++ b/apps/web-client/static/logo.svg
@@ -0,0 +1,24 @@
+
+ PiStation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web-client/static/og-image.svg b/apps/web-client/static/og-image.svg
new file mode 100644
index 0000000..c1cc280
--- /dev/null
+++ b/apps/web-client/static/og-image.svg
@@ -0,0 +1,60 @@
+
+ PiStation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PiStation
+
+
+
+ Any screen becomes a shared screen.
+
+
+
+ Present, annotate and whiteboard together. Self hosted.
+
+
diff --git a/apps/web-client/svelte.config.js b/apps/web-client/svelte.config.js
new file mode 100644
index 0000000..820ad0a
--- /dev/null
+++ b/apps/web-client/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from "@sveltejs/adapter-node";
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/apps/web-client/tsconfig.json b/apps/web-client/tsconfig.json
new file mode 100644
index 0000000..4344710
--- /dev/null
+++ b/apps/web-client/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/apps/web-client/vite.config.ts b/apps/web-client/vite.config.ts
new file mode 100644
index 0000000..bdbfe2a
--- /dev/null
+++ b/apps/web-client/vite.config.ts
@@ -0,0 +1,22 @@
+import { sveltekit } from "@sveltejs/kit/vite";
+import tailwindcss from "@tailwindcss/vite";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ server: {
+ // The workspace root, so the dev server may serve the shared packages.
+ fs: {
+ allow: ["../.."]
+ }
+ },
+ ssr: {
+ noExternal: ["@pistation/shared-types", "@pistation/client-core", "@pistation/ui"]
+ },
+ optimizeDeps: {
+ include: ["react", "react-dom", "react-dom/client"]
+ },
+ define: {
+ "process.env.IS_PREACT": JSON.stringify("false")
+ }
+});