fix(web): a newly installed plugin shows up on its own, and the display form can be used without a mouse

Installing a plugin left the sidebar unchanged until a reload. The reason is
timing, not caching: the host restarts the scripting runner AFTER the job reports
done, and the plugin only registers its UI once that comes back — several seconds
later, by which point the one-shot invalidation had already run and found the old
list. The nav then waited out the 30 s idle poll, which in practice meant "until
I reloaded". Anything that changes the installed set now switches the directory
to a 2 s poll for a minute, so the entry lands about a second after the plugin
actually comes up. Measured end to end in a browser: 29 s → 7 s, with the plugin
registering at 6 s.

The plugin entries also never animated. They are rendered outside the `motion.nav`
that carries the variants and the stagger, so they inherited neither and simply
appeared — most visibly in exactly the case above, where one shows up in a nav
that is already on screen. They get their own animation container now, matching
the main nav. (A motion-wrapped div around the link, not `motion(Link)`, which
erases TanStack's typed `params`.)

The accessibility pass on the display form, where the console's densest controls
live:

- The Custom block's numeric inputs had a `<label>` with no `htmlFor` next to an
  `<input>` with no `id`, which labels nothing at all — a screen reader announced
  them as unnamed spin buttons. Single controls are paired properly now; the
  button groups became real `<fieldset>`/`<legend>`, which is what they are.
- Every option group signalled its active choice with fill colour alone. They
  carry `aria-pressed` now, so the state is available to assistive tech and not
  only to people who can compare two button variants.
- `QueryState`'s error branch is a live region, so a query that fails announces
  the failure instead of silently swapping one region for another.
- Motion honours `prefers-reduced-motion` instead of overriding it.
- `<html lang>` follows the locale instead of claiming "en" while the app renders
  German. Verified: switching to de flips the attribute.
- "Close menu", "Language" and "Loading" went through the message catalogue.

Also: ten dead message keys removed (a whole removed Clients page and the old
Settings token field), and the README no longer tells operators to set the
management token under "Settings → API token" — that field is gone and the token
has been server-side only for some time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:20:10 +02:00
co-authored by Claude Opus 5
parent 9e505aba41
commit de17ceb8f8
11 changed files with 176 additions and 75 deletions
+44 -19
View File
@@ -143,32 +143,57 @@ function PluginNavSection() {
const plugins = uiPlugins(data);
if (plugins.length === 0) return null;
return (
<div className="mt-6 flex flex-col gap-1">
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
// Its own animation container, with the same variants + stagger as the main nav above. These
// were plain links: the group sits OUTSIDE that `motion.nav`, so it inherited neither the
// stagger nor the variants and plugin entries simply appeared. They arrive asynchronously
// (and a fresh install adds one to a nav that is already on screen), which is exactly when
// the animation earns its keep.
<motion.div
animate="enter"
initial="from"
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="mt-6 flex flex-col gap-1"
>
<motion.p
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
>
{m.nav_plugins()}
</p>
</motion.p>
{plugins.map((p) => {
const Icon = pluginIcon(p.ui?.icon);
return (
<Link
// The motion wrapper is a DIV around the link, not `motion(Link)`: wrapping Link
// erases TanStack's typed `params`, and these entries need `$pluginId`.
<motion.div
key={p.id}
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
variants={{
from: { opacity: 0, x: -20 },
enter: { opacity: 1, x: 0 },
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
<Link
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
}}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
</motion.div>
);
})}
</div>
</motion.div>
);
}
@@ -196,7 +221,7 @@ function MobileNav() {
{moreOpen && (
<button
type="button"
aria-label="Close menu"
aria-label={m.nav_close_menu()}
className="fixed inset-0 z-40 bg-black/40 sm:hidden"
onClick={() => setMoreOpen(false)}
/>
@@ -277,7 +302,7 @@ function LanguageSwitcher() {
const current = useLocale();
return (
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
<div className="flex gap-1" role="group" aria-label="Language">
<div className="flex gap-1" role="group" aria-label={m.settings_language()}>
{locales.map((l: Locale) => (
<button
key={l}
+7 -1
View File
@@ -32,7 +32,13 @@ export function QueryState({
if (error) {
const unauthorized = error instanceof ApiError && error.status === 401;
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
// `role="alert"` so the failure is announced. The loading branch above already has
// role="status"; without this, a query that resolved into an error swapped one silent
// region for another and a screen-reader user was told only that loading had stopped.
<div
role="alert"
className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm"
>
<p className="font-medium text-destructive">
{unauthorized ? m.common_unauthorized() : m.common_error()}
</p>
+2 -1
View File
@@ -1,6 +1,7 @@
import { motion, useReducedMotion, useTime, useTransform } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
// The punktfunk lens, alive. The two overlapping circles of the brand mark are
// recreated from divs and animated as if orbiting on a path whose long axis points
@@ -76,7 +77,7 @@ export function Spinner({
<div
ref={ref}
role="status"
aria-label="Loading"
aria-label={m.common_loading()}
className={cn("relative inline-block size-6 isolate", className)}
{...props}
>