Skip to content

Commit 0103899

Browse files
imick-ioclaude
andauthored
feat(website): sitewide announcement banner (#13481)
## Summary Adds a **sitewide announcement banner** to the website, rendered above the navbar on every page. It has a two-layer visibility model: - **Build-time gate** — a pure, unit-tested `evaluateBannerVisibility()` decides whether the banner mounts at all (active flag + optional date window + locale/section targeting), driven by a typed config (`src/config/banner.ts`). No CMS; copy resolves through i18n. - **Client-side dismissal** — persisted in `localStorage`, keyed by a **content hash** so editing the copy re-shows the banner (per-locale, so an en edit doesn't re-show it for zh-CN). Current content points the CTA at **Comfy MCP** (`/mcp`). ## Highlights - **Full-width branded bar** above a now-`sticky` navbar; reuses the design system (`Button`, gradient tokens, new reusable `IconButton`). - **Flash-free** on load: an inline pre-hydration script hides an already-dismissed banner before paint (no pop-in, no layout shift); `close()` sets the same signal after the leave animation to stay flash-free across ClientRouter navigations. - **Open/close transition**: grid-rows height collapse + fade, respecting `prefers-reduced-motion`. - **i18n**: copy in `en` + `zh-CN`. ## Where to edit later - **Copy**: `apps/website/src/i18n/translations.ts` → `launches.banner.text` / `launches.banner.cta` - **Link / on-off / dates / targeting**: `apps/website/src/config/banner.ts` (`bannerConfig`) ## Notes - On a static site the `startsAt`/`endsAt` window is evaluated at **build time** (documented in `banner.ts`). - Changing the copy or link changes the content hash, so previously-dismissed visitors will see the banner again — by design. ## Test plan - `pnpm test:unit` — evaluator + version-hash unit tests pass. - `pnpm typecheck` + lint clean. - On the preview: banner shows on `/`, `/launches`, and a `zh-CN` page; CTA -> `/mcp`; dismiss animates and stays dismissed on reload (no flash); reduced-motion disables the animation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 684b0b0 commit 0103899

14 files changed

Lines changed: 587 additions & 71 deletions

File tree

apps/website/src/components/common/HeaderMain/HeaderMain.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const ctaButtons = [
3333

3434
<template>
3535
<nav
36-
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
36+
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
3737
aria-label="Main navigation"
3838
>
3939
<a
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script setup lang="ts">
2+
import type { PrimitiveProps } from 'reka-ui'
3+
import type { HTMLAttributes } from 'vue'
4+
import type { IconButtonVariants } from '.'
5+
import { Primitive } from 'reka-ui'
6+
import { cn } from '@comfyorg/tailwind-utils'
7+
import { iconButtonVariants } from '.'
8+
9+
interface Props extends PrimitiveProps {
10+
variant?: IconButtonVariants['variant']
11+
size?: IconButtonVariants['size']
12+
class?: HTMLAttributes['class']
13+
disabled?: boolean
14+
}
15+
16+
const {
17+
as = 'button',
18+
asChild,
19+
variant,
20+
size,
21+
class: className,
22+
disabled
23+
} = defineProps<Props>()
24+
</script>
25+
26+
<template>
27+
<Primitive
28+
data-slot="icon-button"
29+
:data-variant="variant"
30+
:data-size="size"
31+
:as
32+
:as-child
33+
:disabled
34+
:class="cn(iconButtonVariants({ variant, size }), className)"
35+
>
36+
<slot />
37+
</Primitive>
38+
</template>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { VariantProps } from 'class-variance-authority'
2+
import { cva } from 'class-variance-authority'
3+
4+
export const iconButtonVariants = cva(
5+
[
6+
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
7+
],
8+
{
9+
variants: {
10+
variant: {
11+
ghost:
12+
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
13+
outline:
14+
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
15+
},
16+
size: {
17+
sm: 'size-8',
18+
default: 'size-10',
19+
lg: 'size-14'
20+
}
21+
},
22+
defaultVariants: {
23+
variant: 'ghost',
24+
size: 'default'
25+
}
26+
}
27+
)
28+
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { onMounted, ref } from 'vue'
2+
3+
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
4+
5+
type ClosedBanners = Record<string, boolean>
6+
7+
function readClosedBanners(): ClosedBanners {
8+
try {
9+
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
10+
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
11+
} catch {
12+
return {}
13+
}
14+
}
15+
16+
function writeClosedBanners(value: ClosedBanners): void {
17+
try {
18+
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
19+
} catch {
20+
// Storage unavailable (private mode / quota) — dismissal just won't persist.
21+
}
22+
}
23+
24+
/** The stable part of a version key (everything before `_v<hash>`). */
25+
function versionPrefix(version: string): string {
26+
const idx = version.lastIndexOf('_v')
27+
return idx === -1 ? version : version.slice(0, idx)
28+
}
29+
30+
/**
31+
* Client-side dismissal persisted in localStorage, keyed by a content-aware
32+
* `version`. The banner renders visible in the static HTML (so non-dismissers
33+
* see no pop-in); an inline pre-hydration script hides an already-dismissed
34+
* banner before paint, and this composable then removes it from the DOM on mount.
35+
*/
36+
export function useBannerDismissal(version: string) {
37+
const isVisible = ref(true)
38+
39+
onMounted(() => {
40+
const stored = readClosedBanners()
41+
const prefix = versionPrefix(version)
42+
43+
// Prune stale versions of THIS banner+locale; keep other banners/locales
44+
// and the current version.
45+
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
46+
let pruned = false
47+
for (const key of Object.keys(stored)) {
48+
if (versionPrefix(key) !== prefix || key === version) {
49+
cleaned[key] = stored[key]
50+
} else {
51+
pruned = true
52+
}
53+
}
54+
if (pruned) writeClosedBanners(cleaned)
55+
56+
isVisible.value = !cleaned[version]
57+
})
58+
59+
function close(): void {
60+
isVisible.value = false
61+
const stored = readClosedBanners()
62+
stored[version] = true
63+
writeClosedBanners(stored)
64+
}
65+
66+
// Call once the close transition has finished. Sets the pre-paint hide signal
67+
// so the banner doesn't flash back in on a ClientRouter (view-transition)
68+
// navigation — where the inline <head> script does not re-run but <html>
69+
// persists. Deferred to after the animation so the leave transition can play.
70+
function persistHidden(): void {
71+
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
72+
}
73+
74+
return { isVisible, close, persistHidden }
75+
}

apps/website/src/config/banner.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { ButtonVariants } from '../components/ui/button'
2+
import type { Locale, TranslationKey } from '../i18n/translations'
3+
4+
import { t } from '../i18n/translations'
5+
import { resolveRel } from '../utils/cta'
6+
7+
// The banner "CMS": a single typed config resolved through i18n at build time.
8+
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
9+
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
10+
// window gates on the last deploy, not the visitor's exact clock.
11+
12+
interface BannerLinkConfig {
13+
readonly href: string
14+
readonly titleKey: TranslationKey
15+
readonly target?: boolean
16+
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
17+
}
18+
19+
export interface BannerConfig {
20+
readonly id: string
21+
readonly isActive: boolean
22+
readonly startsAt?: string
23+
readonly endsAt?: string
24+
/** Empty/undefined = all locales. */
25+
readonly targetLocales?: readonly Locale[]
26+
/** v1 only supports 'sitewide'. */
27+
readonly targetSections?: readonly string[]
28+
readonly titleKey: TranslationKey
29+
readonly descriptionKey?: TranslationKey
30+
readonly link?: BannerLinkConfig
31+
}
32+
33+
interface BannerLinkData {
34+
readonly href: string
35+
readonly title: string
36+
readonly target?: '_blank'
37+
readonly rel?: string
38+
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
39+
}
40+
41+
export interface BannerData {
42+
readonly id: string
43+
readonly title: string
44+
readonly description?: string
45+
readonly link?: BannerLinkData
46+
}
47+
48+
export const bannerConfig: BannerConfig = {
49+
id: 'announcement',
50+
isActive: true,
51+
targetSections: ['sitewide'],
52+
titleKey: 'launches.banner.text',
53+
link: {
54+
href: '/mcp',
55+
titleKey: 'launches.banner.cta',
56+
buttonVariant: 'underlineLink'
57+
}
58+
}
59+
60+
/** Resolve a config's i18n keys into display strings for the given locale. */
61+
export function getBannerData(
62+
config: BannerConfig,
63+
locale: Locale
64+
): BannerData {
65+
const { link } = config
66+
const target = link?.target ? '_blank' : undefined
67+
68+
return {
69+
id: config.id,
70+
title: t(config.titleKey, locale),
71+
description: config.descriptionKey
72+
? t(config.descriptionKey, locale)
73+
: undefined,
74+
link: link
75+
? {
76+
href: link.href,
77+
title: t(link.titleKey, locale),
78+
target,
79+
rel: resolveRel({ target: target ?? '_self' }),
80+
buttonVariant: link.buttonVariant
81+
}
82+
: undefined
83+
}
84+
}

apps/website/src/i18n/translations.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3983,12 +3983,12 @@ const translations = {
39833983
// Launches page (/launches) — subscribe banner
39843984
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
39853985
'launches.banner.text': {
3986-
en: 'Join the live stream. Get answers in real time.',
3987-
'zh-CN': '加入直播,实时获得解答。'
3986+
en: 'Now turn your agent into a creative technologist.',
3987+
'zh-CN': '现在,让你的智能体成为创意技术专家。'
39883988
},
39893989
'launches.banner.cta': {
3990-
en: 'Join livestream',
3991-
'zh-CN': '加入直播'
3990+
en: 'Start Comfy MCP',
3991+
'zh-CN': '启动 Comfy MCP'
39923992
},
39933993

39943994
// Launches page (/launches) — closing CTA

apps/website/src/layouts/BaseLayout.astro

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import '../styles/global.css'
55
import type { Locale } from '../i18n/translations'
66
import SiteFooter from '../components/common/SiteFooter.vue'
77
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
8+
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
9+
import { bannerConfig, getBannerData } from '../config/banner'
10+
import {
11+
BANNER_DISMISS_ATTR,
12+
BANNER_STORAGE_KEY,
13+
createBannerVersion,
14+
evaluateBannerVisibility
15+
} from '../utils/banner'
816
import { escapeJsonLd } from '../utils/escapeJsonLd'
917
import { fetchGitHubStars, formatStarCount } from '../utils/github'
1018
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
3442
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
3543
const githubStars = rawStars ? formatStarCount(rawStars) : ''
3644
45+
// Announcement banner — build-time visibility gate + content-hash version key.
46+
const bannerData = getBannerData(bannerConfig, locale)
47+
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
48+
currentLocale: locale,
49+
currentSection: 'sitewide',
50+
now: new Date(),
51+
})
52+
const bannerVersion = createBannerVersion(bannerData, locale)
53+
3754
const gtmId = 'GTM-NP9JM6K7'
3855
const gtmEnabled = import.meta.env.PROD
3956
@@ -124,6 +141,25 @@ const websiteJsonLd = {
124141

125142
<ClientRouter />
126143
<slot name="head" />
144+
145+
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
146+
{bannerVisible && (
147+
<script
148+
is:inline
149+
define:vars={{
150+
bannerVersion,
151+
storageKey: BANNER_STORAGE_KEY,
152+
dismissAttr: BANNER_DISMISS_ATTR
153+
}}
154+
>
155+
try {
156+
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
157+
if (dismissed[bannerVersion]) {
158+
document.documentElement.setAttribute(dismissAttr, '')
159+
}
160+
} catch (e) {}
161+
</script>
162+
)}
127163
</head>
128164
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
129165
{gtmEnabled && (
@@ -137,8 +173,16 @@ const websiteJsonLd = {
137173
</noscript>
138174
)}
139175

176+
{bannerVisible && (
177+
<AnnouncementBanner
178+
data={bannerData}
179+
version={bannerVersion}
180+
locale={locale}
181+
client:load
182+
/>
183+
)}
140184
<HeaderMain locale={locale} github-stars={githubStars} client:load />
141-
<main class="mt-20 lg:mt-32">
185+
<main>
142186
<slot />
143187
</main>
144188
<SiteFooter locale={locale} client:load />

apps/website/src/pages/launches.astro

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
33
import CtaSection from '../templates/drops/CtaSection.vue'
44
import DropsSection from '../templates/drops/DropsSection.vue'
55
import HeroSection from '../templates/drops/HeroSection.vue'
6-
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
76
import { t } from '../i18n/translations'
87
98
const locale = 'en' as const
@@ -13,7 +12,6 @@ const locale = 'en' as const
1312
title={t('launches.page.title', locale)}
1413
description={t('launches.page.description', locale)}
1514
>
16-
<SubscribeBanner locale={locale} client:load />
1715
<HeroSection locale={locale} client:load />
1816
<DropsSection locale={locale} />
1917
<CtaSection locale={locale} />

apps/website/src/pages/zh-CN/launches.astro

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
33
import CtaSection from '../../templates/drops/CtaSection.vue'
44
import DropsSection from '../../templates/drops/DropsSection.vue'
55
import HeroSection from '../../templates/drops/HeroSection.vue'
6-
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
76
import { t } from '../../i18n/translations'
87
98
const locale = 'zh-CN' as const
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
1312
title={t('launches.page.title', locale)}
1413
description={t('launches.page.description', locale)}
1514
>
16-
<SubscribeBanner locale={locale} client:load />
1715
<HeroSection locale={locale} client:load />
1816
<DropsSection locale={locale} />
1917
<CtaSection locale={locale} />

apps/website/src/styles/global.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
--color-secondary-mauve: #4d3762;
7171
--color-destructive: #f44336;
7272
--color-primary-comfy-plum: #49378b;
73+
--color-secondary-deep-plum: #2b2040;
7374
--color-secondary-cool-gray: #3c3c3c;
7475
--color-illustration-forest: #20464c;
7576
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
@@ -93,6 +94,14 @@
9394
initial-value: 0deg;
9495
}
9596

97+
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
98+
script in BaseLayout head) — prevents any flash before Vue hydrates.
99+
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
100+
keep them in sync. */
101+
[data-banner-dismissed] [data-slot='announcement-banner'] {
102+
display: none;
103+
}
104+
96105
@keyframes border-angle-spin {
97106
to {
98107
--border-angle: 360deg;

0 commit comments

Comments
 (0)