A pre-release sweep of the management console for two things that no type check
and no diff can catch: primitives that were never @unom/ui's, and animation
that a nested motion parent quietly cancelled.
THE PRESET TILES ALL LANDED ON THE SAME FRAME. @unom/ui's <Section> sets
`delayChildren: stagger(...)`, so a page whose cards are direct descendants of
it staggers for free — which is why every page but one looked right. An
<AnimatedCard> is ALSO a motion element and sets no `delayChildren`, and the
Virtual displays preset tiles are cards nested INSIDE that page's config card,
so that card became their timing group. Measured in a headless browser: the
opacity spread between the first and last tile was 0.00 across the whole
animation (six tiles in lockstep), and is 0.98 now — a ~100 ms cascade matching
the rest of the console. The four hand-rolled copies of the stagger container
collapse into one `<Stagger>` that carries the explanation.
FIVE FILES IMPORTED THE WRONG BUTTON. `@unom/ui/button` exports both a plain
`Button` and the `AnimatedButton` that this console's wrapper re-exports under
the same name — so `import { Button } from "@unom/ui/button"` compiles, renders,
and silently opts out of the mount animation and the hover/tap response.
Displays, SessionGame, GPU, Update and PendingDevices had dead buttons sitting
next to live ones.
THREE PRIMITIVES HAD NO WRAPPER, SO NOBODY REACHED FOR THEM. @unom/ui ships
form/select, form/textarea and form/checkbox; components/ui did not, and the
gap was filled with browser-chrome `<select>`, `<textarea>` and
`<input type="checkbox">` in the add-hook modal and both library forms. Select
needs the same token correction Tabs needed — upstream `text-secondary` is a
text colour, but here `--secondary` is a SURFACE, so the trigger's chevron and
placeholder rendered at near-zero contrast on the card behind them.
The hook timeout also stops accepting a value the host rejects: `min`/`max` on
a controlled `<input type="number">` are decoration (no form validation ever
runs), so 900 went into a field capped at 600 and failed later, at run time.
@unom/ui's InputNumber clamps on blur and lets the field be empty while you
retype instead of snapping to the fallback.
Storybook gains the page that had no story at all — the console's largest
config surface, and the reason this shipped unseen. Its <Card> wrapper is load
bearing: it reproduces the motion nesting that IS the bug.
223 lines
7.6 KiB
TypeScript
223 lines
7.6 KiB
TypeScript
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
|
|
import { type FC, useEffect, useState } from "react";
|
|
import type { StoreEntry } from "@/api/store";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { m } from "@/paraglide/messages";
|
|
|
|
// Friction proportional to trust. The three install paths deliberately do NOT share a confirm
|
|
// button: a verified install is one click on a plain dialog, an external one makes you read that
|
|
// unom didn't review the code and names who did curate it, and a raw package spec makes you retype
|
|
// the spec and tick a box. The last one is never reachable by accident — no card, no button on a
|
|
// listing, only the small footer link on Browse.
|
|
|
|
/**
|
|
* Tiers 1 and 2: install a curated catalog entry. Verified entries get an ordinary confirm; an
|
|
* entry from an operator-added source gets the warning treatment and names its source, because
|
|
* "pinned and integrity-checked" is not the same claim as "reviewed".
|
|
*/
|
|
export const InstallDialog: FC<{
|
|
/** The entry being confirmed, or null when the dialog is closed. */
|
|
entry: StoreEntry | null;
|
|
onCancel: () => void;
|
|
onConfirm: (entry: StoreEntry) => void;
|
|
isPending: boolean;
|
|
}> = ({ entry, onCancel, onConfirm, isPending }) => {
|
|
const external = entry?.tier === "external";
|
|
return (
|
|
<Dialog open={entry !== null} onOpenChange={(open) => !open && onCancel()}>
|
|
{entry && (
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
{external ? (
|
|
<ShieldQuestion className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
|
) : (
|
|
<BadgeCheck className="size-5 shrink-0 text-[var(--success)]" />
|
|
)}
|
|
{external
|
|
? m.store_install_external_title({ title: entry.title })
|
|
: m.store_install_title({ title: entry.title })}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{external
|
|
? m.store_install_external_body({
|
|
version: entry.version,
|
|
source: entry.source,
|
|
})
|
|
: m.store_install_verified_body({ version: entry.version })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
|
{entry.pkg}@{entry.version}
|
|
</p>
|
|
|
|
{external && (
|
|
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
|
|
{m.store_install_external_note()}
|
|
</p>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onCancel} disabled={isPending}>
|
|
{m.common_cancel()}
|
|
</Button>
|
|
{/* Red is reserved for the raw-spec install; an external one escalates through
|
|
its copy and its amber panel, not by borrowing the danger colour. */}
|
|
<Button disabled={isPending} onClick={() => onConfirm(entry)}>
|
|
{external
|
|
? m.store_install_external_confirm()
|
|
: m.store_install_confirm()}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Tier 3: install a raw package spec. No catalog, no review, no pinning — so the dialog spells out
|
|
* exactly what that means and asks for two independent confirmations (retype the spec, tick the
|
|
* box) before it will even enable its button.
|
|
*/
|
|
export const SpecInstallDialog: FC<{
|
|
open: boolean;
|
|
onCancel: () => void;
|
|
onConfirm: (spec: string, password: string) => void;
|
|
isPending: boolean;
|
|
/** Set when the BFF rejected the password (401), so the dialog can say so and stay open. */
|
|
wrongPassword?: boolean;
|
|
}> = ({ open, onCancel, onConfirm, isPending, wrongPassword }) => {
|
|
const [spec, setSpec] = useState("");
|
|
const [echo, setEcho] = useState("");
|
|
const [accepted, setAccepted] = useState(false);
|
|
const [password, setPassword] = useState("");
|
|
|
|
// Every confirmation is cleared on exit, cancel AND confirm alike: reopening this dialog must
|
|
// never find it pre-armed with the last spec, a ticked box, or a typed password.
|
|
//
|
|
// Clearing hangs off `open` rather than off the two exit paths, because only one of them runs in
|
|
// this component: cancel goes through `onCancel`, but SUCCESS is the parent flipping `open`, and
|
|
// the dialog stays mounted either way. Setter identities are stable, so the effect needs no other
|
|
// dependency — a `reset()` helper in the list would be a new function every render.
|
|
useEffect(() => {
|
|
if (open) return;
|
|
setSpec("");
|
|
setEcho("");
|
|
setAccepted(false);
|
|
setPassword("");
|
|
}, [open]);
|
|
|
|
const wanted = spec.trim();
|
|
// Every gate must pass: the retyped spec matches exactly, the box is ticked, and the console
|
|
// password is re-entered (the BFF verifies it — a session cookie alone must not run new code).
|
|
const ready =
|
|
wanted.length > 0 &&
|
|
echo.trim() === wanted &&
|
|
accepted &&
|
|
password.length > 0;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
|
<DialogContent className="max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<ShieldAlert className="size-5 shrink-0 text-destructive" />
|
|
{m.store_spec_title()}
|
|
</DialogTitle>
|
|
<DialogDescription>{m.store_spec_lead()}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
{m.store_spec_permanent()}
|
|
</p>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="store-spec">{m.store_spec_field()}</Label>
|
|
<Input
|
|
id="store-spec"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
placeholder="@scope/plugin-name@1.2.3"
|
|
value={spec}
|
|
onChange={(e) => setSpec(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{m.store_spec_field_help()}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="store-spec-echo">
|
|
{m.store_spec_confirm_field()}
|
|
</Label>
|
|
<Input
|
|
id="store-spec-echo"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
value={echo}
|
|
onChange={(e) => setEcho(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<Label className="flex items-start gap-3 text-sm font-normal">
|
|
<Checkbox
|
|
checked={accepted}
|
|
onCheckedChange={(next) => setAccepted(next === true)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span>{m.store_spec_checkbox()}</span>
|
|
</Label>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="store-spec-password">{m.store_spec_password()}</Label>
|
|
<Input
|
|
id="store-spec-password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{m.store_spec_password_help()}
|
|
</p>
|
|
{wrongPassword && (
|
|
<p role="alert" className="text-xs text-destructive">
|
|
{m.update_apply_wrong_password()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onCancel} disabled={isPending}>
|
|
{m.common_cancel()}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={!ready || isPending}
|
|
onClick={() => {
|
|
// Keep the dialog's state until the call settles: a rejected password must
|
|
// leave the operator's typed spec in place, not make them start over.
|
|
onConfirm(wanted, password);
|
|
}}
|
|
>
|
|
{m.store_spec_confirm()}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|