+ );
+}
+
+function CloseIcon() {
+ return (
+
+ );
+}
diff --git a/src/hub/src/components/partner-row.tsx b/src/hub/src/components/partner-row.tsx
new file mode 100644
index 0000000..7e26d32
--- /dev/null
+++ b/src/hub/src/components/partner-row.tsx
@@ -0,0 +1,142 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { JSX } from 'preact';
+import { useId, useRef, useState } from 'preact/hooks';
+import { CopyControl, Chip } from './ui';
+import { PartnerStatus } from './status';
+import type { TrustedInstance } from '../lib/types';
+
+let idCounter = 0;
+const nextId = () => `partner-${++idCounter}`;
+
+/**
+ * A trusted-partner row (§4.5). Tapping opens an inline disclosure explaining
+ * browsers can't browse partners; Escape inside collapses and returns focus to
+ * the row button. No focus trap and no focus movement on open — the panel is
+ * adjacent DOM and reading order continues naturally.
+ */
+export function PartnerRow({ partner }: { partner: TrustedInstance }) {
+ const [open, setOpen] = useState(false);
+ const rowId = useId() || nextId();
+ const panelId = `${rowId}-panel`;
+ const titleId = `${rowId}-title`;
+ const btnRef = useRef(null);
+ const name = partner.name || partner.url;
+
+ const toggle = () => setOpen(o => !o);
+ const close = () => {
+ setOpen(false);
+ btnRef.current?.focus();
+ };
+
+ const onKeyDown = (e: JSX.TargetedKeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ close();
+ }
+ };
+
+ return (
+
+
+
+ {open ? (
+
+
+ Can't browse partners from the browser
+
+
+ A browser page may only read the instance that serves it, so this
+ hub can't fetch {name}’s profile. Open its address directly, or use
+ the Hub app on a phone.
+
+ );
+}
+
+export function linkClick(target: string) {
+ return (e: JSX.TargetedMouseEvent) => {
+ // Only intercept plain left-clicks (middle-click / ctrl+click stay native).
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
+ return;
+ }
+ e.preventDefault();
+ push(target);
+ };
+}
+
+/* ------------------------------------------------------------------ */
+/* Instance-scope header + tab strip */
+/* ------------------------------------------------------------------ */
+
+export function TabStrip({ active }: { active: 'services' | 'trusted' }) {
+ const { refresh, probing } = useData();
+ return (
+
+ );
+}
+
+export function BackChevron({ target, label }: { target: string; label: string }) {
+ // A real (middle-click/new-tab safe); the click handler applies the
+ // one back rule (§3.1.1) only for plain left-clicks.
+ return (
+ ) => {
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
+ return;
+ }
+ e.preventDefault();
+ backOrReplace(target);
+ }}
+ >
+
+
+ );
+}
+
+export function InfoButton({ onClick, label = 'Instance details' }: { onClick: () => void; label?: string }) {
+ return (
+
+
+
+ );
+}
+
+function RefreshIcon() {
+ return (
+
+ );
+}
+
+function SettingsIcon() {
+ return (
+
+ );
+}
+
+function BackIcon() {
+ return (
+
+ );
+}
+
+function InfoIcon() {
+ return (
+
+ );
+}
diff --git a/src/hub/src/components/status.tsx b/src/hub/src/components/status.tsx
new file mode 100644
index 0000000..3ffaec5
--- /dev/null
+++ b/src/hub/src/components/status.tsx
@@ -0,0 +1,99 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { InstanceStatus } from '../lib/types';
+import {
+ capitalize,
+ instanceTone,
+ partnerOk,
+ serviceTone,
+ type Tone,
+} from '../lib/format';
+import { Dot } from './ui';
+
+/**
+ * Status pill: `*-soft` fill + 6 px status dot + INK word (§6.2). The word is
+ * visible text (the accessible name); colour is never the only signal.
+ */
+export function StatusPill({ status }: { status: string }) {
+ const tone = serviceTone(status);
+ return (
+
+
+ {capitalize(status)}
+
+ );
+}
+
+/** Partner status: ok (online/authorized) or muted. */
+export function PartnerStatus({ status }: { status: string }) {
+ const ok = partnerOk(status);
+ return (
+
+
+ {capitalize(status)}
+
+ );
+}
+
+/** Instance connectivity indicator: wifi icon + status word + aria-label. */
+export function InstanceStatusBadge({ status }: { status: InstanceStatus }) {
+ const tone = instanceTone(status);
+ return (
+
+
+
+ {status}
+
+
+ );
+}
+
+function WifiIcon({ tone }: { tone: Tone }) {
+ const color = tone === 'ok' ? 'var(--dn-ok)' : tone === 'warn' ? 'var(--dn-warn)' : 'var(--dn-bad)';
+ return (
+
+ );
+}
diff --git a/src/hub/src/components/ui.tsx b/src/hub/src/components/ui.tsx
new file mode 100644
index 0000000..315e74e
--- /dev/null
+++ b/src/hub/src/components/ui.tsx
@@ -0,0 +1,195 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { ComponentChildren, JSX } from 'preact';
+import { useCallback, useRef, useState } from 'preact/hooks';
+import type { Tone } from '../lib/format';
+
+/** Decorative icon slot. */
+export type IconSlot = JSX.Element | null;
+
+export function Card({ className = '', children }: { className?: string; children: ComponentChildren }) {
+ return
+ );
+}
+
+export type DotTone = Tone | 'muted';
+
+export function Dot({ tone }: { tone: DotTone }) {
+ return ;
+}
+
+export function Chip({
+ children,
+ soft = false,
+ title,
+}: {
+ children: ComponentChildren;
+ soft?: boolean;
+ title?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function IconButton({
+ label,
+ onClick,
+ disabled,
+ children,
+ className = 'icon-btn',
+}: {
+ label: string;
+ onClick?: () => void;
+ disabled?: boolean;
+ children: ComponentChildren;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Clipboard Copy control (§4.0). Feature-detects navigator.clipboard; on
+ * absence (plain-HTTP origins) selects the rendered mono value instead and
+ * says "Select and copy the address" — never a lying "Copied".
+ */
+export function CopyControl({ text }: { text: string }) {
+ const codeRef = useRef(null);
+ const [feedback, setFeedback] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const onCopy = useCallback(() => {
+ const target = codeRef.current;
+ if (!target) return;
+ if (navigator.clipboard?.writeText) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ setFeedback('Copied');
+ setCopied(true);
+ window.setTimeout(() => {
+ setCopied(false);
+ setFeedback(null);
+ }, 2000);
+ })
+ .catch(() => {
+ selectText(target);
+ setFeedback('Select and copy the address');
+ });
+ } else {
+ selectText(target);
+ setFeedback('Select and copy the address');
+ }
+ }, [text]);
+
+ return (
+
+
+ {text}
+
+
+ {feedback && !copied ? {feedback} : null}
+
+ );
+}
+
+function selectText(el: HTMLElement): void {
+ const sel = window.getSelection();
+ if (!sel) return;
+ sel.removeAllRanges();
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ sel.addRange(range);
+}
diff --git a/src/hub/src/lib/api.ts b/src/hub/src/lib/api.ts
new file mode 100644
index 0000000..9031829
--- /dev/null
+++ b/src/hub/src/lib/api.ts
@@ -0,0 +1,155 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type {
+ InstancePublicProfile,
+ PublicService,
+ SectionState,
+ TrustedInstance,
+} from './types';
+
+export type ProfileErrorCode =
+ | 'ERR_NOT_INSTANCE'
+ | 'ERR_UNREACHABLE'
+ | 'ERR_OLD_MAESTRO';
+
+export class ProfileError extends Error {
+ code: ProfileErrorCode;
+ /** Raw HTTP status when a non-2xx response was received (may be 0). */
+ status: number;
+
+ constructor(code: ProfileErrorCode, message: string, status = 0) {
+ super(message);
+ this.code = code;
+ this.status = status;
+ }
+}
+
+const isRecord = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null;
+
+const isArray = (v: unknown): v is unknown[] => Array.isArray(v);
+
+/**
+ * Fetch and parse a Maestro public profile (GET /api/instance).
+ *
+ * Error taxonomy (§4.0):
+ * - fetch rejects / abort (10 s timeout) → ERR_UNREACHABLE,
+ * - clean 404 → ERR_OLD_MAESTRO,
+ * - non-2xx / non-JSON / wrong shape / empty instance.name → ERR_NOT_INSTANCE
+ * (401/403 are distinguished by callers via `error.status`).
+ *
+ * The raw body is returned alongside the normalized profile so callers can
+ * read section *presence* before null-normalization (§4.0).
+ */
+export interface FetchResult {
+ profile: InstancePublicProfile;
+ /** Section presence per the raw body (drives private/empty states). */
+ sections: {
+ services: SectionState;
+ trusted: SectionState;
+ };
+}
+
+function sectionState(key: string, body: Record): SectionState {
+ if (!(key in body)) {
+ return { presence: 'absent', value: [] };
+ }
+ const raw = body[key];
+ if (raw === null) {
+ return { presence: 'null', value: [] };
+ }
+ if (isArray(raw)) {
+ return {
+ presence: raw.length === 0 ? 'empty' : 'present',
+ value: raw as PublicService[] | TrustedInstance[],
+ };
+ }
+ // Malformed-but-present: treat as empty for safety.
+ return { presence: 'empty', value: [] };
+}
+
+export async function fetchPublicProfile(
+ baseUrl: string,
+ timeoutMs = 10000,
+): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ let res: Response;
+ try {
+ res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/instance`, {
+ signal: controller.signal,
+ headers: { Accept: 'application/json' },
+ });
+ } catch {
+ throw new ProfileError('ERR_UNREACHABLE', `cannot reach ${baseUrl}`);
+ } finally {
+ clearTimeout(timer);
+ }
+
+ if (res.status === 404) {
+ throw new ProfileError('ERR_OLD_MAESTRO', `${baseUrl} has no /api/instance`, 404);
+ }
+ if (!res.ok) {
+ throw new ProfileError('ERR_NOT_INSTANCE', `HTTP ${res.status} from ${baseUrl}`, res.status);
+ }
+
+ let body: unknown;
+ try {
+ body = await res.json();
+ } catch {
+ throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} sent non-JSON`);
+ }
+
+ if (!isRecord(body) || !isRecord(body.instance)) {
+ throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} is not an instance`);
+ }
+ const inst = body.instance as Record;
+ if (typeof inst.name !== 'string' || inst.name === '') {
+ throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} is not an instance`);
+ }
+
+ return {
+ profile: {
+ instance: body.instance as unknown as InstancePublicProfile['instance'],
+ services: normalizeSection(body.services),
+ trusted_instances: normalizeSection(body.trusted_instances),
+ },
+ sections: {
+ services: sectionState('services', body),
+ trusted: sectionState('trusted_instances', body),
+ },
+ };
+}
+
+function normalizeSection(v: unknown): T[] | null {
+ return isArray(v) ? (v as T[]) : null;
+}
+
+/** Number of listed services when the array is present (not private/null). */
+export function serviceCount(sections: SectionState): number | null {
+ return sections.presence === 'present' || sections.presence === 'empty'
+ ? sections.value.length
+ : null;
+}
+
+/** Number of listed partners when the array is present (not private/null). */
+export function partnerCount(sections: SectionState): number | null {
+ return sections.presence === 'present' || sections.presence === 'empty'
+ ? sections.value.length
+ : null;
+}
diff --git a/src/hub/src/lib/format.ts b/src/hub/src/lib/format.ts
new file mode 100644
index 0000000..9661dfa
--- /dev/null
+++ b/src/hub/src/lib/format.ts
@@ -0,0 +1,69 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { InstanceStatus } from './types';
+
+/** The rendered status word is exactly the runtime set (§4.6 vocabulary). */
+export const statusWord = (s: InstanceStatus): string => s;
+
+/** Capitalize a raw wire status for display (e.g. "running" → "Running"). */
+export const capitalize = (s: string): string =>
+ s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
+
+/** Map a service status to a semantic dot tone (ok | warn | bad). */
+export type Tone = 'ok' | 'warn' | 'bad';
+
+export function serviceTone(status: string): Tone {
+ if (status === 'running') return 'ok';
+ if (status === 'stopped') return 'warn';
+ return 'bad';
+}
+
+/** Map instance connectivity to a semantic dot tone. */
+export function instanceTone(status: InstanceStatus): Tone {
+ if (status === 'connected') return 'ok';
+ if (status === 'flaky') return 'warn';
+ return 'bad';
+}
+
+/** Map a partner status to ok (online/authorized) or muted otherwise. */
+export function partnerOk(status: string): boolean {
+ return status === 'online' || status === 'authorized';
+}
+
+/** Pluralize a noun: (1, 'service') → "1 service". */
+export function plural(n: number, singular: string): string {
+ return `${n} ${n === 1 ? singular : `${singular}s`}`;
+}
+
+/** The profile display name: title || name, else a fallback word. */
+export function instanceDisplayName(
+ title: string | undefined,
+ name: string | undefined,
+ fallback = 'instance',
+): string {
+ return title || name || fallback;
+}
+
+/**
+ * Version chips stay on one line: image digests can be 64-char hashes, so a
+ * long version is shortened for the card (the full value stays available as a
+ * `title` tooltip and on the service detail page).
+ */
+export function shortVersion(version: string): string {
+ return version.length > 16 ? `${version.slice(0, 12)}…` : version;
+}
diff --git a/src/hub/src/lib/router.ts b/src/hub/src/lib/router.ts
new file mode 100644
index 0000000..b56a8ec
--- /dev/null
+++ b/src/hub/src/lib/router.ts
@@ -0,0 +1,113 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * Path-based History-API router (§3.1). Routes live under the frozen SPA mount
+ * point `/hub/`. Every navigation writes `history.state = { hubDepth: n }` so
+ * the in-app back rule (§3.1.1) can tell whether a previous in-app entry
+ * exists below the current one.
+ */
+
+export type Route =
+ | { name: 'redirect-root' }
+ | { name: 'instances' }
+ | { name: 'redirect-local' }
+ | { name: 'services' }
+ | { name: 'service-detail'; serviceName: string }
+ | { name: 'trusted' }
+ | { name: 'settings' }
+ | { name: 'not-found' };
+
+/** Strip a leading `/hub` so matching works in dev (no prefix) and prod. */
+function strip(p: string): string {
+ if (p.startsWith('/hub')) {
+ p = p.slice(4) || '/';
+ }
+ return p;
+}
+
+export function matchPath(rawPath: string): Route {
+ const p = strip(rawPath);
+ if (p === '/' || p === '') return { name: 'redirect-root' };
+ if (p === '/instances') return { name: 'instances' };
+ if (p === '/instances/local') return { name: 'redirect-local' };
+ if (p === '/instances/local/services') return { name: 'services' };
+ if (p === '/instances/local/trusted') return { name: 'trusted' };
+ if (p === '/settings') return { name: 'settings' };
+ if (p.startsWith('/instances/local/services/')) {
+ const rest = p.slice('/instances/local/services/'.length);
+ if (rest && !rest.includes('/')) {
+ return { name: 'service-detail', serviceName: decodeURIComponent(rest) };
+ }
+ }
+ return { name: 'not-found' };
+}
+
+type Listener = () => void;
+const listeners = new Set();
+
+export function subscribe(fn: Listener): () => void {
+ listeners.add(fn);
+ return () => {
+ listeners.delete(fn);
+ };
+}
+
+function emit(): void {
+ listeners.forEach(fn => fn());
+}
+
+window.addEventListener('popstate', emit);
+
+function currentDepth(): number {
+ const s: unknown = history.state;
+ if (s && typeof s === 'object') {
+ const d = (s as { hubDepth?: unknown }).hubDepth;
+ if (typeof d === 'number') return d;
+ }
+ return 0;
+}
+
+/** pushState: a new in-app entry (depth + 1). Used by tab/forward links. */
+export function push(target: string): void {
+ history.pushState({ hubDepth: currentDepth() + 1 }, '', target);
+ emit();
+}
+
+/** replaceState: keep the current depth (redirects, defaults). */
+export function replace(target: string): void {
+ history.replaceState({ hubDepth: currentDepth() }, '', target);
+ emit();
+}
+
+/**
+ * The one back rule (§3.1.1): if a previous in-app entry exists below this
+ * one, browser Back unwinds it; otherwise replace to the target so the
+ * chevron never exits the app and Back leaves it as the user arrived.
+ */
+export function backOrReplace(target: string): void {
+ if (currentDepth() >= 1) {
+ history.back();
+ } else {
+ replace(target);
+ }
+}
+
+/** href (prefixed) for a canonical path. */
+export function href(path: string): string {
+ return `/hub${path === '/' ? '' : path}`;
+}
diff --git a/src/hub/src/lib/types.ts b/src/hub/src/lib/types.ts
new file mode 100644
index 0000000..5c4e823
--- /dev/null
+++ b/src/hub/src/lib/types.ts
@@ -0,0 +1,88 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/** Mirrors Maestro spec.yaml `InstanceIdentity` (hub-app M1 schema). */
+export interface InstanceIdentity {
+ name: string;
+ title?: string;
+ description?: string;
+ fingerprint?: string;
+}
+
+/** Mirrors `PublicService` — no ids, IPs, or ports ever cross the wire. */
+export interface PublicService {
+ service_name: string;
+ title?: string;
+ description?: string;
+ status: string;
+ version?: string;
+ capabilities?: string[];
+ has_auth?: boolean;
+ image?: string;
+}
+
+/** Mirrors `TrustedInstance`. Rev 9 retired federation, so the public profile's
+ * `trusted_instances` array is always empty; the type is kept for wire
+ * compatibility with older hub builds. */
+export interface TrustedInstance {
+ name: string;
+ url: string;
+ fingerprint: string;
+ status: string;
+ scopes?: string[];
+}
+
+/** GET /api/instance — the one unauthenticated read surface (hub-app D2). */
+export interface InstancePublicProfile {
+ instance: InstanceIdentity;
+ services?: PublicService[] | null;
+ trusted_instances?: TrustedInstance[] | null;
+}
+
+/**
+ * Connectivity health of the serving instance, shown by the WiFi icon.
+ * The rendered status vocabulary is exactly this set (§4.6).
+ */
+export type InstanceStatus = 'connected' | 'flaky' | 'disconnected';
+
+/** A section of the profile can be absent (privacy off), null, empty or data. */
+export type SectionPresence = 'absent' | 'null' | 'empty' | 'present';
+
+export interface SectionState {
+ presence: SectionPresence;
+ /** Normalized value: [] when null/absent/empty, else the array. */
+ value: PublicService[] | TrustedInstance[];
+}
+
+/**
+ * Shell-level profile state. `profile` is the last successful decode;
+ * `status` is the hysteresis-buffered connectivity; `error` is the last
+ * failure message when a probe failed; `probing`/`lastProbedAt` drive the
+ * "Connecting…" neutral label and the in-flight guard.
+ */
+export interface DataState {
+ profile: InstancePublicProfile | null;
+ status: InstanceStatus;
+ probing: boolean;
+ error: string | null;
+ lastProbedAt: number | null;
+ /** Whether the last probe failed (drives the separate failure sentence). */
+ lastProbeFailed: boolean;
+ offline: boolean;
+ /** Last detected section presence (drives private/empty states). */
+ sections: { services: SectionState; trusted: SectionState } | null;
+}
diff --git a/src/hub/src/main.tsx b/src/hub/src/main.tsx
new file mode 100644
index 0000000..67d672a
--- /dev/null
+++ b/src/hub/src/main.tsx
@@ -0,0 +1,22 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { render } from 'preact'
+import './styles.css'
+import { App } from './app.tsx'
+
+render(, document.getElementById('root')!)
diff --git a/src/hub/src/screens/instance-home.tsx b/src/hub/src/screens/instance-home.tsx
new file mode 100644
index 0000000..3efc61d
--- /dev/null
+++ b/src/hub/src/screens/instance-home.tsx
@@ -0,0 +1,28 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { ServicesScreen } from './services';
+
+/**
+ * Instance home (§4.2). The bare `/hub/instances/local` route carries no tab
+ * in the URL, so the app redirects it (replace, never a history entry) to the
+ * Services tab — native's initialRouteName. This screen renders that tab so
+ * the redirect target is never blank during the transition.
+ */
+export function InstanceHomeScreen() {
+ return ;
+}
diff --git a/src/hub/src/screens/instances.tsx b/src/hub/src/screens/instances.tsx
new file mode 100644
index 0000000..6c9d5fd
--- /dev/null
+++ b/src/hub/src/screens/instances.tsx
@@ -0,0 +1,101 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { useData, linkClick } from '../components/shell';
+import { Banner } from '../components/ui';
+import { InstanceStatusBadge } from '../components/status';
+import { href } from '../lib/router';
+import { plural, instanceDisplayName } from '../lib/format';
+
+/**
+ * Instances home (§4.1): one card for the same-origin instance, plus the
+ * static "Other instances can't be added here" peers panel. The card is a
+ * real to /hub/instances/local.
+ */
+export function InstancesScreen() {
+ const { profile, status, error, offline, probing, lastProbedAt, sections, refresh } = useData();
+ const name = profile ? instanceDisplayName(profile.instance.title, profile.instance.name) : 'this instance';
+ const origin = typeof window !== 'undefined' ? window.location.origin : '';
+ const connecting = probing && lastProbedAt === null;
+
+ // Count only present, non-null sections (§4.0): an absent key (privacy off)
+ // or a null value (store absent) is not counted; a present [] counts as 0.
+ const servicesCount =
+ sections && (sections.services.presence === 'present' || sections.services.presence === 'empty')
+ ? sections.services.value.length
+ : null;
+ const partnersCount =
+ sections && (sections.trusted.presence === 'present' || sections.trusted.presence === 'empty')
+ ? sections.trusted.value.length
+ : null;
+ const summary = [
+ servicesCount !== null ? plural(servicesCount, 'service') : null,
+ partnersCount !== null ? plural(partnersCount, 'partner') : null,
+ ].filter(Boolean);
+
+ return (
+
+ This hub is served by the instance it describes, and a browser page
+ may only read that one instance. To browse other instances, use the
+ Hub app on a phone, or open the other instance's own hub address
+ directly.
+
+
+
+ );
+}
+
+function ServerIcon() {
+ return (
+
+ );
+}
diff --git a/src/hub/src/screens/not-found.tsx b/src/hub/src/screens/not-found.tsx
new file mode 100644
index 0000000..432b069
--- /dev/null
+++ b/src/hub/src/screens/not-found.tsx
@@ -0,0 +1,69 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { JSX } from 'preact';
+import { href, push } from '../lib/router';
+
+/** Not found (§4.7): a static route with no data dependency. */
+export function NotFoundScreen() {
+ const target = href('/instances');
+ return (
+
+ );
+}
+
+function MissingIcon() {
+ return (
+
+ );
+}
diff --git a/src/hub/src/screens/service-detail.tsx b/src/hub/src/screens/service-detail.tsx
new file mode 100644
index 0000000..9680dd9
--- /dev/null
+++ b/src/hub/src/screens/service-detail.tsx
@@ -0,0 +1,203 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { JSX } from 'preact';
+import { BackChevron, TabStrip, useData } from '../components/shell';
+import { Banner, EmptyState, LoadingView, Card } from '../components/ui';
+import { StatusPill } from '../components/status';
+import { instanceDisplayName } from '../lib/format';
+import { href, push } from '../lib/router';
+import type { PublicService } from '../lib/types';
+
+/**
+ * Service detail (§4.4): read-only all public fields. Rows use a
; the
+ * status pill is soft fill + status dot + ink word. Not-found when the probe
+ * succeeded but no such service_name exists. An xl-only