mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Add identity key help and reveal toggle to machine onboarding (#2064)
Signed-off-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -21,6 +21,8 @@ export default defineConfig({
|
||||
testMatch: [
|
||||
"**/smoke.spec.ts",
|
||||
"**/onboarding-docked-cta-screenshots.spec.ts",
|
||||
"**/identity-key-help.spec.ts",
|
||||
"**/key-import-reveal.spec.ts",
|
||||
"**/navigation.spec.ts",
|
||||
"**/channels.spec.ts",
|
||||
"**/channel-shared-header-backdrop.spec.ts",
|
||||
|
||||
103
desktop/src/features/onboarding/ui/IdentityKeyHelpDialog.tsx
Normal file
103
desktop/src/features/onboarding/ui/IdentityKeyHelpDialog.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { ONBOARDING_INK_ICON_CLASS } from "./OnboardingChrome";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
|
||||
const IDENTITY_KEY_HELP_SEEN_STORAGE_KEY =
|
||||
"buzz.machine-onboarding.identity-key-help-seen.v1";
|
||||
const IDENTITY_KEY_HELP_DELAY_MS = 2_000;
|
||||
|
||||
function hasSeenIdentityKeyHelp(): boolean {
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem(IDENTITY_KEY_HELP_SEEN_STORAGE_KEY) === "true"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberIdentityKeyHelpSeen() {
|
||||
try {
|
||||
window.localStorage.setItem(IDENTITY_KEY_HELP_SEEN_STORAGE_KEY, "true");
|
||||
} catch {
|
||||
// The help remains available for this visit if storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export function IdentityKeyHelpDialog() {
|
||||
const [isVisible, setIsVisible] = React.useState(hasSeenIdentityKeyHelp);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isVisible) return;
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
rememberIdentityKeyHelpSeen();
|
||||
setIsVisible(true);
|
||||
}, IDENTITY_KEY_HELP_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<OnboardingFooter className="max-w-none">
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className={`text-foreground/70 transition-opacity duration-300 hover:text-foreground motion-reduce:transition-none ${
|
||||
isVisible ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
data-testid="identity-key-help-trigger"
|
||||
tabIndex={isVisible ? 0 : -1}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
What’s an identity key?
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</OnboardingFooter>
|
||||
<DialogContent
|
||||
className="buzz-onboarding-neutral-theme max-w-[47.5rem] -translate-y-5"
|
||||
closeButtonClassName={ONBOARDING_INK_ICON_CLASS}
|
||||
data-testid="identity-key-help-dialog"
|
||||
overlayVariant="transparent"
|
||||
surface="textured"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[35rem] py-14 text-left max-sm:py-6">
|
||||
<DialogTitle className="text-balance pr-8 text-3xl font-normal text-foreground">
|
||||
What’s an identity key?
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
asChild
|
||||
className="mt-6 space-y-4 text-pretty text-base leading-7 text-[color:var(--buzz-onboarding-backup-ink)]"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
Buzz uses an identity key instead of a traditional account. It’s
|
||||
created on your device and represents you whenever you use Buzz.
|
||||
</p>
|
||||
<p>
|
||||
Your identity belongs to you, not Buzz. There’s no password to
|
||||
reset, and Buzz can’t recover your key if you lose it. Keep a
|
||||
backup somewhere safe and never share it. Anyone with your key
|
||||
can act as you.
|
||||
</p>
|
||||
<p>
|
||||
If you’re new to Buzz, create a new identity key. If you already
|
||||
have a Nostr identity, use your existing key.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Button } from "@/shared/ui/button";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import { BackupStep } from "./BackupStep";
|
||||
import { DefaultConfigStep } from "./DefaultConfigStep";
|
||||
import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
|
||||
import { LandingBees } from "./LandingBees";
|
||||
import { NostrKeyImportForm } from "./NostrKeyImportForm";
|
||||
import {
|
||||
@@ -154,7 +155,7 @@ export function MachineOnboardingFlow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 text-foreground ${
|
||||
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-x-hidden overflow-y-auto px-4 text-foreground ${
|
||||
page === "identity"
|
||||
? "buzz-onboarding-welcome py-8"
|
||||
: "pb-28 pt-[106px]"
|
||||
@@ -200,7 +201,7 @@ export function MachineOnboardingFlow({
|
||||
onClick={() => void loadFreshIdentity()}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Saving identity…" : "Get started"}
|
||||
{isPending ? "Saving identity…" : "Create a new identity key"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
|
||||
@@ -209,9 +210,10 @@ export function MachineOnboardingFlow({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Enter a key
|
||||
Use an existing key
|
||||
</Button>
|
||||
</div>
|
||||
<IdentityKeyHelpDialog />
|
||||
</OnboardingSlideTransition>
|
||||
) : page === "key-import" ? (
|
||||
<OnboardingSlideTransition
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Check, KeyRound } from "lucide-react";
|
||||
import { Check, Eye, EyeOff, KeyRound } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { nsecToNpub } from "@/shared/lib/nostrUtils";
|
||||
@@ -7,7 +7,10 @@ import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
import {
|
||||
ONBOARDING_INK_ICON_CLASS,
|
||||
ONBOARDING_PRIMARY_CTA_CLASS,
|
||||
} from "./OnboardingChrome";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
|
||||
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
|
||||
@@ -41,11 +44,21 @@ export function NostrKeyImportForm({
|
||||
const [isImporting, setIsImporting] = React.useState(false);
|
||||
const [importError, setImportError] = React.useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [isRevealed, setIsRevealed] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const previewNpub = React.useMemo(() => nsecToNpub(nsecInput), [nsecInput]);
|
||||
const trimmedInput = nsecInput.trim();
|
||||
const hasInput = trimmedInput.length > 0;
|
||||
|
||||
// Masked-by-default must re-assert whenever the field empties: a sticky
|
||||
// reveal from a previous key must never apply to newly pasted content the
|
||||
// user hasn't chosen to expose.
|
||||
React.useEffect(() => {
|
||||
if (!hasInput) {
|
||||
setIsRevealed(false);
|
||||
}
|
||||
}, [hasInput]);
|
||||
const isValid = previewNpub !== null;
|
||||
const isInteractionDisabled = disabled || isImporting;
|
||||
const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5;
|
||||
@@ -143,22 +156,52 @@ export function NostrKeyImportForm({
|
||||
data-testid="nostr-import-card"
|
||||
variant="textured"
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="h-[3.6875rem] rounded-none border-0 bg-transparent px-0 text-center font-mono !text-4xl shadow-none placeholder:text-foreground/30 focus-visible:ring-0"
|
||||
data-testid="nostr-import-nsec-input"
|
||||
id="nostr-private-key"
|
||||
onChange={(event) => {
|
||||
setNsecInput(event.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
placeholder="Enter your key here"
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value={nsecInput}
|
||||
/>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
// Symmetric px reserves the absolutely positioned toggle's
|
||||
// footprint on BOTH sides, so the centered key text never
|
||||
// runs under the eye control and stays optically centered.
|
||||
className="h-[3.6875rem] rounded-none border-0 bg-transparent px-10 text-center font-mono !text-4xl text-[color:var(--buzz-onboarding-backup-ink)] shadow-none placeholder:text-foreground/30 focus-visible:ring-0"
|
||||
data-testid="nostr-import-nsec-input"
|
||||
id="nostr-private-key"
|
||||
onChange={(event) => {
|
||||
setNsecInput(event.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
placeholder="Enter your key here"
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
type={isRevealed ? "text" : "password"}
|
||||
value={nsecInput}
|
||||
/>
|
||||
{/* Absolutely positioned so appearing/disappearing never resizes
|
||||
the input or shifts its centered text; fades with hasInput. */}
|
||||
<Button
|
||||
aria-hidden={!hasInput}
|
||||
aria-label={
|
||||
isRevealed ? "Hide private key" : "Reveal private key"
|
||||
}
|
||||
className={cn(
|
||||
ONBOARDING_INK_ICON_CLASS,
|
||||
"absolute -right-2 top-1/2 h-10 w-10 -translate-y-1/2 transition-opacity duration-300 motion-reduce:transition-none",
|
||||
hasInput ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
data-testid="nostr-import-reveal-toggle"
|
||||
onClick={() => setIsRevealed((current) => !current)}
|
||||
size="icon"
|
||||
tabIndex={hasInput ? 0 : -1}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? (
|
||||
<EyeOff aria-hidden="true" className="h-6 w-6" />
|
||||
) : (
|
||||
<Eye aria-hidden="true" className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Input
|
||||
@@ -270,25 +313,42 @@ export function NostrKeyImportForm({
|
||||
data-testid="nostr-import-feedback"
|
||||
>
|
||||
{previewNpub ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs"
|
||||
data-testid="nostr-import-npub-preview"
|
||||
>
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="font-medium text-foreground">
|
||||
This will use this Nostr identity:
|
||||
variant === "spotlight" ? (
|
||||
// Spotlight uses the backup step's quiet caption language:
|
||||
// centered, unboxed, with the npub in the shared olive key ink.
|
||||
<div
|
||||
className="space-y-1 text-sm"
|
||||
data-testid="nostr-import-npub-preview"
|
||||
>
|
||||
<p className="flex items-center justify-center gap-1.5 text-foreground">
|
||||
<Check aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
Nostr identity found
|
||||
</p>
|
||||
<p className="break-all font-mono text-2xs text-muted-foreground">
|
||||
<p className="break-all font-mono text-[color:var(--buzz-onboarding-backup-ink)]">
|
||||
{previewNpub}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs"
|
||||
data-testid="nostr-import-npub-preview"
|
||||
>
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="font-medium text-foreground">
|
||||
This will use this Nostr identity:
|
||||
</p>
|
||||
<p className="break-all font-mono text-2xs text-muted-foreground">
|
||||
{previewNpub}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{showInvalidHint && !errorMessage ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Waiting for a valid nsec1 key.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Waiting for a valid nsec1 key
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -24,6 +24,16 @@ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(-
|
||||
*/
|
||||
export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`;
|
||||
|
||||
/**
|
||||
* Icon-control styling for onboarding surfaces that sit on the textured card:
|
||||
* olive backup ink (`--buzz-onboarding-backup-ink`) with a plain
|
||||
* brighten-to-foreground hover (no hover pill, which would read as a floating
|
||||
* box on the texture). Used by the identity-help dialog close button and the
|
||||
* key-import reveal toggle.
|
||||
*/
|
||||
export const ONBOARDING_INK_ICON_CLASS =
|
||||
"text-[color:var(--buzz-onboarding-backup-ink)] hover:bg-transparent hover:text-foreground";
|
||||
|
||||
/**
|
||||
* Shared onboarding chrome shown on every page after the landing screen: a
|
||||
* static Buzz mark pinned to the top-left, and a centered pagination track that
|
||||
|
||||
@@ -7,7 +7,30 @@
|
||||
* fixed and repeats edge texture while the white center fills any card size.
|
||||
*/
|
||||
.buzz-card-textured {
|
||||
/*
|
||||
* The asset has three zones, from outside in:
|
||||
* 1. transparent powder bleed (outside the layout box, via outset)
|
||||
* 2. feathered fade (inside the layout edge, white is NOT fully opaque)
|
||||
* 3. solid white center (safe for content)
|
||||
*
|
||||
* `--buzz-card-textured-safe-inset` is the distance from the layout edge
|
||||
* to zone 3. `Card variant="textured"` applies it as default padding so
|
||||
* content always lands on solid fill. Features may add more padding,
|
||||
* never less — and must never layer an opaque surface on top of the
|
||||
* texture; the baked white center IS the surface.
|
||||
*/
|
||||
--buzz-card-textured-safe-inset: 5rem;
|
||||
border: 0 solid transparent;
|
||||
/*
|
||||
* Each border-image band is 208px wide but extends 96px outside the layout
|
||||
* box (outset), leaving 112px per edge inside it. Below 224px (2 × 112px)
|
||||
* on either axis the opposing bands collapse into each other: the solid
|
||||
* white center region disappears and the bands meet on a semi-transparent
|
||||
* row of the fade, which shows the page background through as a thin seam
|
||||
* line. Enforce the floor so the texture can never degenerate.
|
||||
*/
|
||||
min-height: 224px;
|
||||
min-width: 224px;
|
||||
border-image-source: url("./assets/card-texture.png");
|
||||
border-image-slice: 416 fill;
|
||||
border-image-width: 208px;
|
||||
|
||||
@@ -4,12 +4,39 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import "./card-texture.css";
|
||||
|
||||
/**
|
||||
* `variant="textured"` renders the baked nine-slice powder texture
|
||||
* (`card-texture.css`). The asset bakes the card surface INTO the image:
|
||||
* a solid white center that feathers out into speckle, with a transparent
|
||||
* powder bleed extending 96px beyond the layout box.
|
||||
*
|
||||
* Usage contract:
|
||||
* - The baked white center IS the card surface. Never layer an opaque
|
||||
* background (`bg-card`, `bg-white`, …) on top of the texture — it
|
||||
* covers the feathered edge and reintroduces a visible hard border.
|
||||
* - Default padding is the safe inset (`--buzz-card-textured-safe-inset`),
|
||||
* which keeps content on the fully opaque center. Add more padding as
|
||||
* the content needs; go below it only if the content tolerates sitting
|
||||
* on the semi-transparent fade (e.g. a transparent input).
|
||||
* - The card resizes freely — the nine-slice stretches the solid center
|
||||
* with plain CSS. No image regeneration is ever needed for sizing or
|
||||
* padding changes.
|
||||
* - Give the layout around the card room for the 96px outer bleed; an
|
||||
* `overflow: hidden` ancestor will clip it.
|
||||
* - For modals, use `DialogContent surface="textured"` instead of
|
||||
* composing this by hand — it places the close button inside the
|
||||
* solid center for you.
|
||||
*/
|
||||
const cardVariants = cva("text-card-foreground", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "rounded-xl border border-border/70 bg-card/80 shadow-xs",
|
||||
textured:
|
||||
"buzz-card-textured relative isolate rounded-none border-0 shadow-none",
|
||||
// flex + justify-center: the variant enforces a min size (see
|
||||
// card-texture.css); when that floor stretches the card beyond its
|
||||
// content, the content stays vertically centered instead of pinning
|
||||
// to the top padding edge.
|
||||
"buzz-card-textured relative isolate flex flex-col justify-center rounded-none border-0 p-[var(--buzz-card-textured-safe-inset)] shadow-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import "./card-texture.css";
|
||||
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
|
||||
import {
|
||||
MODAL_CONTENT_MOTION_CLASS,
|
||||
@@ -42,36 +43,93 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> & {
|
||||
/** Extra classes for the built-in close button (e.g. a themed icon color). */
|
||||
closeButtonClassName?: string;
|
||||
overlayVariant?: "default" | "transparent";
|
||||
showCloseButton?: boolean;
|
||||
/**
|
||||
* - `default`: standard opaque dialog panel (rounded, shadowed).
|
||||
* - `none`: no surface — the caller composes its own.
|
||||
* - `textured`: the baked nine-slice powder card (`Card variant="textured"`)
|
||||
* IS the dialog surface. Content and the close button are automatically
|
||||
* kept on the solid center of the texture via its safe inset.
|
||||
*/
|
||||
surface?: "default" | "none" | "textured";
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
|
||||
<DialogPrimitive.Content
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
closeButtonClassName,
|
||||
overlayVariant = "default",
|
||||
showCloseButton = true,
|
||||
surface = "default",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
data-testid="dialog-overlay"
|
||||
className={
|
||||
overlayVariant === "transparent"
|
||||
? "bg-transparent backdrop-blur-none"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto relative grid w-[calc(100vw-2rem)] max-w-2xl gap-4 rounded-2xl bg-background p-6 shadow-2xl outline-hidden",
|
||||
MODAL_CONTENT_MOTION_CLASS,
|
||||
className,
|
||||
"pointer-events-none fixed inset-0 z-50 grid place-items-center overflow-x-hidden overflow-y-auto",
|
||||
// The textured surface bleeds a 96px powder band beyond its layout
|
||||
// box (see card-texture.css). Give the wrapper enough padding that
|
||||
// the bleed isn't clipped by this scroll container; every other
|
||||
// surface keeps the standard gutter.
|
||||
surface === "textured"
|
||||
? "p-[calc(6rem+1rem)] max-sm:p-[calc(6rem-1.5rem)]"
|
||||
: "p-4",
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton ? (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
));
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"pointer-events-auto relative grid w-[calc(100vw-2rem)] max-w-2xl gap-4 outline-hidden",
|
||||
surface === "default" && "rounded-2xl bg-background p-6 shadow-2xl",
|
||||
surface === "none" && "bg-transparent p-0 shadow-none",
|
||||
surface === "textured" &&
|
||||
"buzz-card-textured isolate box-border w-full rounded-none border-0 bg-transparent p-[var(--buzz-card-textured-safe-inset)] shadow-none",
|
||||
MODAL_CONTENT_MOTION_CLASS,
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton ? (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
"absolute flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
|
||||
// On the textured surface the layout edge sits in the powder
|
||||
// fade; dock the close button at the safe-inset corner so it
|
||||
// stays on the solid center of the texture.
|
||||
surface === "textured"
|
||||
? "right-[var(--buzz-card-textured-safe-inset)] top-[var(--buzz-card-textured-safe-inset)] -mr-2 -mt-2"
|
||||
: "right-4 top-4",
|
||||
closeButtonClassName,
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
),
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
|
||||
@@ -118,6 +118,17 @@ export async function load(url, context, nextLoad) {
|
||||
};
|
||||
}
|
||||
|
||||
// Vite handles side-effect CSS imports (e.g. `import "./card-texture.css"`
|
||||
// in shared/ui) at bundle time; node's ESM loader has no CSS support. Serve
|
||||
// them as empty modules so components with style imports stay unit-testable.
|
||||
if (url.endsWith(".css")) {
|
||||
return {
|
||||
format: "module",
|
||||
shortCircuit: true,
|
||||
source: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (url.endsWith(".tsx")) {
|
||||
const source = fs.readFileSync(fileURLToPath(url), "utf8");
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
|
||||
58
desktop/tests/e2e/identity-key-help.spec.ts
Normal file
58
desktop/tests/e2e/identity-key-help.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const HELP_SEEN_KEY = "buzz.machine-onboarding.identity-key-help-seen.v1";
|
||||
|
||||
test("identity key help explains the first-run choice", async ({ page }) => {
|
||||
await installMockBridge(page, undefined, {
|
||||
skipCommunitySeed: true,
|
||||
skipOnboardingSeed: true,
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
const trigger = page.getByTestId("identity-key-help-trigger");
|
||||
// No initial opacity-0 assertion: on a slow runner the 2s reveal timer can
|
||||
// fire before the first assertion runs, failing the test for the wrong
|
||||
// reason. The reveal + persistence assertions below carry the coverage.
|
||||
await expect(trigger).toHaveCSS("opacity", "1", { timeout: 5000 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => localStorage.getItem(key), HELP_SEEN_KEY),
|
||||
)
|
||||
.toBe("true");
|
||||
|
||||
await page.setViewportSize({ width: 720, height: 620 });
|
||||
await trigger.click();
|
||||
|
||||
const dialog = page.getByTestId("identity-key-help-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "What’s an identity key?" }),
|
||||
).toBeVisible();
|
||||
await expect(dialog).toHaveClass(/shadow-none/);
|
||||
await expect(page.getByTestId("dialog-overlay")).toHaveCSS(
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
const dialogWrapper = dialog.locator("..");
|
||||
await expect(dialogWrapper).toHaveCSS("overflow-x", "hidden");
|
||||
const dialogBounds = await dialog.boundingBox();
|
||||
expect(dialogBounds).not.toBeNull();
|
||||
expect(dialogBounds?.x).toBeGreaterThanOrEqual(0);
|
||||
expect(
|
||||
(dialogBounds?.x ?? 0) + (dialogBounds?.width ?? 0),
|
||||
).toBeLessThanOrEqual(720);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(trigger).toHaveCSS("opacity", "1");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("identity-key-help-trigger")).toHaveCSS(
|
||||
"opacity",
|
||||
"1",
|
||||
);
|
||||
});
|
||||
@@ -20,11 +20,10 @@ test("normal first launch uses the already-persisted identity", async ({
|
||||
// Landing carries a subtle dot-grid pattern over the chartreuse fill.
|
||||
await expect(gate).toHaveCSS("background-image", /radial-gradient/);
|
||||
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
|
||||
await expect(page.getByRole("button", { name: "Get started" })).toHaveCSS(
|
||||
"background-color",
|
||||
"rgb(23, 23, 23)",
|
||||
);
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create a new identity key" }),
|
||||
).toHaveCSS("background-color", "rgb(23, 23, 23)");
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
|
||||
68
desktop/tests/e2e/key-import-reveal.spec.ts
Normal file
68
desktop/tests/e2e/key-import-reveal.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SAMPLE_NSEC =
|
||||
"nsec1u70xptkumvfc4k4hu0rc4fnzcexvw63zvq2ng9vmqujsaayhparqu8eju9";
|
||||
|
||||
// --buzz-onboarding-backup-ink (#717106), the olive key ink shared with the
|
||||
// backup step.
|
||||
const BACKUP_INK = "rgb(113, 113, 6)";
|
||||
|
||||
test("key import masks the key with a reveal toggle", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await installMockBridge(page, undefined, {
|
||||
skipCommunitySeed: true,
|
||||
skipOnboardingSeed: true,
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByRole("button", { name: "Use an existing key" }).click();
|
||||
const input = page.getByTestId("nostr-import-nsec-input");
|
||||
await expect(input).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
// Masked by default; no toggle until there is input; key text uses the
|
||||
// shared backup ink.
|
||||
const toggle = page.getByTestId("nostr-import-reveal-toggle");
|
||||
await expect(input).toHaveAttribute("type", "password");
|
||||
await expect(toggle).toHaveCSS("opacity", "0");
|
||||
await expect(input).toHaveCSS("color", BACKUP_INK);
|
||||
|
||||
// The toggle is absolutely positioned: its appearance must not resize the
|
||||
// input or shift the centered text.
|
||||
const widthBefore = await input.evaluate(
|
||||
(el) => el.getBoundingClientRect().width,
|
||||
);
|
||||
await input.fill(SAMPLE_NSEC);
|
||||
await expect(toggle).toHaveCSS("opacity", "1");
|
||||
const widthAfter = await input.evaluate(
|
||||
(el) => el.getBoundingClientRect().width,
|
||||
);
|
||||
expect(widthAfter).toBe(widthBefore);
|
||||
|
||||
// Reveal, then clear: a sticky reveal must never carry over to newly
|
||||
// pasted content, so the next key starts masked again.
|
||||
await toggle.click();
|
||||
await expect(input).toHaveAttribute("type", "text");
|
||||
await input.fill("");
|
||||
await expect(toggle).toHaveCSS("opacity", "0");
|
||||
await input.fill(SAMPLE_NSEC);
|
||||
await expect(input).toHaveAttribute("type", "password");
|
||||
|
||||
// Re-masking via the toggle still works.
|
||||
await toggle.click();
|
||||
await expect(input).toHaveAttribute("type", "text");
|
||||
await toggle.click();
|
||||
await expect(input).toHaveAttribute("type", "password");
|
||||
|
||||
// Narrow viewport: the absolutely positioned toggle must not cause
|
||||
// horizontal overflow.
|
||||
await page.setViewportSize({ width: 720, height: 620 });
|
||||
await waitForAnimations(page);
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBe(false);
|
||||
});
|
||||
@@ -34,7 +34,7 @@ function availableRuntime(
|
||||
async function navigateToSetupPage(
|
||||
page: Parameters<typeof installMockBridge>[0],
|
||||
) {
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
await passThroughBackupStep(page);
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ async function enterMachineBackup(page: import("@playwright/test").Page) {
|
||||
skipOnboardingSeed: true,
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
}
|
||||
|
||||
const SHOTS = "test-results/screenshots-onboarding";
|
||||
@@ -85,7 +85,9 @@ test("backup step back button returns to machine identity choice", async ({
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Get started" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create a new identity key" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,7 +103,7 @@ test("backup step shows error banner and retry button when get_nsec fails", asyn
|
||||
{ skipCommunitySeed: true, skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-load-error")).toBeVisible();
|
||||
@@ -125,7 +127,7 @@ test("backup step retry succeeds and shows key after initial failure", async ({
|
||||
{ skipCommunitySeed: true, skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
|
||||
await expect(page.getByTestId("backup-load-error")).toBeVisible();
|
||||
|
||||
|
||||
@@ -27,26 +27,30 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` });
|
||||
|
||||
await page.getByRole("button", { name: "Enter a key" }).click();
|
||||
await page.getByRole("button", { name: "Use an existing key" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Enter your private key" }),
|
||||
).toBeVisible();
|
||||
const importCard = page.getByTestId("nostr-import-card");
|
||||
await expect(importCard).toBeVisible();
|
||||
await expect(page.getByLabel("Private key")).toBeVisible();
|
||||
await expect(page.getByLabel("Private key", { exact: true })).toBeVisible();
|
||||
// The production card uses a baked nine-slice texture: no runtime SVG
|
||||
// filter, measurement, or texture regeneration during resize.
|
||||
await expect(importCard).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(importCard).toHaveCSS("border-top-width", "0px");
|
||||
await expect(importCard).toHaveCSS("border-image-repeat", "repeat");
|
||||
await expect(importCard).toHaveCSS("border-image-outset", "96px");
|
||||
await expect(importCard.locator("svg")).toHaveCount(0);
|
||||
// Icon SVGs (e.g. the reveal toggle) are fine; a filter would mean the
|
||||
// texture regressed to the runtime SVG pipeline.
|
||||
await expect(importCard.locator("svg filter")).toHaveCount(0);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/01b-enter-key.png` });
|
||||
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expect(page.getByRole("button", { name: "Get started" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create a new identity key" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "Your unique identity has been created",
|
||||
@@ -78,10 +82,10 @@ test("machine key import remains usable in a short viewport", async ({
|
||||
skipOnboardingSeed: true,
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Enter a key" }).click();
|
||||
await page.getByRole("button", { name: "Use an existing key" }).click();
|
||||
|
||||
const heading = page.getByRole("heading", { name: "Enter your private key" });
|
||||
const input = page.getByLabel("Private key");
|
||||
const input = page.getByLabel("Private key", { exact: true });
|
||||
const footer = page.getByTestId("onboarding-footer-slot");
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
@@ -572,7 +572,7 @@ test("first-launch key import continues to machine setup", async ({ page }) => {
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByRole("button", { name: "Enter a key" }).click();
|
||||
await page.getByRole("button", { name: "Use an existing key" }).click();
|
||||
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
|
||||
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
|
||||
await page.getByTestId("nostr-import-submit").click();
|
||||
|
||||
Reference in New Issue
Block a user