Compare commits

...
4 Commits
Author SHA1 Message Date
enricobuehler 23f53d46a7 chore(web): biome 2.5, and the findings it surfaced
ci / docs-drift (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 32s
ci / web (pull_request) Successful in 52s
ci / docs-site (pull_request) Successful in 1m46s
ci / rust-arm64 (pull_request) Successful in 3m4s
ci / rust (pull_request) Successful in 7m51s
nix / flake (pull_request) Successful in 7m43s
biome is not in ci.yml's web gate, so formatting and import order had drifted; `biome check
--write` accounts for most of this diff. `biome migrate` moved the deprecated
linter.rules.recommended to rules.preset. public/ and storybook-static/ are excluded — biome
was linting the favicon as if it were inline JSX and asking it for a <title>.

Three findings were real:

- Library lifted the whole entry list into state so the providers card could count owners.
  That consumer is gone: `entries` was written on every grid load and read by nothing. The
  state, the `onEntries` prop threading it up, and the effect firing it are all removed —
  including from LibraryGrid, where the callback had no callers left.
- `flatten` in SourceSettings spread the accumulator per `allOf` branch, rebuilding the object
  each time. Object.assign onto an accumulator that already starts as a fresh copy of the node
  is the same result without the O(n²).
- LogsCard's `newestVisible` dep already carried a comment begging the next person not to
  "fix" it — it is a trigger, not a read, and removing it silently breaks follow-mode. That
  warning is now a biome-ignore, so the tool enforces it instead of prose hoping to.

Build, tsc, 22 tests and biome all clean.
2026-08-28 02:23:40 +02:00
enricobuehler f693edda20 refactor(web,docs-site): take the stale-chunk recovery from @unom/ui instead of keeping our own
@unom/ui has exported `reloadOnStaleChunk` for exactly this the whole time and nothing had
ever imported it — the copy added a few commits ago was a fourth implementation of a solved
problem. Both routers now call the library.

0.10.0 grew the two things using it turned up: an optional `resolveTarget`, so the recovery
lands on the page the user was navigating to rather than reloading in place, and a try/catch
around the sessionStorage guard, because blocked storage throws on `getItem` and that throw
escaping the listener leaves the tab on exactly the dead page the helper exists to prevent.

The helper installs once, so the callback reads a module-level ref rather than closing over a
router: hydration builds a second router and discards the first, which is the same reason the
console keeps its QueryClient in one.

Deps: @unom/ui 0.10.0 and motion 13.1 (0.10.0 widened the peer, which is what had pinned us to
12), plus @unom/app-ui 0.3.0 for the docs site.

docs-site: build + tsc clean, and the deploy simulation still recovers onto /docs with the
library helper in place. web: build + tsc + 22 tests clean, bun.nix regenerated.
2026-08-28 02:23:25 +02:00
enricobuehler 71454da082 chore(deps): bring the docs site and console up to Vite 8 and TypeScript 7
Everything in range moves to current — TanStack router/start/query, fumadocs, radix,
recharts, orval, storybook, biome, paraglide, playwright — and the majors that were
holding both apps a generation back go with them: Vite 7 to 8, @vitejs/plugin-react 5 to
6, vite-tsconfig-paths 5 to 6, @types/node 22 to 26, TypeScript 5.9 to 7. The marketing
site already ran the Vite 8 line against TanStack Start, so this closes a split the
monorepo was carrying for no reason.

Two of the console's majors were fixing a mismatch rather than chasing a number:
@unom/ui@0.9.2 asks for lucide-react ^1.17 and depends on tailwind-merge ^3.6, while the
console was pinned to lucide-react 0.469 and tailwind-merge 2.6.

motion is deliberately left on 12.43. 13.1 is available, but @unom/ui declares motion
^12.40 as a peer and inlines framer-motion 12.38, so moving the app alone would put two
motion runtimes in one tree — and a split motion context is exactly the failure that
neither a typecheck nor a build can see. It moves when @unom/ui does.

docs-site: build + tsc clean. web: build + tsc + 22 tests clean, bun.nix regenerated.
2026-08-28 01:24:21 +02:00
enricobuehler 263e173604 fix(web,docs-site): a deploy under an open tab stops killing the next navigation
Routes are code-split, so every build names its chunks by content hash and a deploy
replaces the whole `.output`. A tab opened before the deploy still holds the previous
build's HTML, so the first navigation asks for `/assets/*-<oldhash>.js` — which the new
server has never heard of. The dynamic import rejects, nothing catches it, and the page
goes blank. `defaultPreload: "intent"` widens the window: a hover is enough to trip it.

Vite raises `vite:preloadError` for exactly this, and its preload helper wraps both the
dependency preloads and the module import itself, so the event fires before the router
ever sees the rejection. A full page load is the entire fix — the fresh HTML names the
new chunks. Navigating to `latestLocation` rather than reloading in place means a click
that tripped this still lands on the page the user asked for.

Deliberately not `preventDefault()`: that suppresses Vite's rethrow and resolves the
import with `undefined`, handing the router a broken module on the way out. A
sessionStorage stamp keeps a genuinely broken deploy from spinning in a reload loop, and
its read is wrapped because storage can be blocked outright.

Verified on the built docs site under Playwright, serving 404s for the chunks an open tab
asks for: without the handler the page collapses to 32 characters and no <main>, with it
the tab reloads onto /docs and renders in full. Re-verified after the Vite 8 bump.
2026-08-28 01:24:05 +02:00
24 changed files with 1577 additions and 979 deletions
+454 -164
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -11,14 +11,14 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@scalar/api-reference-react": "^0.9.63",
"@tanstack/react-router": "^1.170.28",
"@tanstack/react-start": "^1.168.45",
"@unom/app-ui": "^0.2.1",
"@scalar/api-reference-react": "^0.9.65",
"@tanstack/react-router": "^1.170.32",
"@tanstack/react-start": "^1.168.49",
"@unom/app-ui": "^0.3.0",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.2",
"fumadocs-core": "^16.14.4",
"fumadocs-ui": "^16.14.4",
"@unom/ui": "^0.10.0",
"fumadocs-core": "^16.15.4",
"fumadocs-ui": "^16.15.4",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
@@ -26,14 +26,14 @@
"@tailwindcss/vite": "^4.3.3",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/mdx": "^2.0.14",
"@types/node": "^22.20.1",
"@types/node": "^26",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.2.0",
"fumadocs-mdx": "^15.2.3",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6",
"fumadocs-mdx": "^15.4.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4"
"typescript": "^7",
"vite": "^8",
"vite-tsconfig-paths": "^6"
}
}
+18 -1
View File
@@ -1,13 +1,30 @@
import { createRouter as createTanStackRouter, Link } from '@tanstack/react-router'
import { reloadOnStaleChunk } from '@unom/ui/preload-reload'
import { routeTree } from './routeTree.gen'
// Hydration can build a second router and discard the first. `reloadOnStaleChunk`
// registers once, so its callback has to read whichever router is live at event
// time rather than closing over the one that happened to exist at startup.
let liveRouter: { latestLocation: { href: string } } | undefined
export function getRouter() {
return createTanStackRouter({
const router = createTanStackRouter({
routeTree,
defaultPreload: 'intent',
scrollRestoration: true,
defaultNotFoundComponent: NotFound,
})
liveRouter = router
// A deploy replaces every hashed chunk, so a tab opened before it asks for files the
// new server has never heard of and the next navigation dies — with `defaultPreload:
// 'intent'`, a hover is enough to trip it. Handing back where the router was heading
// means the click that tripped it still lands on the page the reader asked for; during
// a hover-preload that is the current URL, which degrades to a plain reload.
reloadOnStaleChunk(10_000, () => liveRouter?.latestLocation.href)
return router
}
function NotFound() {
+2 -2
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**"]
"includes": ["**", "!public", "!storybook-static"]
},
"css": {
"parser": {
+328 -240
View File
File diff suppressed because it is too large Load Diff
+652 -492
View File
File diff suppressed because it is too large Load Diff
+23 -23
View File
@@ -23,42 +23,42 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-router": "^1.170.32",
"@tanstack/react-start": "^1.168.49",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.2",
"@unom/ui": "^0.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.4",
"lucide-react": "^1",
"motion": "^13.1.1",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.0",
"tailwind-merge": "^2.6.1",
"recharts": "^3.10.1",
"tailwind-merge": "^3",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@inlang/paraglide-js": "^2.22.0",
"@inlang/plugin-message-format": "^4.4.0",
"@storybook/react-vite": "^10.5.3",
"@biomejs/biome": "^2.5.11",
"@inlang/paraglide-js": "^2.25.0",
"@inlang/plugin-message-format": "^4.4.3",
"@storybook/react-vite": "^10.5.10",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@types/node": "^26",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6",
"bun2nix": "2.1.2",
"orval": "^8.22.0",
"playwright": "^1.61.1",
"storybook": "^10.5.3",
"orval": "^8.26.0",
"playwright": "^1.62.1",
"storybook": "^10.5.10",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4"
"typescript": "^7",
"vite": "^8",
"vite-tsconfig-paths": "^6"
},
"overrides": {
"tar": "^7.5.21",
@@ -8,7 +8,12 @@
//
// Wins over the `/api/**` catch-all by h3 route specificity — the catch-all injects the admin
// bearer unconditionally, so without this file the host-side check is inert for this caller.
import { defineEventHandler, getRequestURL, getRouterParam, readBody } from "h3";
import {
defineEventHandler,
getRequestURL,
getRouterParam,
readBody,
} from "h3";
import { forwardJson } from "../../../../../util/forward";
import { confirmIfCommandExecution } from "../../../../../util/libraryConfirm";
+3 -1
View File
@@ -27,7 +27,9 @@ interface EntryLike {
}
/** Does this entry carry a field the host will hand to a shell? */
export function carriesCommandExecution(entry: EntryLike | null | undefined): boolean {
export function carriesCommandExecution(
entry: EntryLike | null | undefined,
): boolean {
if (!entry || typeof entry !== "object") return false;
if (Array.isArray(entry.prep) && entry.prep.length > 0) return true;
return entry.launch?.kind === "command";
+5 -5
View File
@@ -82,11 +82,11 @@ const resolve = (os?: string): { viewBox: string; d: string } | undefined => {
};
/** The host's OS mark; a generic monitor when the chain is absent or entirely unknown. */
export const OsIcon: FC<{ os?: string; className?: string; label?: string }> = ({
os,
className,
label,
}) => {
export const OsIcon: FC<{
os?: string;
className?: string;
label?: string;
}> = ({ os, className, label }) => {
const icon = resolve(os);
if (!icon) return <Monitor className={className} aria-hidden />;
return (
+17 -1
View File
@@ -1,5 +1,6 @@
import { QueryClient } from "@tanstack/react-query";
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { reloadOnStaleChunk } from "@unom/ui/preload-reload";
import { ApiError } from "./api/fetcher";
import { routeTree } from "./routeTree.gen";
@@ -38,6 +39,10 @@ function createQueryClient() {
*/
let browserQueryClient: QueryClient | undefined;
// Same rebuild, same reason: `reloadOnStaleChunk` registers once, so its callback reads
// whichever router is live at event time instead of the one discarded by hydration.
let liveRouter: { latestLocation: { href: string } } | undefined;
export function getRouter() {
let queryClient: QueryClient;
if (typeof window === "undefined") {
@@ -47,7 +52,7 @@ export function getRouter() {
queryClient = browserQueryClient;
}
return createTanStackRouter({
const router = createTanStackRouter({
routeTree,
context: { queryClient },
defaultPreload: "intent",
@@ -56,6 +61,17 @@ export function getRouter() {
<QueryProvider client={queryClient}>{children}</QueryProvider>
),
});
liveRouter = router;
// A deploy replaces every hashed chunk, so a console left open across one asks for files
// the new server has never heard of and the next navigation dies — with `defaultPreload:
// "intent"`, a hover is enough to trip it. Handing back where the router was heading means
// the click that tripped it still lands on the page the operator asked for; during a
// hover-preload that is the current URL, which degrades to a plain reload.
reloadOnStaleChunk(10_000, () => liveRouter?.latestLocation.href);
return router;
}
// Local import kept below the function so the module reads top-down.
+3 -1
View File
@@ -1,4 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionDisplays } from "@/sections/Displays";
export const Route = createFileRoute("/displays")({ component: SectionDisplays });
export const Route = createFileRoute("/displays")({
component: SectionDisplays,
});
+3 -1
View File
@@ -93,7 +93,9 @@ const GameRow: FC<{
})
: untracked
? m.games_untracked_note()
: [game.client, planeLabel(game.plane)].filter(Boolean).join(" · ")}
: [game.client, planeLabel(game.plane)]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<Button
+2 -7
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useMemo } from "react";
import { type FC, useMemo } from "react";
import {
getGetLibraryQueryKey,
useDeleteCustomGame,
@@ -27,16 +27,11 @@ export const LibraryGridSection: FC<{
onEdit: (entry: OperatorGameEntry) => void;
/** Show only entries owned by this provider, or everything when null. */
providerFilter?: string | null;
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
onEntries?: (entries: OperatorGameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
}> = ({ onEdit, providerFilter }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const library = useGetLibrary();
const all = library.data;
useEffect(() => {
if (all) onEntries?.(all);
}, [all, onEntries]);
// Filtering CLIENT-side: `GET /library?provider=` exists, but the page already holds the whole
// list for the grid, and a second parameterised query would just be a second cache entry of the
// same data going stale independently.
+3 -1
View File
@@ -156,8 +156,10 @@ interface JsonSchemaDoc {
* a form that only reads the top level silently loses every title and default on those fields.
*/
const flatten = (node: JsonSchemaNode): JsonSchemaNode =>
// Object.assign rather than a spread: the accumulator starts as a fresh copy of `node`, so
// mutating it is contained here, and it stays O(n) instead of rebuilding the object per branch.
(node.allOf ?? []).reduce<JsonSchemaNode>(
(acc, branch) => ({ ...acc, ...branch }),
(acc, branch) => Object.assign(acc, branch),
{ ...node },
);
+1 -5
View File
@@ -1,7 +1,6 @@
import Section from "@unom/ui/section";
import { Plus } from "lucide-react";
import { type FC, useState } from "react";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -17,9 +16,7 @@ export const SectionLibrary: FC = () => {
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
// by the target re-seeds its fields when switching add → edit (or between entries).
const [target, setTarget] = useState<FormTarget | null>(null);
// The full list, lifted from the grid so the providers card can count owners without a second
// copy of the same query, plus which provider (if any) the grid is filtered to.
const [entries, setEntries] = useState<GameEntry[]>([]);
// Which provider (if any) the grid is filtered to.
const [providerFilter, setProviderFilter] = useState<string | null>(null);
return (
@@ -51,7 +48,6 @@ export const SectionLibrary: FC = () => {
<LibraryGridSection
onEdit={(entry) => setTarget(entry)}
providerFilter={providerFilter}
onEntries={setEntries}
/>
</div>
</Section>
+1
View File
@@ -153,6 +153,7 @@ export const LogsCard: FC<{
// offers to remove it. Do NOT take that fix — it is a TRIGGER, the signal that new lines arrived.
// Removing it reinstates the bug this replaced: the effect stops re-running and follow-mode
// quietly stops following. The same warning was here before, on `visible.length`.
// biome-ignore lint/correctness/useExhaustiveDependencies: newestVisible is a deliberate trigger, not a read — see the note above; removing it stops follow-mode from following.
useEffect(() => {
if (!follow) return;
const el = listRef.current;
+9 -3
View File
@@ -15,8 +15,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
import {
type AccessDraft,
AccessControls,
type AccessDraft,
draftExpirySecs,
draftFromStored,
GRANT_ALL,
@@ -124,7 +124,9 @@ export const ApproveDialog: FC<{
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="approve-name">{m.pairing_pending_name_prompt()}</Label>
<Label htmlFor="approve-name">
{m.pairing_pending_name_prompt()}
</Label>
<Input
id="approve-name"
autoFocus
@@ -140,7 +142,11 @@ export const ApproveDialog: FC<{
</p>
)}
<AccessControls value={draft} onChange={setDraft} idPrefix="approve" />
<AccessControls
value={draft}
onChange={setDraft}
idPrefix="approve"
/>
{/* Approving pairs the device outright — no PIN — so it re-confirms the console
password, which the BFF verifies and strips (util/confirm.ts). Both approve
+14 -5
View File
@@ -12,8 +12,8 @@ import {
} from "@/components/ui/dialog";
import { m } from "@/paraglide/messages";
import {
type AccessDraft,
AccessControls,
type AccessDraft,
draftExpirySecs,
GRANT_ALL,
} from "./access";
@@ -45,7 +45,15 @@ export const EditAccessSheet: FC<{
/** Hands off to the existing unpair confirmation. */
onRemove: (fingerprint: string) => void;
isPending: boolean;
}> = ({ target, nowUnix, onCancel, onSave, onExpireNow, onRemove, isPending }) => {
}> = ({
target,
nowUnix,
onCancel,
onSave,
onExpireNow,
onRemove,
isPending,
}) => {
const [draft, setDraft] = useState<AccessDraft>({
grants: GRANT_ALL,
expiry: "keep",
@@ -63,8 +71,7 @@ export const EditAccessSheet: FC<{
});
}, [target]);
const expired =
target?.expiresUnix != null && target.expiresUnix <= nowUnix;
const expired = target?.expiresUnix != null && target.expiresUnix <= nowUnix;
const save = () => {
if (!target) return;
@@ -93,7 +100,9 @@ export const EditAccessSheet: FC<{
<DialogHeader>
<DialogTitle>{m.access_edit_title()}</DialogTitle>
<DialogDescription>
{m.access_edit_desc({ name: target.name || target.fingerprint.slice(0, 16) })}
{m.access_edit_desc({
name: target.name || target.fingerprint.slice(0, 16),
})}
</DialogDescription>
</DialogHeader>
@@ -19,8 +19,8 @@ import { Label } from "@/components/ui/label";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import {
type AccessDraft,
AccessControls,
type AccessDraft,
draftExpirySecs,
GRANT_ALL,
} from "./access";
+1 -2
View File
@@ -421,8 +421,7 @@ export const PairedDevices: FC<{
size="icon"
aria-label={m.action_unpair()}
disabled={
isUnpairingAll ||
pendingFingerprint === r.fingerprint
isUnpairingAll || pendingFingerprint === r.fingerprint
}
onClick={() => onUnpair(r.protocol, r.fingerprint)}
>
+9 -7
View File
@@ -131,10 +131,7 @@ export const draftFromStored = (
export const useNowUnix = (stepMs = 30_000): number => {
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const t = setInterval(
() => setNow(Math.floor(Date.now() / 1000)),
stepMs,
);
const t = setInterval(() => setNow(Math.floor(Date.now() / 1000)), stepMs);
return () => clearInterval(t);
}, [stepMs]);
return now;
@@ -142,7 +139,8 @@ export const useNowUnix = (stepMs = 30_000): number => {
/** Remaining seconds → a short "left" label ("2 h left"); the caller has ruled out ≤ 0. */
export const fmtRemaining = (secs: number): string => {
if (secs >= 48 * 3600) return m.access_left_days({ d: Math.round(secs / 86400) });
if (secs >= 48 * 3600)
return m.access_left_days({ d: Math.round(secs / 86400) });
if (secs >= 3600) return m.access_left_hours({ h: Math.round(secs / 3600) });
if (secs >= 60) return m.access_left_minutes({ min: Math.ceil(secs / 60) });
return m.access_left_under_minute();
@@ -270,7 +268,9 @@ export const AccessControls: FC<{
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-expires`}>{m.access_expires_label()}</Label>
<Label htmlFor={`${idPrefix}-expires`}>
{m.access_expires_label()}
</Label>
<Select
value={value.expiry}
onValueChange={(expiry) =>
@@ -284,7 +284,9 @@ export const AccessControls: FC<{
{allowKeepExpiry && (
<SelectItem value="keep">{m.access_expires_keep()}</SelectItem>
)}
<SelectItem value="forever">{m.access_expires_forever()}</SelectItem>
<SelectItem value="forever">
{m.access_expires_forever()}
</SelectItem>
<SelectItem value="1h">{m.access_expires_1h()}</SelectItem>
<SelectItem value="4h">{m.access_expires_4h()}</SelectItem>
<SelectItem value="8h">{m.access_expires_8h()}</SelectItem>
+4 -1
View File
@@ -1,7 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MoonlightPairing } from "@/sections/Pairing/MoonlightPairingCard";
import { NativePairingCard } from "@/sections/Pairing/NativePairingCard";
import { PairedDevices, type PairedRow } from "@/sections/Pairing/PairedDevices";
import {
PairedDevices,
type PairedRow,
} from "@/sections/Pairing/PairedDevices";
import { PendingDevices } from "@/sections/Pairing/PendingDevices";
import { PairingView } from "@/sections/Pairing/view";
import {
+4 -1
View File
@@ -1,7 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ApproveDialog } from "@/sections/Pairing/ApproveDialog";
import { EditAccessSheet } from "@/sections/Pairing/EditAccessSheet";
import { PairedDevices, type PairedRow } from "@/sections/Pairing/PairedDevices";
import {
PairedDevices,
type PairedRow,
} from "@/sections/Pairing/PairedDevices";
import {
accessNowUnix,
nativeClients,