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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user