The welcome cards in the extension popup, all at the shipping 600 × 500 panel. The first-run card with its burst option, and the update welcome for the JoyPixels to EmojiCopy change.
The install-tab page is Extension install page. The in-popup Pro gates are on Extension upsell.
Get the most out of EmojiCopy for free.
Guests get the free-account ask. The account pitch itself lives on the Create a free account modal; this card stays a greeting.
ExtensionWelcome · new — one component, both states; renders in place of the grid on first open, never as an overlayPrimaryButton · ui/buttons/PrimaryButton.vue — variant="green" size="md", renders f:body/m-heavy, exactly the stage's classesText · ui/components/Text.vue — both strings. Title tag="h6" (DM Sans 900, -.04em, lh 1.1em), line tag="p" type="body/l-reg" / text-secondary — the same pairing AlertModal.vue usesTextButton · ui/buttons/TextButton.vue — the skip, size="md" → f:body/m-heavy; one scoped line pins the label to tertiary inkuseAccountStore · ui/store/account.js — tier picks the state: 'guest' = this card, anything else = card B1f44b.ext-welcome column · art, title, line, CTA, skip on one axis — gap owns every space (R10, R11)<script setup>
import { computed } from 'vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import TextButton from '@/buttons/TextButton.vue'
import Text from '@/components/Text.vue'
import { useAccountStore } from '@/store/account'
import { getEnv } from '@shared/config/env'
import { setProp } from '@shared/storage/local'
const emit = defineEmits(['done'])
const account = useAccountStore()
const signedIn = computed(() => account.tier !== 'guest')
const finish = () => {
setProp('ext_welcome_seen', '1')
emit('done')
}
/* auth always runs in a web tab — the popup unmounts on focus loss,
so no flow may live inside it */
const createAccount = () => {
window.open(getEnv('OAUTH_BASE_URL') + '/join')
finish()
}
</script>
<template>
<div class="ext-welcome">
<img class="__art jp-font"
src="https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/1f44b.png" alt="">
<!-- title + line are the app's own styles: tag="h6" is the
heading ramp (DM Sans 900), body/l-reg the message ramp —
the exact pairing AlertModal.vue uses -->
<Text tag="h6" class="__title" text="Welcome to the new EmojiCopy extension" />
<Text v-if="!signedIn" tag="p" type="body/l-reg" color="text-secondary" class="__line"
text="Get the most out of EmojiCopy for free." />
<Text v-else tag="p" type="body/l-reg" color="text-secondary" class="__line"
text="You're signed in. Favorites, combos, and settings are already synced." />
<PrimaryButton v-if="!signedIn" variant="green" size="md"
label="Create a free account" @click="createAccount" />
<PrimaryButton v-else variant="green" size="md"
label="Start copying" @click="finish" />
<TextButton v-if="!signedIn" class="__skip" size="md"
label="Not now" @click="finish" />
</div>
</template>
<!-- extension App.vue — before the grid mounts -->
<ExtensionWelcome v-if="showWelcome" @done="showWelcome = false" />
// showWelcome = runtime() === 'extension' && !(await getProp('ext_welcome_seen'))
// checked once per boot; the done handler is the only writer
/* light app surface on purpose — onboarding, not sales. Theme-aware
tokens so the card follows the popup's scheme. */
.ext-welcome {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-4);
padding: 40px 28px 32px;
text-align: center;
}
.ext-welcome .__art { width: 56px; height: 56px; }
/* NO type declarations in here. Title = <Text tag="h6">, line =
body/l-reg, skip = TextButton size="md" (body/m-heavy). At the
welcome's 600px panel the fluid ramp resolves each just above its
floor: 2.9rem title, 1.54rem line, 1.32rem labels. Only measures
and the fill are scoped. */
.ext-welcome {
flex: 1; /* fills and centers in the 600 × 500 panel */
justify-content: center;
}
.ext-welcome .__title { max-width: 18ch; }
.ext-welcome .__line { max-width: 30ch; }
/* TextButton ships no quiet-gray variant; this one line pins the
label ink, the component owns the rest */
.ext-welcome .__skip { --bg: var(--c-text-tertiary); }
.ext-welcome .__skip:hover { --bg: var(--c-text-secondary); }
You're signed in. Favorites, combos, and settings are already synced.
Signed-in users skip the ask entirely — one confirmation line, one exit.
ExtensionWelcome as card A — account.tier !== 'guest' flips the line and the CTA, nothing elsePrimaryButton · ui/buttons/PrimaryButton.vue — variant="green" size="md"Text · ui/components/Text.vue — same two styles as card A: tag="h6" title, body/l-reg lineuseAccountStore · ui/store/account.js — the one check<ExtensionWelcome v-if="showWelcome" @done="showWelcome = false" /> // same mount — the component reads account.tier itself. // Signed-in exits: Start copying → finish() → the grid.
.ext-welcome {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-4);
padding: 40px 28px 32px;
text-align: center;
}
.ext-welcome .__art { width: 56px; height: 56px; }
/* NO type declarations in here. Title = <Text tag="h6">, line =
body/l-reg, skip = TextButton size="md" (body/m-heavy). At the
welcome's 600px panel the fluid ramp resolves each just above its
floor: 2.9rem title, 1.54rem line, 1.32rem labels. Only measures
and the fill are scoped. */
.ext-welcome {
flex: 1; /* fills and centers in the 600 × 500 panel */
justify-content: center;
}
.ext-welcome .__title { max-width: 18ch; }
.ext-welcome .__line { max-width: 30ch; }
/* TextButton ships no quiet-gray variant; this one line pins the
label ink, the component owns the rest */
.ext-welcome .__skip { --bg: var(--c-text-tertiary); }
.ext-welcome .__skip:hover { --bg: var(--c-text-secondary); }
Get the most out of EmojiCopy for free.
The loudest change for the least new code: one decorative header, and the same four elements underneath it. Tiles float, and straighten when the pointer is over the popup.
ExtensionWelcome · new — card A's component with a __burst header in place of the __art image; states, exits and storage are unchangedText · ui/components/Text.vue — tag="h6" title, body/l-reg line, same as APrimaryButton · ui/buttons/PrimaryButton.vue — variant="green" size="md"TextButton · ui/buttons/TextButton.vue — the skip, size="md"2728 · 1f44b · 1f389, in that order.ext-welcome column · the burst is one more child on the same axis, so the gap keeps owning every space (R10, R11)<template>
<div class="ext-welcome burst">
<!-- decoration: aria-hidden, and the card reads identically
without it -->
<div class="__burst jp-font" aria-hidden="true">
<img v-for="cp in ['2728', '1f44b', '1f389']" :key="cp"
:src="`https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/${cp}.png`" alt="">
</div>
<!-- everything below is card A, unchanged -->
<Text tag="h6" class="__title" text="Welcome to the new EmojiCopy extension" />
<Text v-if="!signedIn" tag="p" type="body/l-reg" color="text-secondary" class="__line"
text="Get the most out of EmojiCopy for free." />
<Text v-else tag="p" type="body/l-reg" color="text-secondary" class="__line"
text="You're signed in. Favorites, combos, and settings are already synced." />
<PrimaryButton v-if="!signedIn" variant="green" size="md"
label="Create a free account" @click="createAccount" />
<PrimaryButton v-else variant="green" size="md"
label="Start copying" @click="finish" />
<TextButton v-if="!signedIn" class="__skip" size="md"
label="Not now" @click="finish" />
</div>
</template>
<ExtensionWelcome v-if="showWelcome" @done="showWelcome = false" /> // the treatment is a class on the component's own root, not a prop — // if C is picked it simply becomes the card, and `burst` goes away.
/* card A's block is unchanged; these are the additions */
.ext-welcome.burst {
padding-top: 34px;
/* light wash, not a pattern — the doodle background stays
reserved for Explore and collections */
background: radial-gradient(120% 78% at 50% 0%,
color-mix(in srgb, var(--green-500) 34%, transparent) 0%,
transparent 64%);
}
.ext-welcome .__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 4px;
}
.ext-welcome .__burst img {
width: 30px;
height: 30px;
padding: 11px;
box-sizing: content-box;
border-radius: var(--r-md);
background: var(--c-surface-primary);
border: 1px solid var(--c-border-primary);
box-shadow: var(--shadow-popover);
rotate: -9deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
}
.ext-welcome .__burst img + img { margin-left: -10px; }
.ext-welcome .__burst img:nth-child(2) {
width: 38px;
height: 38px;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
.ext-welcome .__burst img:nth-child(3) { rotate: 9deg; animation-delay: .8s; }
/* the popup is the hover target, not the tiles — pointing anywhere
in the card settles them */
.popup:hover .ext-welcome .__burst img { rotate: 0deg; }
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.ext-welcome .__burst img { animation: none; }
}
Your emoji keyboard just grew into a whole app! Copying works like always, and there's a lot more to unlock.
Already have an account?
or
The rebrand said plainly, then two ways forward: Continue drops into the grid, Learn more opens screen 2. The account asks stay quiet feet. In the mock, Learn more scrolls to card F.
ExtensionUpdateWelcome · new — one component, two screens (welcome | features); replaces the popup body once, ever, after the update. Separate from the first-run ExtensionWelcome: fresh installs get that card, updated JoyPixels installs get this oneText · ui/components/Text.vue — tag="h6" title, body/l-reg line, body/m-reg + body/s-reg feetPrimaryButton · ui/buttons/PrimaryButton.vue — Continue variant="green" size="md", Learn more variant="gray" size="md"TextButton · ui/buttons/TextButton.vue — Log in variant="green" size="md"; the create link the same, pinned one step down by the scoped __slink line (TextButton ships lg and md only)2728 · 1f973 · 1f389__confetti · new atom — the 🎉 tile's payload: ten pinned-green pieces drifting over the wash; aria-hidden decoration, deletes clean.upd-screen column · burst, title, line, CTA row, two feet on one centered axis — gap owns every space (R10, R11)<script setup>
import { ref, onMounted } from 'vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import TextButton from '@/buttons/TextButton.vue'
import Text from '@/components/Text.vue'
import Pill from '@/components/Pill.vue' /* screen 2's chips */
import { useUpsellStore } from '@/store/upsell'
import { getEnv } from '@shared/config/env'
import { setProp } from '@shared/storage/local'
/* @gate hands the feature name to the shell, which opens the in-popup
GateSheet (extension-upsell.html card B) */
const emit = defineEmits(['done', 'gate'])
const upsell = useUpsellStore()
const screen = ref('welcome') /* 'welcome' | 'features' */
/* one render = seen — the flag writes on mount, so a plain popup
close never reshows the card */
onMounted(() => setProp('ext_update_welcome_seen', '1'))
const finish = () => emit('done')
/* auth always runs in a web tab — the popup unmounts on focus loss,
so no flow may live inside it (same rule as the first-run card) */
const login = () => { window.open(getEnv('OAUTH_BASE_URL') + '/login'); finish() }
const join = () => { window.open(getEnv('OAUTH_BASE_URL') + '/join'); finish() }
const tour = () => { upsell.updateShowTutorial(true); finish() }
const gate = (feature) => emit('gate', feature)
const anywhere = () => { window.open('https://emojicopy.com'); finish() }
</script>
<template>
<div v-if="screen === 'welcome'" class="upd-screen welcome">
<!-- celebration layer: confetti + party burst, both aria-hidden
decoration — the title alone still says everything -->
<div class="__confetti" aria-hidden="true"><i v-for="n in 10" :key="n" /></div>
<div class="__burst jp-font" aria-hidden="true">
<img v-for="cp in ['2728', '1f973', '1f389']" :key="cp"
:src="`https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/${cp}.png`" alt="">
</div>
<Text tag="h6" class="__title" text="JoyPixels is now EmojiCopy" />
<Text tag="p" type="body/l-reg" color="text-secondary" class="__line"
text="Your emoji keyboard just grew into a whole app! Copying works like always, and there's a lot more to unlock." />
<div class="__cta">
<PrimaryButton variant="green" size="md" label="Continue" @click="finish" />
<PrimaryButton variant="gray" size="md" label="Learn more" @click="screen = 'features'" />
</div>
<Text tag="p" type="body/m-reg" color="text-secondary" class="__foot">
Already have an account?
<TextButton variant="green" size="md" label="Log in" @click="login" />
</Text>
<Text tag="p" type="body/s-reg" color="text-tertiary" class="__foot-sub">
or <TextButton variant="green" size="md" class="__slink"
label="create your free account" @click="join" />
</Text>
</div>
<!-- screen 2 template on card F -->
</template>
<!-- extension App.vue — before the grid mounts -->
<ExtensionUpdateWelcome v-if="showUpdateWelcome"
@done="showUpdateWelcome = false"
@gate="openGateSheet" />
// @gate carries the feature name; the shell opens the in-popup
// GateSheet with it (extension-upsell.html card B).
// showUpdateWelcome = runtime() === 'extension'
// && updatedFromJoypixels() // prior-version marker in storage
// && !(await getProp('ext_update_welcome_seen'))
// Fresh installs miss the marker and fall through to ExtensionWelcome.
// While the welcome shows, the popup runs panel-width medium — the
// shipping 600 × 500 (body.panel-width.medium in the built extension's
// CSS); it returns to the standard 385 once the card is done.
/* the two screens share the shell; theme-aware tokens throughout */
.upd-screen {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.upd-screen.welcome {
position: relative; /* anchors the confetti */
justify-content: center;
gap: var(--sp-4);
padding: 24px 48px 20px;
/* C's wash — light radial, never the doodle art */
background: radial-gradient(120% 66% at 50% 0%,
color-mix(in srgb, var(--green-500) 34%, transparent) 0%,
transparent 62%);
}
/* burst tiles — option C's block rescoped from .ext-welcome to this
component's root; otherwise verbatim */
.upd-screen .__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 4px;
}
.upd-screen .__burst img {
width: 30px;
height: 30px;
padding: 11px;
box-sizing: content-box;
border-radius: var(--r-md);
background: var(--c-surface-primary);
border: 1px solid var(--c-border-primary);
box-shadow: var(--shadow-popover);
rotate: -9deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
}
.upd-screen .__burst img + img { margin-left: -10px; }
.upd-screen .__burst img:nth-child(2) {
width: 38px;
height: 38px;
rotate: 0deg;
translate: 0 -10px;
z-index: 1;
animation-delay: .4s;
}
.upd-screen .__burst img:nth-child(3) { rotate: 9deg; animation-delay: .8s; }
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
/* confetti — the 🎉 tile's payload: ten pieces drifting over the
wash. Pinned raw greens (identical in both schemes); decoration
only, the block deletes clean. */
.upd-screen .__confetti { position: absolute; inset: 0; pointer-events: none; }
.upd-screen .__confetti i {
position: absolute;
width: 7px;
height: 11px;
border-radius: 2px;
background: var(--green-500);
animation: confetti-drift 3.6s ease-in-out infinite;
}
.upd-screen .__confetti i:nth-child(1) { left: 13%; top: 22%; rotate: -28deg; background: var(--green-600); }
.upd-screen .__confetti i:nth-child(2) { left: 21%; top: 9%; rotate: 18deg; background: var(--green-400); animation-delay: .5s; }
.upd-screen .__confetti i:nth-child(3) { left: 30%; top: 27%; rotate: -10deg; width: 6px; height: 6px; border-radius: 50%; background: var(--green-800); animation-delay: 1.1s; }
.upd-screen .__confetti i:nth-child(4) { left: 38%; top: 7%; rotate: 34deg; animation-delay: 1.6s; }
.upd-screen .__confetti i:nth-child(5) { left: 50%; top: 4%; rotate: -6deg; width: 6px; height: 6px; border-radius: 50%; background: var(--green-400); animation-delay: .8s; }
.upd-screen .__confetti i:nth-child(6) { left: 61%; top: 8%; rotate: 22deg; background: var(--green-600); animation-delay: 2s; }
.upd-screen .__confetti i:nth-child(7) { left: 70%; top: 25%; rotate: -18deg; width: 6px; height: 6px; border-radius: 50%; animation-delay: .3s; }
.upd-screen .__confetti i:nth-child(8) { left: 79%; top: 10%; rotate: 12deg; background: var(--green-800); animation-delay: 1.4s; }
.upd-screen .__confetti i:nth-child(9) { left: 87%; top: 23%; rotate: -30deg; background: var(--green-400); animation-delay: .9s; }
.upd-screen .__confetti i:nth-child(10) { left: 45%; top: 30%; rotate: 40deg; width: 5px; height: 8px; background: var(--green-600); animation-delay: 2.4s; }
@keyframes confetti-drift {
0%, 100% { transform: translateY(0) }
50% { transform: translateY(-7px) }
}
@media (prefers-reduced-motion: reduce) {
.upd-screen .__burst img,
.upd-screen .__confetti i { animation: none; }
}
.upd-screen .__title { max-width: 22ch; }
.upd-screen .__line { max-width: 44ch; }
.upd-screen .__cta { display: flex; align-items: center; gap: 10px; }
.upd-screen .__foot,
.upd-screen .__foot-sub { display: flex; align-items: center; gap: 6px; }
.upd-screen .__foot-sub { gap: 4px; }
.upd-screen.welcome .__foot-sub { margin-top: calc(-1 * var(--sp-2)); }
/* TextButton ships lg and md only — the quiet create link is size:md
pinned one step down (card A's __skip pattern) */
.upd-screen .__slink { font-size: 1.12rem; }
Every feature is a link: Free rows open the join flow, Pro rows open the gate. Under the list, the web line and the tour. In the mock, Back scrolls up to card E.
ExtensionUpdateWelcome as card E — screen === 'features' renders this template; Back sets it to 'welcome'Pill · ui/components/Pill.vue — the Free / Pro chips, variant="sm", text only, no leading mark; Pro rides background="glass"TextButton · ui/buttons/TextButton.vue — Back variant="menu-back" size="md" (the app's menu-back pattern), the tour variant="green" size="md"TutorialModal · ui/modals/TutorialModal.vue — Take a quick tour opens it via upsell.updateShowTutorial(true)GateSheet · new — defined on extension-upsell.html card B, not yet in the build; Pro rows open it via the component's @gate emit, checkout still leaves the popup2764 1f9e9 1f4da 1f4f1 267e 1f9e0 1f6e1, the web line 1f310.__rows column of single-line row buttons · name flex-none, one-liner truncates, chip flex-none — seven rows hold 500px (R10)<script>
/* rows are plan truth from PLANS-PRICING.md — Free unlocks vs Pro.
Free rows route to the join flow; Pro rows pop the gate sheet. */
const FEATURES = [
{ cp: '2764', name: 'Favorites', line: 'Save your go-to emoji', plan: 'free' },
{ cp: '1f9e9', name: 'Combos', line: 'Copy and save up to 8 at once', plan: 'free' },
{ cp: '1f4da', name: 'Collections', line: 'Follow and sort up to 5', plan: 'free' },
{ cp: '1f4f1', name: 'Sync', line: 'Same setup on every device', plan: 'free' },
{ cp: '267e', name: 'Unlimited lists and combos', line: 'No caps, plus recent combos', plan: 'pro' },
{ cp: '1f9e0', name: 'Smart Recents', line: 'Up to 96, sorted your way', plan: 'pro' },
{ cp: '1f6e1', name: 'Ad free', line: 'No ads anywhere in the app', plan: 'pro' },
]
</script>
<template>
<div v-if="screen === 'features'" class="upd-screen features">
<div class="__head">
<TextButton label="Back" size="md" variant="menu-back"
icon-start="arrow-left" @click="screen = 'welcome'" />
<Text tag="h6" text="What's inside" />
</div>
<div class="__rows jp-font">
<!-- gate() and anywhere() live in the component script
(card E's pane); gate emits the feature name up -->
<button v-for="f in FEATURES" :key="f.name" class="__row" type="button"
@click="f.plan === 'free' ? join() : gate(f.name)">
<img :src="`https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/${f.cp}.png`" alt="">
<Text type="body/m-heavy" color="text-primary" class="__row-name" :text="f.name" />
<Text type="body/m-reg" color="text-secondary" class="__row-line" :text="f.line" />
<Pill variant="sm" :text="f.plan === 'pro' ? 'Pro' : 'Free'"
:background="f.plan === 'pro' ? 'glass' : undefined" />
</button>
</div>
<div class="__links">
<button class="__anywhere jp-font" type="button" @click="anywhere">
<img src="https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/1f310.png" alt="">
<Text type="body/m-reg" color="text-secondary"
text="Use it anywhere. EmojiCopy runs on the web from any device." />
</button>
<TextButton variant="green" size="md" label="Take a quick tour" @click="tour" />
</div>
</div>
</template>
// no separate mount — Learn more on screen 1 sets screen = 'features'. // gate(name) pops the in-popup GateSheet with the feature named // (extension-upsell.html card B); join() opens the web join tab. // The anywhere line opens https://emojicopy.com in a web tab.
.upd-screen.features {
padding: 32px 24px 16px;
gap: var(--sp-4);
text-align: left;
align-items: stretch;
}
.upd-screen .__head {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 32px;
}
.upd-screen .__head .text-button { position: absolute; left: 0; }
.upd-screen .__rows { display: flex; flex-direction: column; }
.upd-screen .__row {
display: flex;
align-items: center;
gap: 12px;
padding: 7px 10px;
/* raw <button> rows — the build has no global button reset */
border: none;
background: none;
border-radius: var(--r-md);
cursor: pointer;
text-align: left;
transition: background .15s;
}
.upd-screen .__row:hover { background: var(--c-surface-explore-hover); }
.upd-screen .__row > img { width: 24px; height: 24px; flex: none; }
.upd-screen .__row-name { flex: none; }
.upd-screen .__row-line {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* the chips are the shipping Pill at variant:sm — its own 28px
metrics and m-reg label; nothing to scope beyond flex-none */
.upd-screen .__row .pill { flex: none; }
.upd-screen .__links {
margin-top: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
padding-top: var(--sp-3);
border-top: 1px solid var(--c-border-primary);
}
/* a real link — plain-button reset, opens emojicopy.com */
.upd-screen .__anywhere {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: none;
cursor: pointer;
}
.upd-screen .__anywhere img { width: 18px; height: 18px; }
Your emoji keyboard just grew into a whole app! Copying is just the start.
Already have an account? · or
EmojiCopy also runs on the web from any device.
Everything from both screens on one: the rebrand, six tappable feature cells with their plan chips, both exits, both account links, the web line. No second screen to reach, nothing to come back from.
ExtensionUpdateWelcome · new — the single-screen variant: same component, no screen state, the feature grid inline. If G is picked, the two-screen templates go awayText · ui/components/Text.vue — tag="h6" title, body/l-reg line, s-reg feetPrimaryButton · ui/buttons/PrimaryButton.vue — Continue variant="green", the tour variant="gray", both size="md" — screen 1's exact pairingTextButton · ui/buttons/TextButton.vue — the two foot links, variant="green" size="md", pinned down by the scoped __slink linePill · ui/components/Pill.vue — variant="sm" chips in the cells, Pro on background="glass"2728 1f973 1f389, cells 2764 1f9e9 1f4da 1f4f1 1f9e0 1f6e1__confetti · card E's confetti atom — full block repeated in this drawer's CSS so G ships alone.__grid · 2 × 3 equal cells — six named features with chips is a wrap-resistant table, so grid, not flex (R10).upd-screen column · burst, title, line, grid, CTA row, two feet — one axis, gap-owned (R11)<script setup>
import { onMounted } from 'vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import TextButton from '@/buttons/TextButton.vue'
import Text from '@/components/Text.vue'
import Pill from '@/components/Pill.vue'
import { useUpsellStore } from '@/store/upsell'
import { getEnv } from '@shared/config/env'
import { setProp } from '@shared/storage/local'
/* @gate hands the feature name to the shell's GateSheet, same wiring
as the two-screen variant */
const emit = defineEmits(['done', 'gate'])
const upsell = useUpsellStore()
/* six cells — the seven-row list minus unlimited-lists, which the
Pro gate itself carries; plan truth from PLANS-PRICING.md */
const CELLS = [
{ cp: '2764', name: 'Favorites', plan: 'free' },
{ cp: '1f9e9', name: 'Combos', plan: 'free' },
{ cp: '1f4da', name: 'Collections', plan: 'free' },
{ cp: '1f4f1', name: 'Sync', plan: 'free' },
{ cp: '1f9e0', name: 'Smart Recents', plan: 'pro' },
{ cp: '1f6e1', name: 'Ad free', plan: 'pro' },
]
/* one render = seen, same as the two-screen variant */
onMounted(() => setProp('ext_update_welcome_seen', '1'))
const finish = () => emit('done')
const login = () => { window.open(getEnv('OAUTH_BASE_URL') + '/login'); finish() }
const join = () => { window.open(getEnv('OAUTH_BASE_URL') + '/join'); finish() }
const tour = () => { upsell.updateShowTutorial(true); finish() }
const gate = (feature) => emit('gate', feature)
</script>
<template>
<div class="upd-screen squeeze">
<div class="__confetti" aria-hidden="true"><i v-for="n in 10" :key="n" /></div>
<div class="__burst sm jp-font" aria-hidden="true">
<img v-for="cp in ['2728', '1f973', '1f389']" :key="cp"
:src="`https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/${cp}.png`" alt="">
</div>
<Text tag="h6" class="__title" text="JoyPixels is now EmojiCopy" />
<Text tag="p" type="body/l-reg" color="text-secondary" class="__line"
text="Your emoji keyboard just grew into a whole app! Copying is just the start." />
<div class="__grid jp-font">
<button v-for="c in CELLS" :key="c.name" class="__cell" type="button"
@click="c.plan === 'free' ? join() : gate(c.name)">
<img :src="`https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/${c.cp}.png`" alt="">
<Text type="body/m-heavy" color="text-primary" class="__cell-name" :text="c.name" />
<Pill variant="sm" :text="c.plan === 'pro' ? 'Pro' : 'Free'"
:background="c.plan === 'pro' ? 'glass' : undefined" />
</button>
</div>
<!-- the CTA row is screen 1's exact pairing: green primary,
gray secondary — never a bare link beside a filled button -->
<div class="__cta">
<PrimaryButton variant="green" size="md" label="Continue" @click="finish" />
<PrimaryButton variant="gray" size="md" label="Take a quick tour" @click="tour" />
</div>
<Text tag="p" type="body/s-reg" color="text-tertiary" class="__foot-sub">
Already have an account?
<TextButton variant="green" size="md" class="__slink" label="Log in" @click="login" />
· or
<TextButton variant="green" size="md" class="__slink" label="create your free account" @click="join" />
</Text>
<Text tag="p" type="body/s-reg" color="text-tertiary"
text="EmojiCopy also runs on the web from any device." />
</div>
</template>
<ExtensionUpdateWelcome v-if="showUpdateWelcome"
@done="showUpdateWelcome = false"
@gate="openGateSheet" />
// same trigger, same seen flag, same 600 × 500 window, same @gate
// wiring as card E. G simply has no screen state — everything exits
// from here.
/* G stands alone — the shared shell, measures, and celebration
blocks repeat here so this drawer ships without card E's. */
.upd-screen {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.upd-screen.squeeze {
position: relative; /* anchors the confetti */
justify-content: center;
gap: var(--sp-3);
padding: 16px 36px 14px;
/* C's wash — light radial, never the doodle art */
background: radial-gradient(120% 66% at 50% 0%,
color-mix(in srgb, var(--green-500) 34%, transparent) 0%,
transparent 62%);
}
/* burst — option C's block rescoped to this root, tiles sized down */
.upd-screen .__burst {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 4px;
}
.upd-screen .__burst img {
box-sizing: content-box;
border-radius: var(--r-md);
background: var(--c-surface-primary);
border: 1px solid var(--c-border-primary);
box-shadow: var(--shadow-popover);
rotate: -9deg;
transition: rotate .2s;
animation: burst-float 3.2s ease-in-out infinite;
}
.upd-screen .__burst img + img { margin-left: -10px; }
.upd-screen .__burst img:nth-child(2) { rotate: 0deg; translate: 0 -8px; z-index: 1; animation-delay: .4s; }
.upd-screen .__burst img:nth-child(3) { rotate: 9deg; animation-delay: .8s; }
.upd-screen .__burst.sm img { width: 24px; height: 24px; padding: 8px; }
.upd-screen .__burst.sm img:nth-child(2) { width: 28px; height: 28px; padding: 10px; }
@keyframes burst-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
/* confetti — card E's atom, repeated so G ships alone */
.upd-screen .__confetti { position: absolute; inset: 0; pointer-events: none; }
.upd-screen .__confetti i {
position: absolute;
width: 7px;
height: 11px;
border-radius: 2px;
background: var(--green-500);
animation: confetti-drift 3.6s ease-in-out infinite;
}
.upd-screen .__confetti i:nth-child(1) { left: 13%; top: 22%; rotate: -28deg; background: var(--green-600); }
.upd-screen .__confetti i:nth-child(2) { left: 21%; top: 9%; rotate: 18deg; background: var(--green-400); animation-delay: .5s; }
.upd-screen .__confetti i:nth-child(3) { left: 30%; top: 27%; rotate: -10deg; width: 6px; height: 6px; border-radius: 50%; background: var(--green-800); animation-delay: 1.1s; }
.upd-screen .__confetti i:nth-child(4) { left: 38%; top: 7%; rotate: 34deg; animation-delay: 1.6s; }
.upd-screen .__confetti i:nth-child(5) { left: 50%; top: 4%; rotate: -6deg; width: 6px; height: 6px; border-radius: 50%; background: var(--green-400); animation-delay: .8s; }
.upd-screen .__confetti i:nth-child(6) { left: 61%; top: 8%; rotate: 22deg; background: var(--green-600); animation-delay: 2s; }
.upd-screen .__confetti i:nth-child(7) { left: 70%; top: 25%; rotate: -18deg; width: 6px; height: 6px; border-radius: 50%; animation-delay: .3s; }
.upd-screen .__confetti i:nth-child(8) { left: 79%; top: 10%; rotate: 12deg; background: var(--green-800); animation-delay: 1.4s; }
.upd-screen .__confetti i:nth-child(9) { left: 87%; top: 23%; rotate: -30deg; background: var(--green-400); animation-delay: .9s; }
.upd-screen .__confetti i:nth-child(10) { left: 45%; top: 30%; rotate: 40deg; width: 5px; height: 8px; background: var(--green-600); animation-delay: 2.4s; }
@keyframes confetti-drift {
0%, 100% { transform: translateY(0) }
50% { transform: translateY(-7px) }
}
@media (prefers-reduced-motion: reduce) {
.upd-screen .__burst img,
.upd-screen .__confetti i { animation: none; }
}
.upd-screen .__title { max-width: 22ch; }
.upd-screen .__line { max-width: 44ch; }
.upd-screen .__cta { display: flex; align-items: center; gap: 10px; }
.upd-screen .__foot-sub { display: flex; align-items: center; gap: 4px; }
/* TextButton ships lg and md only — the quiet links are size:md
pinned one step down (card A's __skip pattern) */
.upd-screen .__slink { font-size: 1.12rem; }
.upd-screen .__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
width: 100%;
max-width: 460px;
}
.upd-screen .__cell {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--c-border-primary);
border-radius: var(--r-md);
background: var(--c-surface-primary);
cursor: pointer;
text-align: left;
transition: background .15s;
}
.upd-screen .__cell:hover { background: var(--c-surface-explore-hover); }
.upd-screen .__cell > img { width: 20px; height: 20px; flex: none; }
/* scoped to the name so the Pill's own .text label is untouched */
.upd-screen .__cell .__cell-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upd-screen .__cell .pill { flex: none; }