feat(web): consolidate paired devices, self-contained sections, docs + lint
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 5m51s
android / android (push) Successful in 6m21s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
windows-host / package (push) Successful in 8m6s
release / apple (push) Successful in 8m17s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
apple / screenshots (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 22s

Web console
- Pairing/Library/Stats refactored into self-contained subsections that each own
  their own queries + mutations; a shared slot-based layout (view.tsx) is filled by
  the live page (containers) and Storybook (pure cards + fixtures) so the layout can't
  drift.
- All paired devices in one list on Pairing with a protocol column (punktfunk/1 +
  Moonlight), routing each unpair to the right endpoint; the redundant Clients page is
  removed.
- Library: overview grid split from the add/edit form into separate files.
- Login screen links out to the docs.

Docs
- "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows)
  plus a new "Forgot your Password?" troubleshooting page, linked from the login screen.
- Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the
  Bun entry, launchers, systemd units, and packaging.

Tooling
- Biome now respects .gitignore (stops linting generated code), config migrated to
  2.5.1; all lint issues fixed cleanly.

Also includes this branch's in-progress host, Apple client, packaging, and CI changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 19:05:22 +02:00
parent e1bc9fda22
commit ba39b08e09
86 changed files with 2726 additions and 2019 deletions
+1 -8
View File
@@ -27,14 +27,7 @@ function ShellHarness({ initialPath }: { initialPath: string }) {
),
});
const navPaths = [
"/",
"/host",
"/library",
"/clients",
"/pairing",
"/settings",
];
const navPaths = ["/", "/host", "/library", "/pairing", "/settings"];
const navRoutes = navPaths.map((path) =>
createRoute({
getParentRoute: () => rootRoute,
-20
View File
@@ -1,20 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ClientsView } from "@/sections/Clients/view";
import { pairedClients } from "./lib/fixtures";
const meta = {
title: "Pages/Clients",
component: ClientsView,
args: { onUnpair: () => {}, isUnpairing: false },
} satisfies Meta<typeof ClientsView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Paired: Story = {
args: { clients: { data: pairedClients, isLoading: false, error: null } },
};
export const Empty: Story = {
args: { clients: { data: [], isLoading: false, error: null } },
};
+45 -13
View File
@@ -1,26 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LibraryView } from "@/sections/Library/view";
import { GameForm } from "@/sections/Library/GameForm";
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
import { library } from "./lib/fixtures";
const noop = () => {};
const idle = { isLoading: false, error: null, refetch: noop };
const emptyForm = {
title: "",
portrait: "",
hero: "",
header: "",
command: "",
};
// The overview grid and the add/edit form are separate components now, so the stories
// render each on its own (no combined page view).
const meta = {
title: "Pages/Library",
component: LibraryView,
args: {
onCreate: () => Promise.resolve(),
onUpdate: () => Promise.resolve(),
onDelete: () => Promise.resolve(),
isSaving: false,
isDeleting: false,
},
} satisfies Meta<typeof LibraryView>;
parameters: { layout: "padded" },
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj;
export const Populated: Story = {
args: { library: { data: library, isLoading: false, error: null } },
render: () => (
<LibraryGrid
library={{ data: library, ...idle }}
onEdit={noop}
onDelete={noop}
isDeleting={false}
/>
),
};
export const Empty: Story = {
args: { library: { data: [], isLoading: false, error: null } },
render: () => (
<LibraryGrid
library={{ data: [], ...idle }}
onEdit={noop}
onDelete={noop}
isDeleting={false}
/>
),
};
export const AddForm: Story = {
render: () => (
<GameForm
initial={emptyForm}
mode="add"
onSubmit={noop}
onCancel={noop}
isSaving={false}
/>
),
};
+1 -1
View File
@@ -13,4 +13,4 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Error: Story = { args: { error: true } };
export const ErrorState: Story = { args: { error: true } };
+59 -23
View File
@@ -1,8 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MoonlightPairing } from "@/sections/Pairing/MoonlightPairingCard";
import { NativePairingCard } from "@/sections/Pairing/NativePairingCard";
import { PairedDevices } from "@/sections/Pairing/PairedDevices";
import { PendingDevices } from "@/sections/Pairing/PendingDevices";
import { PairingView } from "@/sections/Pairing/view";
import {
nativeClients,
nativePairArmed,
pairedClients,
pairingIdle,
pendingDevices,
} from "./lib/fixtures";
@@ -10,39 +15,70 @@ import {
const noop = () => {};
const idle = { isLoading: false, error: null, refetch: noop };
// Renders the REAL page layout (PairingView) — the same component index.tsx uses. The live page
// fills its slots with the self-contained containers; here we fill them with the pure cards + mock
// state, so there's no duplicated composition to drift.
const meta = {
title: "Pages/Pairing",
component: PairingView,
parameters: { layout: "padded" },
args: {
onApprove: noop,
onDeny: noop,
pendingBusy: false,
onArm: noop,
onDisarm: noop,
isArming: false,
isDisarming: false,
onUnpair: noop,
isUnpairing: false,
pin: "",
onPinChange: noop,
onSubmitPin: noop,
isSubmittingPin: false,
pinSuccess: false,
pinError: false,
},
} satisfies Meta<typeof PairingView>;
export default meta;
type Story = StoryObj<typeof meta>;
// The marketing state: a PIN armed for a phone, one device knocking for delegated
// approval, two already-paired native clients.
// The marketing state: one device knocking for delegated approval, a PIN armed for a phone, the
// consolidated paired-devices list (native + Moonlight), idle Moonlight pairing.
export const Armed: Story = {
args: {
pending: { data: pendingDevices, ...idle },
native: { data: nativePairArmed, ...idle },
clients: { data: nativeClients, ...idle },
moonlight: { data: pairingIdle, ...idle },
pending: (
<PendingDevices
pending={{ data: pendingDevices, ...idle }}
onApprove={noop}
onDeny={noop}
busy={false}
/>
),
native: (
<NativePairingCard
status={{ data: nativePairArmed, ...idle }}
onArm={noop}
onDisarm={noop}
isArming={false}
isDisarming={false}
/>
),
moonlight: (
<MoonlightPairing
pairing={{ data: pairingIdle, ...idle }}
pin=""
onPinChange={noop}
onSubmit={noop}
isSubmitting={false}
isSuccess={false}
isError={false}
/>
),
paired: (
<PairedDevices
rows={[
...nativeClients.map((c) => ({
protocol: "native" as const,
fingerprint: c.fingerprint,
name: c.name,
})),
...pairedClients.map((c) => ({
protocol: "moonlight" as const,
fingerprint: c.fingerprint,
name: c.subject ?? "",
})),
]}
isLoading={false}
error={null}
refetch={noop}
onUnpair={noop}
isUnpairing={false}
/>
),
},
};
+52 -23
View File
@@ -1,48 +1,77 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CaptureControlCard } from "@/sections/Stats/CaptureControl";
import { DetailCard } from "@/sections/Stats/Detail";
import { RecordingsCard } from "@/sections/Stats/Recordings";
import { StatsView } from "@/sections/Stats/view";
import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures";
const noop = () => {};
const idle = { isLoading: false, error: null, refetch: noop };
// Renders the REAL page layout (StatsView) — the same component index.tsx uses — with the pure
// cards + mock state in its slots, so there's no duplicated composition to drift.
const meta = {
title: "Pages/Stats",
component: StatsView,
parameters: { layout: "padded" },
args: {
onStart: noop,
onStop: noop,
onSelect: noop,
onDownload: noop,
onDelete: noop,
isStarting: false,
isStopping: false,
isDeleting: false,
},
} satisfies Meta<typeof StatsView>;
export default meta;
type Story = StoryObj<typeof meta>;
// A finished run open in the detail view: recordings table populated and the full
// graph set (latency stack · throughput · loss/FEC) rendered from a deterministic
// fixture series — no live host or capture needed.
// A finished run open in the detail view: recordings table populated and the full graph set
// (latency stack · throughput · loss/FEC) rendered from a deterministic fixture series — no live
// host or capture needed.
export const Recording: Story = {
args: {
status: { data: statsStatusIdle, ...idle },
live: { data: undefined, ...idle },
recordings: { data: captureMetas, ...idle },
detail: { data: captureDetail, ...idle },
selectedId: captureMetas[0]?.id ?? null,
control: (
<CaptureControlCard
status={{ data: statsStatusIdle, ...idle }}
onStart={noop}
onStop={noop}
isStarting={false}
isStopping={false}
/>
),
live: null,
recordings: (
<RecordingsCard
recordings={{ data: captureMetas, ...idle }}
selectedId={captureMetas[0]?.id ?? null}
onSelect={noop}
onDownload={noop}
onDelete={noop}
isDeleting={false}
/>
),
detail: (
<DetailCard detail={{ data: captureDetail, ...idle }} onClose={noop} />
),
},
};
export const Empty: Story = {
args: {
status: { data: statsStatusIdle, ...idle },
live: { data: undefined, ...idle },
recordings: { data: [], ...idle },
detail: { data: undefined, ...idle },
selectedId: null,
control: (
<CaptureControlCard
status={{ data: statsStatusIdle, ...idle }}
onStart={noop}
onStop={noop}
isStarting={false}
isStopping={false}
/>
),
live: null,
recordings: (
<RecordingsCard
recordings={{ data: [], ...idle }}
selectedId={null}
onSelect={noop}
onDownload={noop}
onDelete={noop}
isDeleting={false}
/>
),
detail: null,
},
};
+7
View File
@@ -216,6 +216,13 @@ export const pendingDevices: PendingDevice[] = [
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
age_secs: 8,
},
{
id: 2,
name: "Mac Mini",
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
age_secs: 30,
},
];
export const nativeClients: NativeClient[] = [