feat(landing): add dismissible 2.0 launch banner

Deep otter-brown Web 1.0 style bar pinned above the fixed navbar (offset via --lb-h), with a localStorage-remembered dismissal and a render-blocking check so returning visitors see no flash. Temporary; remove once 2.0 is old news.
This commit is contained in:
SnapOtter
2026-07-07 17:19:11 +08:00
parent 008c4e3654
commit ea7a671ccf
2 changed files with 143 additions and 0 deletions
@@ -0,0 +1,131 @@
---
// TEMP: 2.0 launch banner. Remove a few weeks after release, once it's old news.
// Dismissal is remembered in localStorage; a render-blocking check in Base.astro's
// <head> sets data-lb-dismissed before paint so returning visitors see no flash.
const RELEASE = "https://github.com/snapotter-hq/SnapOtter/releases/tag/v2.0.0";
---
<aside class="lb-root" data-lb>
<div class="lb-burst" aria-hidden="true">NEW!</div>
<div class="lb-marquee" aria-hidden="true">
<div class="lb-track">
<span>&#9733; SnapOtter <b>2.0</b> is HERE &#9733; SnapOtter <b>2.0</b> is HERE &#9733; SnapOtter <b>2.0</b> is HERE &#9733; SnapOtter <b>2.0</b> is HERE &#9733; SnapOtter <b>2.0</b> is HERE &#9733;&nbsp;</span>
<span>&#9733; SnapOtter <b>2.0</b> is HERE &#9733; SnapOtter <b>2.0</b> is HERE &#9733; SnapOtter <b>2.0</b> is HERE &#9733; SnapOtter <b>2.0</b> is HERE &#9733; SnapOtter <b>2.0</b> is HERE &#9733;&nbsp;</span>
</div>
</div>
<a class="lb-btn" href={RELEASE} aria-label="See the SnapOtter 2.0 release notes">Click here!</a>
<button class="lb-x" type="button" data-lb-close aria-label="Dismiss announcement">&#10005;</button>
</aside>
<style is:global>
:root { --lb-h: 48px; }
html[data-lb-dismissed] { --lb-h: 0px; }
/* Push the fixed navbar and all page content down by the banner height, so
the banner sits above the nav without overlapping. Collapses when dismissed. */
body { padding-top: var(--lb-h); transition: padding-top 0.28s ease; }
nav.fixed { top: var(--lb-h); transition: top 0.28s ease; }
.lb-root {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 60;
height: var(--lb-h);
overflow: hidden;
display: flex;
align-items: center;
gap: 10px;
padding: 0 42px 0 10px;
background: #1a1210;
background-image: repeating-linear-gradient(45deg, rgba(240, 149, 80, 0.16) 0 8px, transparent 8px 16px);
border-top: 3px ridge #f0a24e;
border-bottom: 3px solid #0b0704;
box-shadow: 0 6px 18px -10px rgba(0, 0, 0, 0.5);
font-family: "Comic Sans MS", "Trebuchet MS", var(--font-body, sans-serif);
opacity: 1;
transition: opacity 0.22s ease;
}
html[data-lb-dismissed] .lb-root { opacity: 0; visibility: hidden; }
.lb-burst {
flex: 0 0 auto;
color: #7a1503;
font-weight: 900;
font-size: 11px;
letter-spacing: 0.02em;
background: #ffd34e;
width: 36px;
height: 36px;
display: grid;
place-items: center;
text-align: center;
clip-path: polygon(50% 0, 61% 12%, 77% 6%, 78% 23%, 95% 25%, 86% 40%, 100% 50%, 86% 60%, 95% 75%, 78% 77%, 77% 94%, 61% 88%, 50% 100%, 39% 88%, 23% 94%, 22% 77%, 5% 75%, 14% 60%, 0 50%, 14% 40%, 5% 25%, 22% 23%, 23% 6%, 39% 12%);
animation: lb-spin 7s linear infinite;
box-shadow: 0 0 0 2px #b5471b;
}
.lb-marquee { flex: 1 1 auto; overflow: hidden; white-space: nowrap; }
.lb-track { display: inline-flex; animation: lb-slide 22s linear infinite; will-change: transform; }
.lb-track span { color: #fff6da; font-weight: 700; font-size: 15px; text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.55); }
.lb-track b { color: #ffe86b; }
.lb-btn {
flex: 0 0 auto;
font-weight: 800;
font-size: 12.5px;
color: #12233b;
background: linear-gradient(#fff, #cfd8e6);
border: 2px outset #eef;
padding: 5px 11px;
border-radius: 3px;
text-decoration: none;
animation: lb-blink 1.1s steps(2, start) infinite;
}
.lb-btn:hover { background: linear-gradient(#fff, #dde6f2); }
.lb-x {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 22px;
font-weight: 900;
font-size: 12px;
color: #12233b;
background: linear-gradient(#fff, #c8c8c8);
border: 2px outset #fff;
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.lb-x:active { border-style: inset; }
@keyframes lb-spin { to { transform: rotate(360deg); } }
@keyframes lb-slide { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@keyframes lb-blink { 50% { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) {
.lb-track, .lb-burst, .lb-btn { animation: none !important; }
body, nav.fixed, .lb-root { transition: none !important; }
}
</style>
<script>
(() => {
const KEY = "snapotter-v2-banner";
const root = document.querySelector("[data-lb]");
if (!root) return;
const close = root.querySelector("[data-lb-close]");
close?.addEventListener("click", () => {
try {
localStorage.setItem(KEY, "dismissed");
} catch (e) {
/* private mode / storage disabled: just hide for this session */
}
document.documentElement.setAttribute("data-lb-dismissed", "");
});
})();
</script>
+12
View File
@@ -2,6 +2,7 @@
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import "@/styles/globals.css";
import { TOOLS } from "@snapotter/shared";
import LaunchBanner from "@/components/LaunchBanner.astro";
interface Props {
title?: string;
@@ -32,6 +33,16 @@ const {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- TEMP: 2.0 launch banner dismissal. Render-blocking so a returning visitor
who closed the banner never sees it flash. Remove with the banner. -->
<script is:inline>
try {
if (localStorage.getItem("snapotter-v2-banner") === "dismissed") {
document.documentElement.setAttribute("data-lb-dismissed", "");
}
} catch (e) {}
</script>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
@@ -84,6 +95,7 @@ const {
</head>
<body>
<a href="#main" class="skip-link">Skip to content</a>
<LaunchBanner />
<slot />
<!-- Scroll-triggered reveal observer -->