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:
morgmart
2026-07-18 08:45:41 -07:00
committed by GitHub
parent 89bcbd826b
commit 1bdd938491
16 changed files with 500 additions and 73 deletions

View File

@@ -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",

View 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>
);
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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 = ({

View File

@@ -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, {

View 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",
);
});

View File

@@ -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", {

View 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);
});

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();