The Free-vs-Pro upgrade modal the upsell strip opens. Launch language: the beta offer is over, pricing is $5/mo or $48/yr.
A set swaps whole — one title, one sub, one CTA pair, never mixed. Holding rules: no em dashes in customer copy, no save-% on the annual (open question in PLANS-PRICING.md), no time promises, no library counts.
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): the beta first-500 offer ended at GA —
// no discount on launch surfaces (PLANS-PRICING.md). Prices are the
// launch list, $5/mo · $48/yr; annual rides in the price term.
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Save favorites', 'Curate collections', 'Sync across devices'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆',
price: '$5', term: '/mo or $48/yr',
bullets: ['Custom curated lists', 'Smart search', 'No ads, ever'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<div class="pop-modal pro-modal">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- celebration layer — pure decoration, JoyPixels only -->
<span class="__spark __spark--tl jp-font" aria-hidden="true">✨</span>
<span class="__spark __spark--tr jp-font" aria-hidden="true">✨</span>
<div class="__burst" aria-hidden="true">
<span class="__burst-tile jp-font">🎉</span>
<span class="__burst-tile jp-font">👑</span>
<span class="__burst-tile jp-font">⚡</span>
</div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<div class="__bullets">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): the beta first-500 offer ended at GA —
// no discount on launch surfaces (PLANS-PRICING.md); $5/mo · $48/yr.
// B ADDS bullets from the ../PLANS-PRICING.md feature lists —
// suggestion only; pick A if the copy must stay minimal. Combo
// SAVING sits under Free (a Free-plan feature; Pro's edge is
// unlimited + recents).
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Save favorites', 'Curate collections',
'Save emoji combos', 'Sync across devices'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆',
price: '$5', term: '/mo or $48/yr',
bullets: ['Custom curated lists', 'Smart search',
'Unlimited saved combos + recents',
'Smart Recents — most-used first', 'No ads, ever'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<div class="pop-modal pro-modal">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- celebration layer — pure decoration, JoyPixels only -->
<span class="__spark __spark--tl jp-font" aria-hidden="true">✨</span>
<span class="__spark __spark--tr jp-font" aria-hidden="true">✨</span>
<div class="__burst" aria-hidden="true">
<span class="__burst-tile jp-font">🎉</span>
<span class="__burst-tile jp-font">👑</span>
<span class="__burst-tile jp-font">⚡</span>
</div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<div class="__bullets">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.hero-dark.png — EXISTING marketing meshup (already shipping in the beta-invite email); composed for a black background. Bundle the asset or reuse the hosted copy..__mesh scoped CSS — new styling (included in the CSS pane below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): the beta first-500 offer ended at GA —
// no discount on launch surfaces (PLANS-PRICING.md). Prices are the
// launch list, $5/mo · $48/yr; the 🏆 sticker is back on the Pro
// card corner (the offer badge retired with the offer).
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Save favorites', 'Curate collections', 'Sync across devices'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆',
price: '$5', term: '/mo or $48/yr',
bullets: ['Custom curated lists', 'Smart search', 'No ads, ever'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<!-- mesh modifier baked in; bind it instead
(:class="{ 'pro-modal--mesh': meshVariant }") if the
burst treatment stays available as a config -->
<div class="pop-modal pro-modal pro-modal--mesh">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- hero band — replaces the celebration layer (sparks +
burst + top badge); pure decoration: aria-hidden,
pointer-events none, empty of content -->
<div class="__mesh" aria-hidden="true"></div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<div class="__bullets">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
/* modifier at top level — native nesting doesn't support `&--x`.
Bind with :class="{ 'pro-modal--mesh': meshVariant }" (or bake it
in if C ships as THE design). */
.pro-modal--mesh {
/* bigger title — the band gives it room (review ask) */
.pop-modal__title { font-size: 4rem; }
/* close button rides above the band, with a solid backing so it
reads over the imagery (the default translucent circle blends) */
.pop-modal__close {
z-index: 1;
background: color-mix(in srgb, var(--metal-950) 78%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
backdrop-filter: blur(6px);
}
.__mesh {
flex: none;
width: calc(100% + 56px); /* full-bleed past the 28px side padding */
height: 150px; /* shorter band → shorter modal (review ask) */
margin: -32px -28px -10px; /* pull to the modal's top edge */
border-radius: var(--r-lg) var(--r-lg) 0 0;
/* the logo sits ~9% down the source image, so a LOW position
value reveals its full top with room above; higher values
scroll past the top and clip the logo. Lands upper-third. */
background: url('@/assets/hero-dark.png') center 6% / cover no-repeat;
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
pointer-events: none;
}
@media screen and (max-width: 800px) {
.pop-modal__title { font-size: 3rem; }
.__mesh {
width: calc(100% + 36px); /* mobile padding is 26/18 */
height: 108px;
margin: -26px -18px -8px;
background-position: center 6%; /* same low value → full logo */
}
}
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.hero-dark.png — EXISTING marketing meshup (already shipping in the beta-invite email); composed for a black background. Bundle the asset or reuse the hosted copy..__mesh scoped CSS — new styling (included in the CSS pane below).__bullets--2col — new CSS (grid, collapses to 1 column on mobile; included in the CSS pane below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): no discount at GA; $5/mo · $48/yr. The
// 6 Pro labels are PROVISIONAL — trimmed so each holds one line in
// the 2-col grid; all real Pro features (../PLANS-PRICING.md), final
// wording is an editorial call. `cols: 2` opts Pro's list into the
// grid; the 🏆 sticker sits on the Pro card corner.
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Save favorites', 'Curate collections', 'Sync across devices'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆', cols: 2,
price: '$5', term: '/mo or $48/yr',
bullets: ['Custom lists', 'Smart search', 'Unlimited combos',
'Smart Recents', 'XL icon sizes', 'No ads, ever'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<!-- mesh modifier baked in; bind it instead
(:class="{ 'pro-modal--mesh': meshVariant }") if the
burst treatment stays available as a config -->
<div class="pop-modal pro-modal pro-modal--mesh">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- hero band — replaces the celebration layer (sparks +
burst + top badge); pure decoration: aria-hidden,
pointer-events none, empty of content -->
<div class="__mesh" aria-hidden="true"></div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<!-- 2-col modifier when plan.cols === 2 (Pro here) -->
<div class="__bullets"
:class="{ '__bullets--2col': plan.cols === 2 }">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
/* modifier at top level — native nesting doesn't support `&--x`.
Bind with :class="{ 'pro-modal--mesh': meshVariant }" (or bake it
in if C ships as THE design). */
.pro-modal--mesh {
/* bigger title — the band gives it room (review ask) */
.pop-modal__title { font-size: 4rem; }
/* close button rides above the band, with a solid backing so it
reads over the imagery (the default translucent circle blends) */
.pop-modal__close {
z-index: 1;
background: color-mix(in srgb, var(--metal-950) 78%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
backdrop-filter: blur(6px);
}
.__mesh {
flex: none;
width: calc(100% + 56px); /* full-bleed past the 28px side padding */
height: 150px; /* shorter band → shorter modal (review ask) */
margin: -32px -28px -10px; /* pull to the modal's top edge */
border-radius: var(--r-lg) var(--r-lg) 0 0;
/* the logo sits ~9% down the source image, so a LOW position
value reveals its full top with room above; higher values
scroll past the top and clip the logo. Lands upper-third. */
background: url('@/assets/hero-dark.png') center 6% / cover no-repeat;
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
pointer-events: none;
}
@media screen and (max-width: 800px) {
.pop-modal__title { font-size: 3rem; }
.__mesh {
width: calc(100% + 36px); /* mobile padding is 26/18 */
height: 108px;
margin: -26px -18px -8px;
background-position: center 6%; /* same low value → full logo */
}
}
}
/* D · Pro's 6 bullets in a 2-column grid (plan.cols === 2) —
same 3-row height as Free's single column of 3 */
.plan-card .__bullets--2col {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 12px;
row-gap: 10px;
align-content: start;
/* tighter type so each label holds one line → even 3-row block */
.bullet-row { font-size: 1.3rem; }
.check-circle { width: 15px; height: 15px; }
}
@media screen and (max-width: 800px) {
.plan-card .__bullets--2col { grid-template-columns: 1fr; }
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.assets/pro-modal-band.svg — dedicated no-logo band art (pre-cropped, dark-composed like hero-dark.png).pro-modal--band-lower — swaps the band image + pins it to the top (included in the CSS pane below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Free/Pro bullets = the FINAL set (2026-07-15), added verbatim;
// they pair as limit → upgrade, so both lists stay single
// column. Title/sub/prices/CTAs = the LAUNCH copy (2026-08-11):
// no discount at GA, $5/mo · $48/yr; 🏆 sticker on the Pro corner.
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Ad-Supported', '6 Collections',
'1 Favorites List', 'Up to 24 Recents'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆',
price: '$5', term: '/mo or $48/yr',
bullets: ['Ad-free Experience', 'Unlimited Lists',
'Unlimited Collections', 'Expanded Recents',
'Sorting Recents'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<!-- mesh + band-lower modifiers baked in (band-lower swaps
the band art to the dedicated no-logo asset) -->
<div class="pop-modal pro-modal pro-modal--mesh pro-modal--band-lower">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- no-logo hero band — pure decoration: aria-hidden,
pointer-events none, empty of content -->
<div class="__mesh" aria-hidden="true"></div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<div class="__bullets">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
/* modifier at top level — native nesting doesn't support `&--x`.
Bind with :class="{ 'pro-modal--mesh': meshVariant }" (or bake it
in if C ships as THE design). */
.pro-modal--mesh {
/* bigger title — the band gives it room (review ask) */
.pop-modal__title { font-size: 4rem; }
/* close button rides above the band, with a solid backing so it
reads over the imagery (the default translucent circle blends) */
.pop-modal__close {
z-index: 1;
background: color-mix(in srgb, var(--metal-950) 78%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
backdrop-filter: blur(6px);
}
.__mesh {
flex: none;
width: calc(100% + 56px); /* full-bleed past the 28px side padding */
height: 150px; /* shorter band → shorter modal (review ask) */
margin: -32px -28px -10px; /* pull to the modal's top edge */
border-radius: var(--r-lg) var(--r-lg) 0 0;
/* the logo sits ~9% down the source image, so a LOW position
value reveals its full top with room above; higher values
scroll past the top and clip the logo. Lands upper-third. */
background: url('@/assets/hero-dark.png') center 6% / cover no-repeat;
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
pointer-events: none;
}
@media screen and (max-width: 800px) {
.pop-modal__title { font-size: 3rem; }
.__mesh {
width: calc(100% + 36px); /* mobile padding is 26/18 */
height: 108px;
margin: -26px -18px -8px;
background-position: center 6%; /* same low value → full logo */
}
}
}
/* the band uses the dedicated no-logo asset. It's TALL and runs
down behind the title: the big negative bottom margin pulls the
title/sub up over the band's faded lower half (title is later in
the DOM with no bg, so it paints on top of the soft art). The
mask eases gently — solid only at the very top, ~15% ghost behind
the title, gone by the cards. Plan cards are single-column so the
paired Free-limit → Pro-upgrade rows read straight across. */
.pro-modal--band-lower .__mesh {
background-image: url('@/assets/pro-modal-band.svg');
background-position: center top;
height: 300px; /* taller → shows more of the app */
margin: -32px -28px -175px; /* bottom pull → content overlaps the band */
-webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 10%, rgba(0,0,0,.42) 30%, rgba(0,0,0,.12) 50%, transparent 82%);
mask-image: linear-gradient(to bottom, #000 0%, #000 10%, rgba(0,0,0,.42) 30%, rgba(0,0,0,.12) 50%, transparent 82%);
}
@media screen and (max-width: 800px) {
.pro-modal--band-lower .__mesh { height: 220px; margin: -26px -18px -128px; }
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text><PlanLadder> — new atom (the compare grid; Vue + scoped CSS below). <PlanCard> is NOT used in this variant.<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import Text from '@/components/Text.vue'
import PlanLadder from '@/components/PlanLadder.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): no discount at GA; $5/mo · $48/yr
// (PLANS-PRICING.md). Cell values restate the final Free/Pro set
// (2026-07-15) as short ladder values — same facts, cell-sized.
const ROWS = [
{ label: 'Ads', free: 'Ad-supported', pro: 'Ad-free' },
{ label: 'Collections', free: '6', pro: 'Unlimited' },
{ label: 'Favorites lists', free: '1', pro: 'Unlimited' },
{ label: 'Recents', free: 'Up to 24', pro: 'Expanded + sorting' },
]
const FOOT = {
free: { price: '$0', cta: 'Stick with Free', variant: 'gray' },
pro: { price: '$5', term: '/mo or $48/yr', cta: 'Upgrade to Pro', variant: 'green' },
}
const act = (side) => side === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<div class="pop-modal pro-modal pro-modal--ladder">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- celebration layer — two sparks only; the halo carries
the green. No burst: the ladder is the dense variant. -->
<span class="__spark __spark--tl jp-font" aria-hidden="true">✨</span>
<span class="__spark __spark--tr jp-font" aria-hidden="true">✨</span>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<PlanLadder :rows="ROWS" :foot="FOOT" @select="act" />
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
const props = defineProps({
rows: { type: Array, required: true }, // { label, free, pro }
foot: { type: Object, required: true }, // { free: {…}, pro: {…} }
})
const emit = defineEmits(['select'])
</script>
<template>
<!-- GRID on purpose (R10): three fixed tracks, rows aligned
across — structured placement, not a flowing list. The halo
is an ABS-POS grid child with only grid-column set: its
containing block is column 3 across the grid's full height,
so it needs no row math and, being out of flow, never
disturbs auto-placement. z-index:-1 + isolation on the
ladder keep it under the cells. Rules span the FULL row —
short rules leave a col-3 hole that sparse auto-placement
fills with the next feature, shifting the lattice; the line
still stops at the halo via a gradient stop. -->
<div class="plan-ladder">
<span class="__halo" aria-hidden="true"></span>
<span class="__head" aria-hidden="true"></span>
<Text class="__head" type="body/l-heavy" text="Free" />
<span class="__head __head--pro">
<span class="__crown jp-font" aria-hidden="true">👑</span>
<Text type="body/l-heavy" color="white" text="Pro" />
</span>
<template v-for="row in rows" :key="row.label">
<span class="__rule" aria-hidden="true"></span>
<Text class="__feature" type="body/m-med" :text="row.label" />
<Text class="__cell" type="body/m-med" :text="row.free" />
<Text class="__cell __cell--pro" type="body/m-heavy" color="white" :text="row.pro" />
</template>
<span class="__rule" aria-hidden="true"></span>
<span class="__foot" aria-hidden="true"></span>
<div class="__foot">
<span class="__price">{{ foot.free.price }}</span>
<PrimaryButton :variant="foot.free.variant" size="md"
:label="foot.free.cta" @click="emit('select', 'free')" />
</div>
<div class="__foot">
<span class="__price">{{ foot.pro.price }}<span class="__term">{{ foot.pro.term }}</span></span>
<PrimaryButton :variant="foot.pro.variant" size="md"
:label="foot.pro.cta" @click="emit('select', 'pro')" />
</div>
</div>
</template>
<style scoped>
/* modal shell — same fixed-dark recipe as A (metal-950 + green
light; doodle bg stays reserved for Explore + collections) */
.pro-modal--ladder {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
}
/* PlanLadder.vue — scoped. Inks are color-mix pins on the fixed-dark
shell (the page-documented carve-out); Text carries every scale. */
.plan-ladder {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: 1.3fr 1fr 1.15fr;
width: 100%;
margin-top: 8px;
/* abs-pos grid child: with only grid-column set, its containing
block is column 3 horizontally + the grid's padding edge
vertically — full column height, no row counting (grid row -1
can't reach implicit rows, and an in-flow spanning item would
collide with auto-placement). isolation on the ladder keeps
the z-index:-1 inside it. */
.__halo {
position: absolute;
grid-column: 3 / 4;
inset: 0;
z-index: -1;
border-radius: var(--r-md);
background:
radial-gradient(120% 60% at 50% 0%, rgba(48, 217, 9, .14), transparent 60%),
color-mix(in srgb, var(--metal-300) 14%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .16);
}
/* rules span the FULL row — a rule stopping at column 2 leaves
column 3 open and sparse auto-placement pulls the next
feature into the hole, shifting every later row. The line
still visually stops at the halo via a gradient stop at the
col-3 line: 2.3fr of 3.45fr = 66.667%. */
.__rule {
grid-column: 1 / -1;
height: 1px;
background: linear-gradient(to right,
color-mix(in srgb, var(--white) 9%, transparent) 66.667%,
transparent 66.667%);
}
.__head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px 10px;
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.8rem;
letter-spacing: -.01em;
color: color-mix(in srgb, var(--white) 82%, transparent);
}
.__head--pro { color: var(--white); }
.__crown { font-size: 1.8rem; line-height: 1; }
.__feature {
display: flex;
align-items: center;
padding: 11px 16px;
color: color-mix(in srgb, var(--white) 58%, transparent);
}
.__cell {
display: flex;
align-items: center;
padding: 11px 16px;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.__cell--pro { color: var(--white); }
.__foot {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 10px;
padding: 12px 16px 16px;
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.4rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__term {
margin-left: 2px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.25rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button { width: 100%; justify-content: center; }
@media screen and (max-width: 800px) {
grid-template-columns: 1.1fr 1fr 1.1fr;
/* col-3 line moves with the tracks: 2.1fr of 3.2fr */
.__rule {
background: linear-gradient(to right,
color-mix(in srgb, var(--white) 9%, transparent) 65.625%,
transparent 65.625%);
}
.__head { padding: 12px 10px 8px; font-size: 1.6rem; }
.__feature, .__cell { padding: 9px 10px; font-size: 1.2rem; }
.__foot { padding: 10px 10px 12px; }
.__price { font-size: 2rem; }
}
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it..plan-card--wide + .__foot + .__keep-free — new scoped CSS (included in the CSS pane below).__bullets--2col — D's grid, reused (collapses to 1 column under 480px)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): no discount at GA; $5/mo · $48/yr
// (PLANS-PRICING.md). Bullets = Pro's final set (2026-07-15),
// verbatim. No Free card at all — G pitches Pro and leaves; the
// exit is one quiet text button, not a competing plan.
const PRO = { id: 'pro', name: 'Pro', sticker: '🏆', cols: 2,
price: '$5', term: '/mo or $48/yr',
bullets: ['Ad-free Experience', 'Unlimited Lists',
'Unlimited Collections', 'Expanded Recents',
'Sorting Recents'],
cta: 'Upgrade to Pro', variant: 'green' }
</script>
<template>
<PopModal :popped="upsell.showPro" width="560px"
@unpop="upsell.updateShowPro(false)">
<div class="pop-modal pro-modal pro-modal--solo">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- celebration layer — the full A treatment: burst +
sparks + sticker. G is the loud one; the single
card IS the prize. -->
<span class="__spark __spark--tl jp-font" aria-hidden="true">✨</span>
<span class="__spark __spark--tr jp-font" aria-hidden="true">✨</span>
<div class="__burst" aria-hidden="true">
<span class="__burst-tile jp-font">🎉</span>
<span class="__burst-tile jp-font">👑</span>
<span class="__burst-tile jp-font">⚡</span>
</div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<!-- wide: the parent adds --wide; PlanCard wraps its
price + button in one .__foot row (3-line template
change, scoped CSS below). cols: 2 reuses D's
2-col bullets grid. -->
<PlanCard :plan="PRO" class="plan-card--wide"
@select="emit('go-pro')" />
<button class="__keep-free"
@click="upsell.updateShowPro(false)">
<Text type="body/m-med" text="Stick with Free" />
</button>
</div>
</PopModal>
</template>
<style scoped>
/* modal shell — 560px: the pitch doesn't need compare width */
.pro-modal--solo {
position: relative;
max-width: 560px;
padding: 32px 28px 24px;
gap: 14px;
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the strip's tilted-tile language (same block as
A); gentle float, straighten on modal hover */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text>; ink pinned
on the fixed-dark shell (page-documented carve-out) */
.__sub {
margin: -2px 0 0;
max-width: 44ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__keep-free {
margin: 2px auto 0;
padding: 6px 10px;
border: 0;
background: none;
color: color-mix(in srgb, var(--white) 55%, transparent);
cursor: pointer;
&:hover {
color: color-mix(in srgb, var(--white) 82%, transparent);
text-decoration: underline;
}
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal--solo .__burst-tile { animation: none; }
}
/* PlanCard scoped additions — the wide solo card */
.plan-card--wide {
width: 100%;
margin-top: 8px;
.__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
.button { width: auto; }
}
@media screen and (max-width: 480px) {
.__bullets--2col { grid-template-columns: 1fr; }
.__foot {
flex-direction: column;
align-items: stretch;
gap: 10px;
text-align: center;
.button { width: 100%; }
}
}
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it..pro-modal--split panes + .plan-card--slim — new scoped CSS (included in the CSS pane below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): no discount at GA; $5/mo · $48/yr
// (PLANS-PRICING.md). Pro bullets = the final set (2026-07-15),
// single column. Free keeps a real card — one slim row with a
// visible $0 — so the choice stays honest, just not equal.
const PLANS = [
{ id: 'pro', name: 'Pro', sticker: '🏆',
price: '$5', term: '/mo or $48/yr',
bullets: ['Ad-free Experience', 'Unlimited Lists',
'Unlimited Collections', 'Expanded Recents',
'Sorting Recents'],
cta: 'Upgrade to Pro', variant: 'green' },
{ id: 'free', name: 'Free', slim: true, price: '$0',
cta: 'Stick with Free', variant: 'gray' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="760px"
@unpop="upsell.updateShowPro(false)">
<!-- GRID (R10): two deliberate panes — pitch | rail -->
<div class="pop-modal pro-modal pro-modal--split">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<div class="__pitch">
<div class="__burst" aria-hidden="true">
<span class="__burst-tile jp-font">🎉</span>
<span class="__burst-tile jp-font">👑</span>
<span class="__burst-tile jp-font">⚡</span>
</div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
</div>
<div class="__rail">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<style scoped>
/* modal shell — grid, two panes; the page's first left-aligned
title. The green wash moves to the pitch corner so the light
follows the words; still metal-950 + green light, never doodle. */
.pro-modal--split {
position: relative;
display: grid;
grid-template-columns: 1fr 1.15fr;
align-items: stretch; /* pop-modal centers items; panes fill */
gap: var(--sp-6);
max-width: 760px;
padding: 32px;
background:
radial-gradient(70% 62% at 22% 18%, rgba(133, 252, 100, .10), transparent 64%),
var(--metal-950);
.pop-modal__title {
font-size: 3.2rem;
line-height: 1.04;
text-align: left;
}
.__pitch {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: var(--sp-4);
text-align: left;
}
.__burst {
display: flex;
align-items: flex-end;
justify-content: flex-start;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text>; ink pinned
on the fixed-dark shell (page-documented carve-out) */
.__sub {
margin: 0;
text-align: left;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__rail {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
@media screen and (max-width: 800px) {
grid-template-columns: 1fr;
gap: var(--sp-4);
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; text-align: center; }
.__pitch { align-items: center; text-align: center; }
.__burst { justify-content: center; }
.__sub { text-align: center; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal--split .__burst-tile { animation: none; }
}
/* PlanCard scoped additions — the slim Free row */
.plan-card--slim {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
.__name { font-size: 1.6rem; }
.__price { font-size: 1.8rem; }
.button { width: auto; }
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): the beta first-500 offer ended at GA —
// no discount on launch surfaces (PLANS-PRICING.md). Prices are the
// launch list, $5/mo · $48/yr; annual rides in the price term.
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Save favorites', 'Curate collections', 'Sync across devices'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆',
price: '$5', term: '/mo or $48/yr',
bullets: ['Custom curated lists', 'Smart search', 'No ads, ever'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<div class="pop-modal pro-modal">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- celebration layer — pure decoration, JoyPixels only -->
<span class="__spark __spark--tl jp-font" aria-hidden="true">✨</span>
<span class="__spark __spark--tr jp-font" aria-hidden="true">✨</span>
<div class="__burst" aria-hidden="true">
<span class="__burst-tile jp-font">🎉</span>
<span class="__burst-tile jp-font">👑</span>
<span class="__burst-tile jp-font">⚡</span>
</div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<div class="__bullets">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.hero-dark.png — EXISTING marketing meshup (already shipping in the beta-invite email); composed for a black background. Bundle the asset or reuse the hosted copy..__mesh scoped CSS — new styling (included in the CSS pane below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): the beta first-500 offer ended at GA —
// no discount on launch surfaces (PLANS-PRICING.md). Prices are the
// launch list, $5/mo · $48/yr; the 🏆 sticker is back on the Pro
// card corner (the offer badge retired with the offer).
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Save favorites', 'Curate collections', 'Sync across devices'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆',
price: '$5', term: '/mo or $48/yr',
bullets: ['Custom curated lists', 'Smart search', 'No ads, ever'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<!-- mesh modifier baked in; bind it instead
(:class="{ 'pro-modal--mesh': meshVariant }") if the
burst treatment stays available as a config -->
<div class="pop-modal pro-modal pro-modal--mesh">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- hero band — replaces the celebration layer (sparks +
burst + top badge); pure decoration: aria-hidden,
pointer-events none, empty of content -->
<div class="__mesh" aria-hidden="true"></div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<div class="__bullets">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
/* modifier at top level — native nesting doesn't support `&--x`.
Bind with :class="{ 'pro-modal--mesh': meshVariant }" (or bake it
in if C ships as THE design). */
.pro-modal--mesh {
/* bigger title — the band gives it room (review ask) */
.pop-modal__title { font-size: 4rem; }
/* close button rides above the band, with a solid backing so it
reads over the imagery (the default translucent circle blends) */
.pop-modal__close {
z-index: 1;
background: color-mix(in srgb, var(--metal-950) 78%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
backdrop-filter: blur(6px);
}
.__mesh {
flex: none;
width: calc(100% + 56px); /* full-bleed past the 28px side padding */
height: 150px; /* shorter band → shorter modal (review ask) */
margin: -32px -28px -10px; /* pull to the modal's top edge */
border-radius: var(--r-lg) var(--r-lg) 0 0;
/* the logo sits ~9% down the source image, so a LOW position
value reveals its full top with room above; higher values
scroll past the top and clip the logo. Lands upper-third. */
background: url('@/assets/hero-dark.png') center 6% / cover no-repeat;
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
pointer-events: none;
}
@media screen and (max-width: 800px) {
.pop-modal__title { font-size: 3rem; }
.__mesh {
width: calc(100% + 36px); /* mobile padding is 26/18 */
height: 108px;
margin: -26px -18px -8px;
background-position: center 6%; /* same low value → full logo */
}
}
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.hero-dark.png — EXISTING marketing meshup (already shipping in the beta-invite email); composed for a black background. Bundle the asset or reuse the hosted copy..__mesh scoped CSS — new styling (included in the CSS pane below).__bullets--2col — new CSS (grid, collapses to 1 column on mobile; included in the CSS pane below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// LAUNCH copy (2026-08-11): no discount at GA; $5/mo · $48/yr. The
// 6 Pro labels are PROVISIONAL — trimmed so each holds one line in
// the 2-col grid; all real Pro features (../PLANS-PRICING.md), final
// wording is an editorial call. `cols: 2` opts Pro's list into the
// grid; the 🏆 sticker sits on the Pro card corner.
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Save favorites', 'Curate collections', 'Sync across devices'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆', cols: 2,
price: '$5', term: '/mo or $48/yr',
bullets: ['Custom lists', 'Smart search', 'Unlimited combos',
'Smart Recents', 'XL icon sizes', 'No ads, ever'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<!-- mesh modifier baked in; bind it instead
(:class="{ 'pro-modal--mesh': meshVariant }") if the
burst treatment stays available as a config -->
<div class="pop-modal pro-modal pro-modal--mesh">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- hero band — replaces the celebration layer (sparks +
burst + top badge); pure decoration: aria-hidden,
pointer-events none, empty of content -->
<div class="__mesh" aria-hidden="true"></div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<!-- 2-col modifier when plan.cols === 2 (Pro here) -->
<div class="__bullets"
:class="{ '__bullets--2col': plan.cols === 2 }">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
/* modifier at top level — native nesting doesn't support `&--x`.
Bind with :class="{ 'pro-modal--mesh': meshVariant }" (or bake it
in if C ships as THE design). */
.pro-modal--mesh {
/* bigger title — the band gives it room (review ask) */
.pop-modal__title { font-size: 4rem; }
/* close button rides above the band, with a solid backing so it
reads over the imagery (the default translucent circle blends) */
.pop-modal__close {
z-index: 1;
background: color-mix(in srgb, var(--metal-950) 78%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
backdrop-filter: blur(6px);
}
.__mesh {
flex: none;
width: calc(100% + 56px); /* full-bleed past the 28px side padding */
height: 150px; /* shorter band → shorter modal (review ask) */
margin: -32px -28px -10px; /* pull to the modal's top edge */
border-radius: var(--r-lg) var(--r-lg) 0 0;
/* the logo sits ~9% down the source image, so a LOW position
value reveals its full top with room above; higher values
scroll past the top and clip the logo. Lands upper-third. */
background: url('@/assets/hero-dark.png') center 6% / cover no-repeat;
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
pointer-events: none;
}
@media screen and (max-width: 800px) {
.pop-modal__title { font-size: 3rem; }
.__mesh {
width: calc(100% + 36px); /* mobile padding is 26/18 */
height: 108px;
margin: -26px -18px -8px;
background-position: center 6%; /* same low value → full logo */
}
}
}
/* D · Pro's 6 bullets in a 2-column grid (plan.cols === 2) —
same 3-row height as Free's single column of 3 */
.plan-card .__bullets--2col {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 12px;
row-gap: 10px;
align-content: start;
/* tighter type so each label holds one line → even 3-row block */
.bullet-row { font-size: 1.3rem; }
.check-circle { width: 15px; height: 15px; }
}
@media screen and (max-width: 800px) {
.plan-card .__bullets--2col { grid-template-columns: 1fr; }
}
</style>
Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr.
ui/modals/ProModal.vue, latest client; opened by upsell.updateShowPro(true)). Update in place — same open/close wiring, new body + launch copy.<PopModal> · <PrimaryButton> · <Text> · <BulletRow><PlanCard> — SHIPPED in the latest client (ui/components/PlanCard.vue: props title/bullets/bulletColor/price1/price2 + #button slot). The pane below EXTENDS it — term text in the price row, corner sticker, Free check color; diff against the shipped file, don't rebuild it.assets/pro-modal-band.svg — dedicated no-logo band art (pre-cropped, dark-composed like hero-dark.png).pro-modal--band-lower — swaps the band image + pins it to the top (included in the CSS pane below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Free/Pro bullets = the FINAL set (2026-07-15), added verbatim;
// they pair as limit → upgrade, so both lists stay single
// column. Title/sub/prices/CTAs = the LAUNCH copy (2026-08-11):
// no discount at GA, $5/mo · $48/yr; 🏆 sticker on the Pro corner.
const PLANS = [
{ id: 'free', name: 'Free', price: '$0',
bullets: ['Ad-Supported', '6 Collections',
'1 Favorites List', 'Up to 24 Recents'],
cta: 'Stick with Free', variant: 'gray' },
{ id: 'pro', name: 'Pro', sticker: '🏆',
price: '$5', term: '/mo or $48/yr',
bullets: ['Ad-free Experience', 'Unlimited Lists',
'Unlimited Collections', 'Expanded Recents',
'Sorting Recents'],
cta: 'Upgrade to Pro', variant: 'green' },
]
const act = (plan) => plan.id === 'pro'
? emit('go-pro') // → checkout, same route as today
: upsell.updateShowPro(false) // "Stick with Free" just closes
</script>
<template>
<PopModal :popped="upsell.showPro" width="720px"
@unpop="upsell.updateShowPro(false)">
<!-- mesh + band-lower modifiers baked in (band-lower swaps
the band art to the dedicated no-logo asset) -->
<div class="pop-modal pro-modal pro-modal--mesh pro-modal--band-lower">
<button class="pop-modal__close"
@click="upsell.updateShowPro(false)">×</button>
<!-- no-logo hero band — pure decoration: aria-hidden,
pointer-events none, empty of content -->
<div class="__mesh" aria-hidden="true"></div>
<Text tag="h3" class="pop-modal__title" text="Get more with Pro" />
<!-- type carries the scale (1.5rem/500 — the mockup's sub
weight); the scoped .__sub rule only pins layout + ink -->
<Text tag="p" type="body/l-med" class="__sub"
text="Everything in Free, with the ads gone and the caps lifted. $5/mo or $48/yr." />
<div class="__plans">
<PlanCard v-for="plan in PLANS" :key="plan.id"
:plan="plan" @select="act(plan)" />
</div>
</div>
</PopModal>
</template>
<script setup>
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import BulletRow from '@/components/BulletRow.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<span v-if="plan.sticker" class="__sticker jp-font"
aria-hidden="true">{{ plan.sticker }}</span>
<span class="__name">{{ plan.name }}</span>
<!-- div, not <ul>: BulletRow's root is a <div> (invalid as a
<ul> child). check-color: give BulletRow this optional
prop (one-line passthrough to Icon's color — Icon paints
via an inline --clr, so parent CSS can't reach it; a prop
is the only clean path). Free = quiet gray checks per the
mockup; Pro keeps BulletRow's green default. -->
<div class="__bullets">
<BulletRow v-for="b in plan.bullets" :key="b" :text="b"
:check-color="plan.id === 'free'
? 'rgba(255, 255, 255, .45)' : undefined" />
</div>
<span class="__price">
<span v-if="plan.was" class="__was">{{ plan.was }}</span>{{ plan.price }}<span v-if="plan.term" class="__term">{{ plan.term }}</span>
</span>
<PrimaryButton :variant="plan.variant" size="md"
:label="plan.cta" @click="emit('select')" />
</div>
</template>
<style scoped>
/* modal body (in the Pro modal component) */
.pro-modal {
position: relative;
max-width: 720px;
padding: 32px 28px 28px;
gap: 14px;
/* green LIGHT on the dark shell — spotlight, not doodle (doodle
bg stays reserved for Explore + collections) */
background:
radial-gradient(58% 44% at 50% 0%, rgba(133, 252, 100, .10), transparent 62%),
var(--metal-950);
.pop-modal__title { font-size: 3.4rem; }
.__spark {
position: absolute;
font-size: 2rem;
line-height: 1;
opacity: .45;
rotate: -14deg;
pointer-events: none;
}
.__spark--tl { top: 22px; left: 24px; }
.__spark--tr { top: 74px; right: 30px; rotate: 12deg; font-size: 1.5rem; }
/* emoji burst — the upsell strip's tilted-tile language, scaled
up; gentle float, straighten on hover (same as the strip) */
.__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 2px;
}
.__burst-tile {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 3rem;
line-height: 1;
rotate: -8deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
+ .__burst-tile { margin-left: -8px; }
&:nth-child(2) {
width: 62px;
height: 62px;
font-size: 3.4rem;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
&:nth-child(3) { rotate: 8deg; animation-delay: .8s; }
}
&:hover .__burst-tile { rotate: 0deg; }
/* scale comes from type="body/l-med" on the <Text> — this rule
only pins layout + ink (--c-text-white flips to metal-950 in
dark scheme and vanishes on this fixed-dark shell; same pin
as the strip) */
.__sub {
margin: -2px 0 0;
max-width: 52ch;
text-align: center;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.__plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
align-items: stretch;
width: 100%;
margin-top: 8px;
}
@media screen and (max-width: 800px) {
padding: 26px 18px 18px;
.pop-modal__title { font-size: 2.6rem; }
.__plans { grid-template-columns: 1fr; }
.__burst-tile { width: 46px; height: 46px; font-size: 2.4rem; }
.__burst-tile:nth-child(2) { width: 52px; height: 52px; font-size: 2.8rem; }
}
}
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pro-modal .__burst-tile { animation: none; }
}
/* PlanCard.vue — same inner-surface recipe as .pop-modal__content */
.plan-card {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
.__name {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2rem;
line-height: 1;
letter-spacing: -.02em;
color: var(--white);
}
.__bullets {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1; /* price + CTA pin to the card floor */
}
.bullet-row {
color: color-mix(in srgb, var(--white) 85%, transparent);
}
.__price {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.4rem;
line-height: 1;
letter-spacing: -.03em;
color: var(--white);
}
.__was {
margin-right: 8px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 45%, transparent);
text-decoration: line-through;
}
.__term {
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.4rem;
letter-spacing: 0;
color: color-mix(in srgb, var(--white) 64%, transparent);
}
.button {
width: 100%;
justify-content: center;
}
}
/* modifiers at top level — native nesting doesn't support `&--x`.
NOTE: no CSS override for the Free card's gray checks — Icon's
inline --clr beats any stylesheet rule, and .check-circle lives in
BulletRow's scope anyway. The gray comes from the :check-color
prop in the template above. */
.plan-card--pro {
position: relative;
background:
radial-gradient(130% 95% at 50% 0%, rgba(48, 217, 9, .16), transparent 58%),
color-mix(in srgb, var(--metal-300) 16%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--green-600) 60%, transparent),
0 12px 48px rgba(48, 217, 9, .18);
.__sticker {
position: absolute;
top: -16px;
right: -12px;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--metal-950);
border: 1px solid color-mix(in srgb, var(--green-600) 60%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
font-size: 2.5rem;
line-height: 1;
rotate: 9deg;
transition: rotate .2s;
pointer-events: none;
}
}
/* sticker straightens with the burst on modal hover — an ancestor
selector outside the component is fine in scoped CSS (the data-v
attribute lands on the last compound selector) */
.pro-modal:hover .plan-card--pro .__sticker {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro { order: -1; } /* Pro reads first */
.plan-card--pro .__sticker { right: -6px; }
}
/* modifier at top level — native nesting doesn't support `&--x`.
Bind with :class="{ 'pro-modal--mesh': meshVariant }" (or bake it
in if C ships as THE design). */
.pro-modal--mesh {
/* bigger title — the band gives it room (review ask) */
.pop-modal__title { font-size: 4rem; }
/* close button rides above the band, with a solid backing so it
reads over the imagery (the default translucent circle blends) */
.pop-modal__close {
z-index: 1;
background: color-mix(in srgb, var(--metal-950) 78%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
backdrop-filter: blur(6px);
}
.__mesh {
flex: none;
width: calc(100% + 56px); /* full-bleed past the 28px side padding */
height: 150px; /* shorter band → shorter modal (review ask) */
margin: -32px -28px -10px; /* pull to the modal's top edge */
border-radius: var(--r-lg) var(--r-lg) 0 0;
/* the logo sits ~9% down the source image, so a LOW position
value reveals its full top with room above; higher values
scroll past the top and clip the logo. Lands upper-third. */
background: url('@/assets/hero-dark.png') center 6% / cover no-repeat;
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
mask-image: linear-gradient(to bottom, #000 40%, transparent 98%);
pointer-events: none;
}
@media screen and (max-width: 800px) {
.pop-modal__title { font-size: 3rem; }
.__mesh {
width: calc(100% + 36px); /* mobile padding is 26/18 */
height: 108px;
margin: -26px -18px -8px;
background-position: center 6%; /* same low value → full logo */
}
}
}
/* the band uses the dedicated no-logo asset. It's TALL and runs
down behind the title: the big negative bottom margin pulls the
title/sub up over the band's faded lower half (title is later in
the DOM with no bg, so it paints on top of the soft art). The
mask eases gently — solid only at the very top, ~15% ghost behind
the title, gone by the cards. Plan cards are single-column so the
paired Free-limit → Pro-upgrade rows read straight across. */
.pro-modal--band-lower .__mesh {
background-image: url('@/assets/pro-modal-band.svg');
background-position: center top;
height: 300px; /* taller → shows more of the app */
margin: -32px -28px -175px; /* bottom pull → content overlaps the band */
-webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 10%, rgba(0,0,0,.42) 30%, rgba(0,0,0,.12) 50%, transparent 82%);
mask-image: linear-gradient(to bottom, #000 0%, #000 10%, rgba(0,0,0,.42) 30%, rgba(0,0,0,.12) 50%, transparent 82%);
}
@media screen and (max-width: 800px) {
.pro-modal--band-lower .__mesh { height: 220px; margin: -26px -18px -128px; }
}
</style>