The Free-vs-Pro "Special offer" modal the upsell strip opens.
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import Pill from '@/components/Pill.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Copy is verbatim from the shipped modal — content confirmed
// 2026-07-15; this change is presentation-only. The badge restates
// the sub's own fact (no new claims).
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: '🏆',
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Custom curated lists', 'Smart search', 'No ads, ever'],
cta: 'Get 50% off now', 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>
<!-- ⚡ inside the text prop = the DOCUMENTED R7 exception
(DEV-NOTES.md "Pill text props — emoji not wrapped in
jp-font"): Pill has no slot, so the parent can't wrap
it. The mockup shows the intended render (jp-font bolt,
text node = "50% off for life"); resolving needs the
Pill API change already tracked with the dev. -->
<Pill variant="beta" class="__offer-pill"
text="⚡ 50% off for life" />
<Text tag="h3" class="pop-modal__title" text="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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>
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import Pill from '@/components/Pill.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Base copy is verbatim from the shipped modal (confirmed
// 2026-07-15). B ADDS bullets from the ../PLANS-PRICING.md
// feature lists — suggestion only; pick A if the copy must
// stay frozen. Combo SAVING sits under Free (a Free-plan
// feature; Pro's edge is unlimited + recents). The badge
// restates the sub's own fact (no new claims).
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: '🏆',
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Custom curated lists', 'Smart search',
'Unlimited saved combos + recents',
'Smart Recents — most-used first', 'No ads, ever'],
cta: 'Get 50% off now', 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>
<!-- ⚡ inside the text prop = the DOCUMENTED R7 exception
(DEV-NOTES.md "Pill text props — emoji not wrapped in
jp-font"): Pill has no slot, so the parent can't wrap
it. The mockup shows the intended render (jp-font bolt,
text node = "50% off for life"); resolving needs the
Pill API change already tracked with the dev. -->
<Pill variant="beta" class="__offer-pill"
text="⚡ 50% off for life" />
<Text tag="h3" class="pop-modal__title" text="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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>
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)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 + corner deal-badge 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 Pill from '@/components/Pill.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Copy is verbatim from the shipped modal — content confirmed
// 2026-07-15; this change is presentation-only. The corner deal
// badge restates the sub's own fact (no new claims); `deal`
// replaces the 🏆 sticker on the Pro card.
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', deal: '⚡ 50% off for life',
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Custom curated lists', 'Smart search', 'No ads, ever'],
cta: 'Get 50% off now', 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="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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'
import Pill from '@/components/Pill.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<!-- the deal badge renders in the sticker slot; ⚡ inside the
text prop = the DOCUMENTED R7 exception (DEV-NOTES.md
"Pill text props — emoji not wrapped in jp-font"): the
mockup shows the intended render (jp-font bolt, text
node = "50% off for life"). -->
<Pill v-if="plan.deal" variant="beta" class="__deal"
:text="plan.deal" />
<span v-else-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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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 "Special offer" 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 */
}
}
}
/* PlanCard scoped additions — the deal badge rides the Pro card
corner in the sticker slot (positioned by the card's
position: relative) */
.plan-card--pro .__deal {
position: absolute;
top: -14px;
right: -12px;
rotate: 4deg;
transition: rotate .2s;
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
}
/* straightens on modal hover like the A/B sticker does */
.pro-modal:hover .plan-card--pro .__deal {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro .__deal { right: -6px; }
}
</style>
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)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 + corner deal-badge 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 Pill from '@/components/Pill.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Free copy is verbatim from the shipped modal. 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; `deal` puts the offer badge 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', deal: '⚡ 50% off for life', cols: 2,
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Custom lists', 'Smart search', 'Unlimited combos',
'Smart Recents', 'XL icon sizes', 'No ads, ever'],
cta: 'Get 50% off now', 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="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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'
import Pill from '@/components/Pill.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<!-- the deal badge renders in the sticker slot; ⚡ inside the
text prop = the DOCUMENTED R7 exception (DEV-NOTES.md
"Pill text props — emoji not wrapped in jp-font"): the
mockup shows the intended render (jp-font bolt, text
node = "50% off for life"). -->
<Pill v-if="plan.deal" variant="beta" class="__deal"
:text="plan.deal" />
<span v-else-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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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 "Special offer" 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 */
}
}
}
/* PlanCard scoped additions — the deal badge rides the Pro card
corner in the sticker slot (positioned by the card's
position: relative) */
.plan-card--pro .__deal {
position: absolute;
top: -14px;
right: -12px;
rotate: 4deg;
transition: rotate .2s;
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
}
/* straightens on modal hover like the A/B sticker does */
.pro-modal:hover .plan-card--pro .__deal {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro .__deal { right: -6px; }
}
/* 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>
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)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 Pill from '@/components/Pill.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 shipped copy. `deal`
// puts the offer badge on the Pro card 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', deal: '⚡ 50% off for life',
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Ad-free Experience', 'Unlimited Lists',
'Unlimited Collections', 'Expanded Recents',
'Sorting Recents'],
cta: 'Get 50% off now', 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="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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'
import Pill from '@/components/Pill.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<!-- the deal badge renders in the sticker slot; ⚡ inside the
text prop = the DOCUMENTED R7 exception (DEV-NOTES.md
"Pill text props — emoji not wrapped in jp-font"): the
mockup shows the intended render (jp-font bolt, text
node = "50% off for life"). -->
<Pill v-if="plan.deal" variant="beta" class="__deal"
:text="plan.deal" />
<span v-else-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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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 "Special offer" 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 */
}
}
}
/* PlanCard scoped additions — the deal badge rides the Pro card
corner in the sticker slot (positioned by the card's
position: relative) */
.plan-card--pro .__deal {
position: absolute;
top: -14px;
right: -12px;
rotate: 4deg;
transition: rotate .2s;
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
}
/* straightens on modal hover like the A/B sticker does */
.pro-modal:hover .plan-card--pro .__deal {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro .__deal { right: -6px; }
}
/* 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>
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import Pill from '@/components/Pill.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Copy is verbatim from the shipped modal — content confirmed
// 2026-07-15; this change is presentation-only. The badge restates
// the sub's own fact (no new claims).
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: '🏆',
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Custom curated lists', 'Smart search', 'No ads, ever'],
cta: 'Get 50% off now', 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>
<!-- ⚡ inside the text prop = the DOCUMENTED R7 exception
(DEV-NOTES.md "Pill text props — emoji not wrapped in
jp-font"): Pill has no slot, so the parent can't wrap
it. The mockup shows the intended render (jp-font bolt,
text node = "50% off for life"); resolving needs the
Pill API change already tracked with the dev. -->
<Pill variant="beta" class="__offer-pill"
text="⚡ 50% off for life" />
<Text tag="h3" class="pop-modal__title" text="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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>
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)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 + corner deal-badge 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 Pill from '@/components/Pill.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Copy is verbatim from the shipped modal — content confirmed
// 2026-07-15; this change is presentation-only. The corner deal
// badge restates the sub's own fact (no new claims); `deal`
// replaces the 🏆 sticker on the Pro card.
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', deal: '⚡ 50% off for life',
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Custom curated lists', 'Smart search', 'No ads, ever'],
cta: 'Get 50% off now', 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="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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'
import Pill from '@/components/Pill.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<!-- the deal badge renders in the sticker slot; ⚡ inside the
text prop = the DOCUMENTED R7 exception (DEV-NOTES.md
"Pill text props — emoji not wrapped in jp-font"): the
mockup shows the intended render (jp-font bolt, text
node = "50% off for life"). -->
<Pill v-if="plan.deal" variant="beta" class="__deal"
:text="plan.deal" />
<span v-else-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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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 "Special offer" 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 */
}
}
}
/* PlanCard scoped additions — the deal badge rides the Pro card
corner in the sticker slot (positioned by the card's
position: relative) */
.plan-card--pro .__deal {
position: absolute;
top: -14px;
right: -12px;
rotate: 4deg;
transition: rotate .2s;
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
}
/* straightens on modal hover like the A/B sticker does */
.pro-modal:hover .plan-card--pro .__deal {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro .__deal { right: -6px; }
}
</style>
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)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 + corner deal-badge 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 Pill from '@/components/Pill.vue'
import PlanCard from '@/components/PlanCard.vue'
import { useUpsellStore } from '@/store/upsell'
const upsell = useUpsellStore()
const emit = defineEmits(['go-pro'])
// Free copy is verbatim from the shipped modal. 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; `deal` puts the offer badge 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', deal: '⚡ 50% off for life', cols: 2,
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Custom lists', 'Smart search', 'Unlimited combos',
'Smart Recents', 'XL icon sizes', 'No ads, ever'],
cta: 'Get 50% off now', 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="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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'
import Pill from '@/components/Pill.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<!-- the deal badge renders in the sticker slot; ⚡ inside the
text prop = the DOCUMENTED R7 exception (DEV-NOTES.md
"Pill text props — emoji not wrapped in jp-font"): the
mockup shows the intended render (jp-font bolt, text
node = "50% off for life"). -->
<Pill v-if="plan.deal" variant="beta" class="__deal"
:text="plan.deal" />
<span v-else-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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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 "Special offer" 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 */
}
}
}
/* PlanCard scoped additions — the deal badge rides the Pro card
corner in the sticker slot (positioned by the card's
position: relative) */
.plan-card--pro .__deal {
position: absolute;
top: -14px;
right: -12px;
rotate: 4deg;
transition: rotate .2s;
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
}
/* straightens on modal hover like the A/B sticker does */
.pro-modal:hover .plan-card--pro .__deal {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro .__deal { right: -6px; }
}
/* 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>
Get more with Pro—50% off for life—only during beta.
upsell.updateShowPro(true) renders; live on beta.emojicopy.com, not yet in this repo copy). Update in place — same open/close wiring, new body.<PopModal> · <PrimaryButton> · <Text> · <BulletRow> · <Pill variant="beta"><PlanCard> — new atom (Vue + scoped CSS below)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 Pill from '@/components/Pill.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 shipped copy. `deal`
// puts the offer badge on the Pro card 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', deal: '⚡ 50% off for life',
was: '$48', price: '$24', term: '/yr for life',
bullets: ['Ad-free Experience', 'Unlimited Lists',
'Unlimited Collections', 'Expanded Recents',
'Sorting Recents'],
cta: 'Get 50% off now', 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="Special offer" />
<!-- 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="Get more with Pro—50% off for life—only during beta." />
<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'
import Pill from '@/components/Pill.vue'
const props = defineProps({ plan: { type: Object, required: true } })
const emit = defineEmits(['select'])
</script>
<template>
<div class="plan-card" :class="`plan-card--${plan.id}`">
<!-- the deal badge renders in the sticker slot; ⚡ inside the
text prop = the DOCUMENTED R7 exception (DEV-NOTES.md
"Pill text props — emoji not wrapped in jp-font"): the
mockup shows the intended render (jp-font bolt, text
node = "50% off for life"). -->
<Pill v-if="plan.deal" variant="beta" class="__deal"
:text="plan.deal" />
<span v-else-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; }
.__offer-pill {
font-size: 1.2rem;
padding: 7px 14px;
gap: 8px;
rotate: -2deg;
}
/* 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 "Special offer" 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 */
}
}
}
/* PlanCard scoped additions — the deal badge rides the Pro card
corner in the sticker slot (positioned by the card's
position: relative) */
.plan-card--pro .__deal {
position: absolute;
top: -14px;
right: -12px;
rotate: 4deg;
transition: rotate .2s;
box-shadow: 0 8px 20px rgba(0, 0, 0, .45);
}
/* straightens on modal hover like the A/B sticker does */
.pro-modal:hover .plan-card--pro .__deal {
rotate: 0deg;
}
@media screen and (max-width: 800px) {
.plan-card--pro .__deal { right: -6px; }
}
/* 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>