One <PaywallModal> drives the 7 Pro gates a Free user can hit. Guests get the
Create a free account modal instead.
How this page works: every card is complete on its own — open the picked card and its drawer holds the full <PaywallModal> Vue + CSS spec, that trigger's parent invocation, and the behavior notes. trigger drives layout + copy; every config is a Pro gate.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// Pro gates ONLY — account-required cases open AccountRequiredModal
// (account-modal.html) before a guest ever reaches this component.
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const price = { now: '$5', term: '/mo or $48/yr' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>Pro feature</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice :now="price.now" :term="price.term" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="sort-smart"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
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);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
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);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
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);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
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);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__term {
margin-left: 6px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 65%, transparent);
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// Pro gates ONLY — account-required cases open AccountRequiredModal
// (account-modal.html) before a guest ever reaches this component.
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const price = { now: '$5', term: '/mo or $48/yr' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>Pro feature</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice :now="price.now" :term="price.term" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="create-list"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
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);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
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);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
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);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
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);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__term {
margin-left: 6px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 65%, transparent);
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Create additional lists and access the perfect emoji more quickly.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// Pro gates ONLY — account-required cases open AccountRequiredModal
// (account-modal.html) before a guest ever reaches this component.
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const price = { now: '$5', term: '/mo or $48/yr' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>Pro feature</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice :now="price.now" :term="price.term" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="sort-lists"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
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);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
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);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
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);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
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);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__term {
margin-left: 6px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 65%, transparent);
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// Pro gates ONLY — account-required cases open AccountRequiredModal
// (account-modal.html) before a guest ever reaches this component.
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const price = { now: '$5', term: '/mo or $48/yr' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>Pro feature</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice :now="price.now" :term="price.term" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="save-combo"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
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);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
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);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
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);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
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);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__term {
margin-left: 6px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 65%, transparent);
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Rename this list or create your own lists with a pro account.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// Pro gates ONLY — account-required cases open AccountRequiredModal
// (account-modal.html) before a guest ever reaches this component.
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const price = { now: '$5', term: '/mo or $48/yr' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>Pro feature</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice :now="price.now" :term="price.term" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="edit-list"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
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);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
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);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
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);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
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);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__term {
margin-left: 6px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 65%, transparent);
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Use this as a starting point for your own custom list.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// Pro gates ONLY — account-required cases open AccountRequiredModal
// (account-modal.html) before a guest ever reaches this component.
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const price = { now: '$5', term: '/mo or $48/yr' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>Pro feature</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice :now="price.now" :term="price.term" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="clone-collection"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
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);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
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);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
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);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
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);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__term {
margin-left: 6px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 65%, transparent);
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Remove this list, or customize it and make it your own.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// Pro gates ONLY — account-required cases open AccountRequiredModal
// (account-modal.html) before a guest ever reaches this component.
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const price = { now: '$5', term: '/mo or $48/yr' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>Pro feature</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice :now="price.now" :term="price.term" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="delete-list"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
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);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
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);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
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);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
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);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__term {
margin-left: 6px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 65%, transparent);
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>