Merge feat/burst-and-hashbags: handover burst, Hash Bags launch

This commit is contained in:
jwinterm
2026-08-17 14:20:20 -04:00
8 changed files with 255 additions and 19 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+12
View File
@@ -19,6 +19,18 @@ export const metadata: Metadata = {
};
const apps = [
{
name: "Hash Bags",
tagline: "Non-custodial multi-chain wallet. Wownero-first.",
icon: "/images/products/hash-bags.png",
color: "text-emerald-600 dark:text-emerald-400",
borderColor: "border-emerald-500/20",
href: "/products/hash-bags",
links: [
{ platform: "google" as const, href: "https://play.google.com/store/apps/details?id=com.suchsoftware.hashwallet" },
{ platform: "apple" as const, href: null },
],
},
{
name: "Bloomword",
tagline: "Grow your vocab: a daily word game with a living 3D tree",
+37
View File
@@ -327,3 +327,40 @@
html[data-splash="skip"] #splash-overlay {
display: none !important;
}
/* Splash handover burst: the four pillar cells disperse as the mark hands over to the page.
The blobs sit where the monogram's four cells were (a 2x2 grid about the centre), so this
reads as the logo coming apart rather than as a generic flash.
ONLY transform and opacity animate, both compositor properties. This deliberately avoids
animating filter/background: the matrix animation pegged a core doing exactly that, because
a filter change re-renders the whole filter graph every frame. Soft edges come from a static
radial-gradient that is painted once and then only transformed. */
@keyframes splash-burst {
from { transform: translate(-50%, -50%) scale(0.35); opacity: var(--burst-peak); }
to { transform: translate(-50%, -50%) scale(3.1); opacity: 0; }
}
.splash-burst-blob {
position: absolute;
width: 34vmin;
height: 34vmin;
border-radius: 9999px;
opacity: 0;
will-change: transform, opacity;
animation: splash-burst 420ms cubic-bezier(0.16, 0.84, 0.44, 1) forwards;
/* Half-transparent by construction: the gradient's own alpha never reaches 1, and
--burst-peak holds the whole layer under half opacity. */
--burst-peak: 0.42;
}
.dark .splash-burst-blob { --burst-peak: 0.5; }
.splash-burst-s { background: radial-gradient(circle, rgb(26 92 56 / 0.55), transparent 70%); }
.splash-burst-u { background: radial-gradient(circle, rgb(184 54 112 / 0.55), transparent 70%); }
.splash-burst-c { background: radial-gradient(circle, rgb(23 106 146 / 0.55), transparent 70%); }
.splash-burst-h { background: radial-gradient(circle, rgb(18 133 126 / 0.55), transparent 70%); }
.dark .splash-burst-s { background: radial-gradient(circle, rgb(53 146 92 / 0.55), transparent 70%); }
.dark .splash-burst-u { background: radial-gradient(circle, rgb(232 104 158 / 0.55), transparent 70%); }
.dark .splash-burst-c { background: radial-gradient(circle, rgb(74 173 224 / 0.55), transparent 70%); }
.dark .splash-burst-h { background: radial-gradient(circle, rgb(58 212 198 / 0.55), transparent 70%); }
@media (prefers-reduced-motion: reduce) {
.splash-burst-blob { display: none; }
}
@@ -0,0 +1,142 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import { Card } from "@repo/ui/components/card";
import { StoreButtons } from "@/components/store-buttons";
import { JsonLd, breadcrumbLd, softwareAppLd } from "@/components/seo/json-ld";
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
const DESCRIPTION =
"Open-source, non-custodial wallet for Monero, Wownero, Bitcoin, and nine more chains. Wownero-first, with Ledger, Trezor, and BitBox support. Free on Android; iOS and desktop coming soon.";
export const metadata: Metadata = {
title: "Hash Bags: Non-Custodial Multi-Chain Crypto Wallet | Such Software",
description: DESCRIPTION,
alternates: { canonical: "/products/hash-bags" },
openGraph: {
title: "Hash Bags: Non-Custodial Multi-Chain Crypto Wallet | Such Software",
description: DESCRIPTION,
type: "website",
images: ["/images/branding/OG_banner_v2_light.png"],
},
twitter: {
card: "summary_large_image",
images: ["/images/branding/OG_banner_v2_light.png"],
},
};
const chains = [
{ name: "Monero", ticker: "XMR" },
{ name: "Wownero", ticker: "WOW" },
{ name: "Bitcoin", ticker: "BTC" },
{ name: "Litecoin", ticker: "LTC, incl. MWEB" },
{ name: "Bitcoin Cash", ticker: "BCH" },
{ name: "Dogecoin", ticker: "DOGE" },
{ name: "Nano", ticker: "XNO" },
{ name: "Ethereum", ticker: "ETH + ERC-20s" },
{ name: "Polygon", ticker: "POL" },
{ name: "Base", ticker: "BASE" },
{ name: "Arbitrum", ticker: "ARB" },
{ name: "BNB Smart Chain", ticker: "BSC" },
];
export default function HashBagsPage() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<JsonLd data={breadcrumbLd([
{ name: "Home", path: "/" },
{ name: "Products", path: "/products" },
{ name: "Hash Bags", path: "/products/hash-bags" },
])} />
<JsonLd data={softwareAppLd({
name: "Hash Bags",
path: "/products/hash-bags",
description: DESCRIPTION,
category: "FinanceApplication",
operatingSystem: "Android",
price: "0",
})} />
<Header />
<div id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">
← Back to Products
</Link>
<div className="flex items-center gap-5 mb-6">
<Image
src="/images/products/hash-bags.png"
alt="Hash Bags icon"
width={80}
height={80}
className="rounded-[22%]"
/>
<h1 className="text-4xl md:text-5xl font-bold text-emerald-600 dark:text-emerald-400">
Hash Bags
</h1>
</div>
<div className="prose dark:prose-invert max-w-none mb-12">
<p className="text-xl text-muted-foreground leading-relaxed">
A wallet for your coins. Hash Bags is an open-source, non-custodial, multi-chain
crypto wallet: your keys and your funds stay on your device, and we never hold
either. Free on Android today, with iOS and desktop on the way.
</p>
</div>
<div className="mb-12">
<StoreButtons
links={[
{ platform: "google", href: "https://play.google.com/store/apps/details?id=com.suchsoftware.hashwallet" },
{ platform: "apple", href: null },
]}
extraLinks={[{ href: "https://hash.boats", label: "hash.boats" }]}
/>
<p className="mt-4 text-sm text-muted-foreground">
Live on Google Play. The iOS build is in review, and macOS, Linux, and Windows
follow after that.
</p>
</div>
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">Why it exists</h2>
<div className="prose dark:prose-invert max-w-none">
<p className="text-muted-foreground leading-relaxed">
Cake Wallet ended Wownero support in early 2026. Hash Bags is a fork that keeps
Wownero a first-class mobile experience, maintained by the project&apos;s founder,
while shipping a slimmer and more focused multi-coin wallet without the chains and
integrations we would rather not maintain or recommend.
</p>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">Twelve chains</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{chains.map((c) => (
<Card key={c.name} className="glass-card p-4">
<div className="font-semibold">{c.name}</div>
<div className="text-sm text-muted-foreground">{c.ticker}</div>
</Card>
))}
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">Hardware wallets</h2>
<p className="text-muted-foreground leading-relaxed">
Ledger, Trezor, and BitBox are supported, so the keys can stay off the phone
entirely if that is how you prefer to hold them.
</p>
</section>
<div className="text-center">
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← All products
</Link>
</div>
</div>
<MobileNav />
</main>
);
}
+8
View File
@@ -31,6 +31,14 @@ const products: Product[] = [
icon: "/images/products/custom-websites.svg",
releaseDate: "2025-01-01",
},
{
slug: "hash-bags",
title: "Hash Bags",
description: "Open-source, non-custodial wallet for Monero, Wownero, Bitcoin, and nine more chains. Wownero-first, with Ledger, Trezor, and BitBox support. Android now; iOS and desktop coming.",
color: "emerald",
icon: "/images/products/hash-bags.png",
releaseDate: "2026-08-17",
},
{
slug: "smirk-wallet",
title: "Smirk Wallet",
+1
View File
@@ -17,6 +17,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${base}/products/neroswap`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/webshops`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/custom-websites`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/hash-bags`, lastModified: updated, changeFrequency: "weekly", priority: 0.8 },
{ url: `${base}/products/smirk-wallet`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/bauhaus-echo`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/vegan-iq`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
@@ -12,6 +12,16 @@ const SPLASH_MS = 1337;
// gate stacked on the first.
const FADE_MS = 260;
// The four monogram cells, in the 2x2 grid the mark ends on: S and C in the left column,
// U and H in the right. The burst throws one blob out of each, in that letter's own colour,
// so the handover reads as the logo dispersing rather than as an unrelated flash.
const BURST_CELLS = [
{ key: "s", left: "44%", top: "44%" },
{ key: "u", left: "56%", top: "44%" },
{ key: "c", left: "44%", top: "56%" },
{ key: "h", left: "56%", top: "56%" },
] as const;
/**
* First-load splash: the pillar signature.
*
@@ -77,17 +87,41 @@ export function HeroSplash({ onEnter, leaving }: { onEnter: () => void; leaving?
};
}, [leaving]);
const fade = { transitionDuration: `${FADE_MS}ms`, opacity: fadeOut ? 0 : 1 };
return (
<div
id="splash-overlay"
role="presentation"
className={`fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-background ${
leaving ? `pointer-events-none transition-opacity ${fadeOut ? "opacity-0" : "opacity-100"}` : ""
className={`fixed inset-0 z-50 flex items-center justify-center overflow-hidden ${
leaving ? "pointer-events-none" : "bg-background"
}`}
style={leaving ? { transitionDuration: `${FADE_MS}ms` } : undefined}
>
{/* The mark is the only content, so it is the LCP candidate: an <img> is a valid
LCP element where an inline <svg> is not. */}
{/* The ground and the mark fade on their own, NOT as one container: the burst has to
outlive them, and fading the whole overlay would take the burst down with it in
the same 260ms. */}
{leaving && (
<div className="absolute inset-0 bg-background transition-opacity" style={fade} />
)}
{/* The handover burst: one blob per monogram cell, in that cell's own colour, thrown
outward from where the letter was. aria-hidden and pointer-events-none: it is pure
decoration over content that is already interactive underneath. */}
{leaving && (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{BURST_CELLS.map((cell, i) => (
<span
key={cell.key}
className={`splash-burst-blob splash-burst-${cell.key}`}
style={{ left: cell.left, top: cell.top, animationDelay: `${i * 25}ms` }}
/>
))}
</div>
)}
{/* The mark is the largest thing on the first screen, so it is the LCP candidate: an
<img> is a valid LCP element where an inline <svg> is not. */}
<div className={leaving ? "relative transition-opacity" : "relative"} style={leaving ? fade : undefined}>
<img
src="/images/animations/morph_splash_light.svg"
alt="Such Software"
@@ -102,5 +136,6 @@ export function HeroSplash({ onEnter, leaving }: { onEnter: () => void; leaving?
className="hidden h-auto w-[min(62vw,38vh)] dark:block"
/>
</div>
</div>
);
}
@@ -140,7 +140,8 @@ export function HomeShell({ children }: { children: ReactNode }) {
setTimeout(() => {
setSplashUp(false);
tryRelease();
}, 300); // the overlay's 260ms cross-fade, plus a frame
}, 540); // the 420ms burst plus its 75ms stagger, not just the 260ms cross-fade:
// unmounting on the fade alone cut the blobs off mid-flight
};
return (