Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions desktop/scripts/texture-card/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Textured Card asset

`Card variant="textured"` renders a cheap CSS nine-slice PNG at runtime. This
folder preserves the procedural SVG recipe used to generate that asset.

## Regenerate

From `desktop/`:

```bash
pnpm exec node scripts/texture-card/generate-card-texture.mjs
```

This overwrites:

```text
src/shared/ui/assets/card-texture.png
```

The generator renders the archived SVG filter in headless Chromium at 2× DPR,
then captures a transparent PNG. It is a development tool only and is not
included in the production bundle.

After changing texture parameters:

1. Regenerate the asset.
2. Compare the onboarding private-key card at its normal size.
3. Check a tall/narrow textured Card and the smallest supported shape.
4. Check both Retina and standard-density displays.
5. Update the slice/outset values in `card-texture.css` only if the generated
geometry changed.

The runtime component API remains `Card variant="textured"`; feature code owns
padding, dimensions, typography, and placement.
94 changes: 94 additions & 0 deletions desktop/scripts/texture-card/generate-card-texture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Generates the baked nine-slice texture used by Card variant="textured".
*
* This file is the source of truth for the procedural visual. It deliberately
* lives outside runtime code: edit the parameters below, run this script, then
* visually compare the generated asset before committing it.
*/
import { chromium } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUTPUT = path.resolve(
HERE,
"../../src/shared/ui/assets/card-texture.png",
);

// CSS-pixel source geometry. Screenshotting at DPR 2 produces a crisp asset.
const CARD_SIZE = 640;
const OUTSET = 96;
const CAPTURE_SIZE = CARD_SIZE + OUTSET * 2;
const DPR = 2;

// Approved texture parameters, archived from the former runtime SVG filter.
const BLUR = 66;
const DILATE = Math.round(BLUR * 0.85);
const THRESHOLD_BIAS = 0.302;
const SLOPE = 8;
const FREQUENCY = 0.999;
const OCTAVES = 3;
const SEED = 5315;

await mkdir(path.dirname(OUTPUT), { recursive: true });

const browser = await chromium.launch();
try {
const page = await browser.newPage({
deviceScaleFactor: DPR,
viewport: { height: CAPTURE_SIZE, width: CAPTURE_SIZE },
});

await page.setContent(`<!doctype html>
<style>
html, body { margin: 0; width: 100%; height: 100%; background: transparent; }
#stage { position: relative; width: ${CAPTURE_SIZE}px; height: ${CAPTURE_SIZE}px; }
#core {
position: absolute;
inset: ${OUTSET + BLUR / 2}px;
background: white;
filter: blur(${BLUR / 3}px);
}
</style>
<div id="stage">
<svg width="${CAPTURE_SIZE}" height="${CAPTURE_SIZE}" aria-hidden="true">
<defs>
<filter id="texture" x="0" y="0" width="100%" height="100%"
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="${DILATE}" result="squared" />
<feGaussianBlur in="squared" stdDeviation="${BLUR}" result="ramp" />
<feTurbulence type="fractalNoise" baseFrequency="${FREQUENCY}"
numOctaves="${OCTAVES}" seed="${SEED}" result="grain" />
<feColorMatrix in="grain" result="grainAlpha" type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
1 0 0 0 0" />
<feComposite in="ramp" in2="grainAlpha" operator="arithmetic"
k1="0" k2="1" k3="-1" k4="${-THRESHOLD_BIAS}" result="dithered" />
<feComponentTransfer in="dithered" result="specks">
<feFuncA type="linear" slope="${SLOPE}" intercept="0" />
</feComponentTransfer>
<feFlood flood-color="white" result="white" />
<feComposite in="white" in2="specks" operator="in" />
</filter>
</defs>
<rect x="${OUTSET}" y="${OUTSET}" width="${CARD_SIZE}" height="${CARD_SIZE}"
fill="white" filter="url(#texture)" />
</svg>
<span id="core"></span>
</div>`);

await page.locator("#stage").screenshot({
omitBackground: true,
path: OUTPUT,
});
} finally {
await browser.close();
}

console.log(`Generated ${OUTPUT}`);
console.log(`Asset: ${CAPTURE_SIZE * DPR}×${CAPTURE_SIZE * DPR}px @${DPR}x`);
console.log(`Runtime slice: ${(OUTSET + 112) * DPR}px; outset: ${OUTSET}px`);
44 changes: 25 additions & 19 deletions desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,28 +215,34 @@ export function MachineOnboardingFlow({
</OnboardingSlideTransition>
) : page === "key-import" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[640px] flex-col items-center text-center"
className="flex min-h-[calc(100dvh-13.25rem)] w-full max-w-[837px] flex-col items-center text-center"
direction="forward"
transitionKey="machine-key-import"
>
<h1 className="text-title font-normal text-foreground">
{identityLost ? "Re-import your key" : "Enter your private key"}
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
{identityLost
? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
: "If you already have a Nostr account, enter your private key below to get started."}
</p>
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : "Back"}
onBack={
identityLost
? () => void replaceLostIdentity()
: () => setPage("identity")
}
onImport={importExistingIdentity}
variant="spotlight"
/>
<div className="shrink-0">
<h1 className="text-title font-normal text-foreground">
{identityLost
? "Re-import your key"
: "Enter your private key"}
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
{identityLost
? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
: "If you already have a Nostr account, enter your private key below to get started."}
</p>
</div>
<div className="buzz-onboarding-key-import-position w-full">
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : "Back"}
onBack={
identityLost
? () => void replaceLostIdentity()
: () => setPage("identity")
}
onImport={importExistingIdentity}
variant="spotlight"
/>
</div>
</OnboardingSlideTransition>
) : page === "backup" ? (
<BackupStep
Expand Down
128 changes: 77 additions & 51 deletions desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Check, KeyRound } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { nsecToNpub } from "@/shared/lib/nostrUtils";
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";
Expand Down Expand Up @@ -127,36 +128,56 @@ export function NostrKeyImportForm({
}}
>
<div className="space-y-1.5 text-left">
{variant === "spotlight" ? null : (
<label
className="text-sm font-medium text-foreground"
htmlFor="nostr-private-key"
<label
className={cn(
"text-sm font-medium text-foreground",
variant === "spotlight" && "sr-only",
)}
htmlFor="nostr-private-key"
>
Private key
</label>
{variant === "spotlight" ? (
<Card
className="w-full px-8 py-12"
data-testid="nostr-import-card"
variant="textured"
>
Private key
</label>
<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}
/>
</Card>
) : (
<Input
autoComplete="off"
autoCorrect="off"
className="h-10 bg-background"
data-testid="nostr-import-nsec-input"
id="nostr-private-key"
onChange={(event) => {
setNsecInput(event.target.value);
setImportError(null);
}}
placeholder="nsec1..."
ref={inputRef}
spellCheck={false}
type="password"
value={nsecInput}
/>
)}
<Input
autoComplete="off"
autoCorrect="off"
className={
variant === "spotlight"
? "h-16 rounded-2xl border-0 bg-white/85 text-center font-mono !text-xl shadow-[0_0_70px_45px_rgba(255,255,255,0.85)] placeholder:text-foreground/30 focus-visible:ring-0"
: "h-10 bg-background"
}
data-testid="nostr-import-nsec-input"
id="nostr-private-key"
onChange={(event) => {
setNsecInput(event.target.value);
setImportError(null);
}}
placeholder={
variant === "spotlight" ? "Enter your key here" : "nsec1..."
}
ref={inputRef}
spellCheck={false}
type="password"
value={nsecInput}
/>
</div>

{variant === "spotlight" ? null : (
Expand Down Expand Up @@ -244,32 +265,37 @@ export function NostrKeyImportForm({
</>
)}

{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:
</p>
<p className="break-all font-mono text-2xs text-muted-foreground">
{previewNpub}
</p>
<div
className={cn("min-h-8", variant === "spotlight" && "mt-6 text-center")}
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:
</p>
<p className="break-all font-mono text-2xs text-muted-foreground">
{previewNpub}
</p>
</div>
</div>
</div>
) : null}
) : null}

{showInvalidHint && !errorMessage ? (
<p className="text-xs text-muted-foreground">
Waiting for a valid nsec1 key.
</p>
) : null}
{showInvalidHint && !errorMessage ? (
<p className="text-xs text-muted-foreground">
Waiting for a valid nsec1 key.
</p>
) : null}

{errorMessage ? (
<p className="text-center text-sm text-destructive">{errorMessage}</p>
) : null}
{errorMessage ? (
<p className="text-center text-sm text-destructive">{errorMessage}</p>
) : null}
</div>

<OnboardingFooter>
<Button
Expand Down
18 changes: 18 additions & 0 deletions desktop/src/shared/styles/globals/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,24 @@
--buzz-onboarding-emoji-picker-input: 10, 10, 10;
}

/* The key-import card is viewport-centered on ordinary windows, independent
of the top heading and bottom-docked actions. Short windows return it to
document flow so the shell can grow and scroll instead of overlapping. */
.buzz-onboarding-key-import-position {
position: absolute;
left: 50%;
top: calc(50dvh - 6.625rem);
transform: translate(-50%, -50%);
}

@media (max-height: 44rem) {
.buzz-onboarding-key-import-position {
position: static;
margin-top: 3rem;
transform: none;
}
}

.buzz-onboarding-transition-content {
animation-delay: var(--buzz-onboarding-transition-delay, 0ms);
}
Expand Down
Binary file added desktop/src/shared/ui/assets/card-texture.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions desktop/src/shared/ui/card-texture.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Baked nine-slice texture generated by
* desktop/scripts/texture-card/generate-card-texture.mjs.
*
* The source is 2×: 416 asset pixels = 208 CSS pixels. The image includes a
* 96 CSS-pixel transparent outer powder band. `border-image` keeps corners
* fixed and repeats edge texture while the white center fills any card size.
*/
.buzz-card-textured {
border: 0 solid transparent;
border-image-source: url("./assets/card-texture.png");
border-image-slice: 416 fill;
border-image-width: 208px;
border-image-outset: 96px;
border-image-repeat: repeat;
}
Loading
Loading