Points for using the app, spent on effects, skins, and prizes — with Xbox-style achievements. Lives behind a pill on the Explore page.
| The move | Why it converts |
|---|---|
| The daily drop lives on Explore | Daily habit traffic lands on the page whose whole job is following collections. Achievement payouts claim here too — nothing pays until you come collect it. Follows fill the six free slots, Full House meets the follow limit, "Go unlimited." |
| The shelf shows what you're not earning | Five grayed PRO achievement rows (lists, recents, cloning), each opens the Pro modal on tap. The paywall trigger list, rebuilt as a trophy case. |
| Pro earns 2× tickets | The one perk that compounds daily and reads on every surface — the footer line, the doubled drop. |
| A Month of Pro costs 7,500 | About four months of habit buys 30 days of the habit-forming features, and lapsing back to free is a downgrade you can feel. Cheaper than a discount, stickier than a trial. |
| Effects, skins, and streaks make it yours | Churn now costs your streak, your effects, and your chrome — leaving stops being free. |
| Currency | The place | Store tab | Achievements tab | Daily claim |
|---|---|---|---|---|
| Tickets | The Arcade | Prize Counter | Achievements | Daily Collect |
<PopTip> · ui/components/pop/PopTip.vue — hover tip on the pill<ArcadePill> · new atom — spec below · cataloguseArcadeStore · ui/store/arcade.js — new Pinia store, source below<script setup>
import { computed } from 'vue';
import { useArcadeStore } from '../store/arcade.js';
import PopTip from './pop/PopTip.vue';
const arcade = useArcadeStore();
const tip = computed(() =>
arcade.waiting ? `${arcade.waiting} waiting in the Arcade` : 'Arcade'
);
</script>
<template>
<PopTip :text="tip">
<button
class="arcade-pill"
:aria-label="`Arcade — ${arcade.balanceLabel} tickets`"
@click="arcade.open('achievements')"
>
<img class="arcade-pill__mark" :src="arcade.ticketPng" alt="" />
<span class="arcade-pill__count">{{ arcade.balanceLabel }}</span>
<!-- the waiting badge: unseen achievements + today's unclaimed
drop. :key remounts on every bump, so the pop-in + single
ring replay — no watcher, no loop. -->
<span v-if="arcade.waiting" :key="arcade.waiting"
class="arcade-pill__news">{{ arcade.waiting }}</span>
</button>
</PopTip>
</template>
<style scoped>
.arcade-pill {
position: relative; /* the waiting badge anchors here */
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 12px 5px 9px;
border: none;
border-radius: 10rem;
background: var(--c-button-gray-bg-2);
font-weight: 700;
font-size: 1.25rem;
color: var(--c-text-secondary);
line-height: 1;
cursor: pointer;
transition: background .15s, box-shadow .15s;
}
.arcade-pill:hover { background: var(--c-button-gray-hover-bg-2); }
.arcade-pill__mark { width: 1.9rem; height: 1.9rem; margin: -3px 0; } /* big enough to read as a ticket */
/* the waiting badge — unseen achievements + today's unclaimed drop.
Pops in once, rings ONCE, then sits still — nothing on the pill
ever loops. --arcade-grape (#7C3AED) is a NEW color: the app's
destructive red reads as a billing error, and the badge is good
news. Add to colors.css with the build (logged in DEV-NOTES.md). */
.arcade-pill__news {
position: absolute;
top: -6px;
right: -6px;
display: grid;
place-items: center;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 10rem;
background: var(--arcade-grape, #7C3AED);
color: var(--white);
font-size: 1rem;
font-weight: 700;
box-shadow: 0 0 0 2px var(--c-surface-primary);
box-sizing: border-box;
animation: arcade-news-pop .3s cubic-bezier(0.175, 0.885, 0.32, 2) both,
arcade-news-ring 1.2s ease-out .3s 1;
}
@keyframes arcade-news-pop {
from { scale: .4; opacity: 0; }
to { scale: 1; opacity: 1; }
}
@keyframes arcade-news-ring {
from { outline: 4px solid color-mix(in srgb, var(--arcade-grape, #7C3AED) 40%, transparent); outline-offset: 0; }
to { outline: 4px solid transparent; outline-offset: 4px; }
}
@media (prefers-reduced-motion: reduce) {
.arcade-pill__news { animation: none; }
}
</style>
<!-- Evolved Explore layout: right cluster of the sticky controls bar,
between search and the view toggle. -->
<div class="explore-controls__right">
<ArcadePill />
<ViewToggle />
</div>
<!-- Current shipped Explore.vue (no sticky bar yet): wrap the search
form and the pill in one flex row, pill right-aligned. -->
<div class="explore-page__search-row">
<form class="explore-page__search">…existing ComboField…</form>
<ArcadePill />
</div>
import { defineStore } from 'pinia';
// Tickets are play money: earned, never bought, never dollar-
// denominated, entirely separate from the $5 beta feedback credit.
export const useArcadeStore = defineStore('arcade', {
state: () => ({
balance: 0, // hydrated from GET account/arcade
earnedAllTime: 0,
streak: 0, // consecutive daily claims
claimable: false, // is today's drop unclaimed?
todaysDrop: 20, // 20 base + 5/streak day, capped at 40
achievements: [], // [{ slug, tier, earnedAt, claimedAt, tickets, progress, total }]
// tier: 'quiet' | 'milestone' | 'secret' (the volume ladder)
prizes: [], // [{ slug, ownedAt, equipped }]
show: false, // modal visibility
tab: 'achievements', // 'store' | 'achievements' — the shelf is the front door
}),
getters: {
balanceLabel: (s) => s.balance.toLocaleString('en-US'),
ticketPng: () =>
'https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/1f3ab.png',
earnedCount: (s) => s.achievements.filter(a => a.earnedAt).length,
// quiet earns not yet seen on the shelf
// earned but not yet claimed — tickets waiting at the counter
unclaimedEarned: (s) => s.achievements.filter(a => a.earnedAt && !a.claimedAt).length,
// everything the badge counts: unclaimed earns + today's unclaimed
// drop. Each claim clears its own +1 — nothing clears on open.
waiting() { return this.unclaimedEarned + (this.claimable ? 1 : 0); },
},
actions: {
open(tab) {
if (tab) this.tab = tab;
this.show = true;
},
openShelf() {
// the earn toast's View CTA lands here — the shelf lives on Explore
if (router.currentRoute.value.name !== 'explore') router.push('/explore');
this.open('achievements');
},
async claimAchievement(slug) {
// POST account/arcade/claim-achievement { slug } → { balance }
// Tickets land NOW, not at earn — the shelf is the payout counter.
// Server stamps claimedAt; the row's Claim button settles into
// the muted "Claimed +N" mark and the pill badge drops by one.
},
close() { this.show = false; },
async hydrate() { /* GET account/arcade → state */ },
async claimDaily() {
// POST account/arcade/claim → { balance, streak, todaysDrop }
// UI: burst + count-up live in ClaimBar.vue
},
async buyPrize(slug) { /* POST account/arcade/buy { slug } */ },
async togglePrize(slug, on) {
// mirrors a boolean settings slug (see card E) so the
// effect state rides the existing settings sync
},
},
});
Use the app to earn in the Arcade.
<PopModal> — stacked over the Arcade modal, the same layering ProModal already rides<IconButton> icon="x" — the close<ArcadeIntro> atom · cataloguseArcadeStore — showIntro state + dismissIntro(), wiring below<script setup>
import PopModal from '../components/pop/PopModal.vue';
import IconButton from '../buttons/IconButton.vue';
import { useArcadeStore } from '../store/arcade.js';
const arcade = useArcadeStore();
const go = (tab) => { arcade.tab = tab; arcade.dismissIntro(); };
</script>
<template>
<PopModal :popped="arcade.showIntro" width="560px" background-class="arcade-glass"
@unpop="arcade.dismissIntro()">
<div class="arcade-intro">
<IconButton class="arcade-intro__close" icon="x" size="sm" filled
@click="arcade.dismissIntro()" />
<h3 v-if="!arcade.introHelp" class="arcade-intro__title">
<img class="jp-img" :src="emojiPng('1f579')" alt="" />
Welcome to the Arcade
</h3>
<p class="arcade-intro__sub" :class="{ 'is-lead': arcade.introHelp }">
Use the app to earn in the Arcade.</p>
<ul class="arcade-intro__list">
<li><img class="jp-img" :src="emojiPng('1f3c6')" alt="" />
<span><b>Complete Achievements.</b> Tricky ones pay big.</span></li>
<li><img class="jp-img" :src="emojiPng('1f3ab')" alt="" />
<span><b>Earn Tickets.</b> Every copy, save, and follow counts.</span></li>
<li><img class="jp-img" :src="emojiPng('1f9f8')" alt="" />
<span><b>Spend at the Prize Counter.</b> Effects, skins, a month of Pro.</span></li>
<li><img class="jp-img" :src="emojiPng('1f381')" alt="" />
<span><b>Collect Daily.</b> Streaks grow the drop.</span></li>
<li><img class="jp-img" :src="emojiPng('1f95a')" alt="" />
<span><b>Find Secret Achievements.</b> A few are hiding right now.</span></li>
</ul>
<div class="arcade-intro__ctas">
<button class="arcade-intro__go" @click="go('store')">
<img class="jp-img" :src="emojiPng('1f9f8')" alt="" />Browse prizes</button>
<button class="arcade-intro__alt" @click="go('achievements')">
<img class="jp-img" :src="emojiPng('1f3c6')" alt="" />See achievements</button>
</div>
</div>
</PopModal>
</template>
// store/arcade.js — four additions to the card-A source
state: () => ({
// ...existing state...
showIntro: false, // first-visit card over the modal
introHelp: false, // true = reopened from the footer ? (no welcome title)
}),
actions: {
open(tab) { // every door funnels through here already:
if (tab) this.tab = tab; // pill, deep links,
this.show = true; // toast's View achievement
if (!settings.get('arcade-intro-seen')) { // ← new
this.introHelp = false;
this.showIntro = true;
}
},
openIntroHelp() { // ← new — the footer ?, works any time, never reads the seen flag
this.introHelp = true;
this.showIntro = true;
},
dismissIntro() { // ← new
this.showIntro = false;
settings.set('arcade-intro-seen', true); // server-owned, like the equip slugs
},
}
// ArcadeModal.vue — mount it after </ModalContent>:
// import ArcadeIntro from './ArcadeIntro.vue';
// ...
// <ArcadeIntro />
// PopModal stacking puts it on top of the open modal.
//
// ArcadeModal.vue footer — the tiny reopen ?, pinned bottom-right:
// <button class="arcade-footer__help" aria-label="How the Arcade works"
// @click="arcade.openIntroHelp()">?</button>
/* PopModal provides scrim + centering; width is set at the call site (560px).
The frosted shell is a container variant in PopModal.vue, beside &.upsell-black: */
.pop-modal-container {
&.arcade-glass {
background: color-mix(in srgb, var(--metal-950) 62%, transparent);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
}
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.pop-modal-container.arcade-glass { background: var(--metal-950); }
}
/* scoped to ArcadeIntro (flat selectors) */
.arcade-intro { position: relative; padding: 40px 34px 36px;
animation: arcade-intro-pop .22s cubic-bezier(.2, .9, .3, 1.15); }
@keyframes arcade-intro-pop { from { opacity: 0; transform: translateY(10px) scale(.96); } }
@media (prefers-reduced-motion: reduce) { .arcade-intro { animation: none; } }
.arcade-intro__close { position: absolute; top: 14px; right: 14px; }
.arcade-intro__title { display: flex; align-items: center; gap: 12px; padding-right: 34px;
font-weight: 900; font-size: 3rem; line-height: 1.1; letter-spacing: -.02em;
color: var(--white); }
.arcade-intro__title .jp-img { font-size: 3.2rem; }
.arcade-intro__sub { margin: 10px 0 0; font-size: 1.45rem; line-height: 1.5;
color: color-mix(in srgb, var(--white) 65%, transparent); }
/* help reopen (the footer ?) — no welcome title, the sub leads */
.arcade-intro__sub.is-lead { margin: 0; padding-right: 34px;
font-size: 1.7rem; font-weight: 700; color: var(--white); }
.arcade-intro__list { list-style: none; margin: 28px 0 30px; padding: 0;
display: grid; gap: 19px; }
.arcade-intro__list li { display: flex; gap: 11px; font-size: 1.3rem; line-height: 1.45;
color: color-mix(in srgb, var(--white) 72%, transparent); }
.arcade-intro__list .jp-img { font-size: 1.6rem; flex: none; translate: 0 1px; }
.arcade-intro__list b { color: var(--white); }
.arcade-intro__ctas { display: flex; gap: 10px; }
.arcade-intro__go, .arcade-intro__alt { flex: 1; display: inline-flex; align-items: center;
justify-content: center; gap: 8px; padding: 12px 14px; border: none;
border-radius: 10rem; font-weight: 700; font-size: 1.4rem; cursor: pointer; }
.arcade-intro__ctas .jp-img { font-size: 1.6rem; }
.arcade-intro__go { background: var(--green-400); color: var(--green-950); }
.arcade-intro__alt { 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); }
/* the reopen ? — lives in ArcadeModal's footer (card B), pinned bottom-right */
.arcade-footer { position: relative; }
.arcade-footer__help { position: absolute; right: 12px; top: 50%; translate: 0 -50%;
width: 22px; height: 22px; display: grid; place-items: center; border: none;
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) 16%, transparent);
color: color-mix(in srgb, var(--white) 55%, transparent);
font-weight: 700; font-size: 1.15rem; cursor: pointer; }
.arcade-footer__help:hover { color: var(--white); }
<PopModal> + <ModalContent> + <ModalHeaderExplore> · ui/components/pop/<IconButton icon="x" filled> · <ToggleSwitch size="sm"> · <Text>useArcadeStore · ui/store/arcade.js — source in card A's drawer applies verbatim<script setup>
import PopModal from '../components/pop/PopModal.vue';
import ModalContent from '../components/pop/templates/ModalContent.vue';
import ModalHeaderExplore from '../components/pop/templates/ModalHeaderExplore.vue';
import IconButton from '../buttons/IconButton.vue';
import ClaimBar from '../components/Arcade/ClaimBar.vue';
import PrizeCard from '../components/Arcade/PrizeCard.vue';
import AchievementRow from '../components/Arcade/AchievementRow.vue';
import TicketMark from '../components/Arcade/TicketMark.vue';
import { useArcadeStore } from '../store/arcade.js';
import { useUpsellStore } from '../store/upsell.js';
const arcade = useArcadeStore();
const upsell = useUpsellStore();
const PRIZE_GROUPS = ['effects', 'skins', 'big-prizes'];
</script>
<template>
<PopModal :popped="arcade.show" width="760px" background-class="upsell-black"
@unpop="arcade.close()">
<ModalContent>
<template #header>
<ModalHeaderExplore title="Arcade">
<template #controls>
<TicketMark :count="arcade.balance" size="lg" />
<IconButton icon="x" size="md" filled />
</template>
</ModalHeaderExplore>
</template>
<ClaimBar />
<nav class="arcade-tabs">
<!-- Achievements first — the pill lands here, and it leads -->
<button :class="{ 'is-active': arcade.tab === 'achievements' }"
@click="arcade.tab = 'achievements'">
<img class="jp-img" :src="emojiPng('1f3c6')" alt="" />
Achievements
<span class="arcade-tabs__badge">
{{ arcade.earnedCount }} of {{ arcade.achievements.length }}
</span>
</button>
<button :class="{ 'is-active': arcade.tab === 'store' }"
@click="arcade.tab = 'store'">
<img class="jp-img" :src="emojiPng('1f3ab')" alt="" />
Prize Counter
</button>
</nav>
<section v-if="arcade.tab === 'store'" class="arcade-shop">
<div v-for="group in PRIZE_GROUPS" :key="group" class="arcade-shop__group">
<h3 class="arcade-shop__group-title">{{ groupLabel(group) }}</h3>
<div class="arcade-shop__grid">
<PrizeCard v-for="p in arcade.prizesIn(group)" :key="p.slug" :prize="p" />
</div>
</div>
</section>
<section v-else class="arcade-achievements">
<div class="arcade-achievements__meter">…summary + meter, see card C…</div>
<AchievementRow v-for="a in arcade.achievements" :key="a.slug" :achievement="a" />
</section>
<template #footer>
<p class="arcade-footer">
Pro earns <b>2× tickets</b> on everything.
<button class="arcade-footer__link" @click="upsell.updateShowPro(true)">
See what's in Pro
</button>
</p>
</template>
</ModalContent>
</PopModal>
</template>
<script setup>
import ToggleSwitch from '../../input/ToggleSwitch.vue';
import TicketMark from './TicketMark.vue';
import { useArcadeStore } from '../../store/arcade.js';
import { useAccountStore } from '../../store/account.js';
import { useUpsellStore } from '../../store/upsell.js';
const props = defineProps({ prize: { type: Object, required: true } });
const arcade = useArcadeStore();
const account = useAccountStore();
const upsell = useUpsellStore();
// prize: { slug, name, desc, emoji, cost, group, proOnly, ownedAt, equipped }
const affordable = () => arcade.balance >= props.prize.cost;
const buy = () => {
if (props.prize.proOnly && !account.isPro) return upsell.updateShowPro(true);
if (affordable()) arcade.buyPrize(props.prize.slug);
};
</script>
<template>
<article class="prize-card" :class="{ 'is-owned': prize.ownedAt }">
<div class="prize-card__art">
<img class="jp-img" :src="emojiPng(prize.emoji)" alt="" />
</div>
<h4 class="prize-card__name">
{{ prize.name }}
<span v-if="prize.proOnly" class="prize-card__pro-chip">Pro</span>
</h4>
<p class="prize-card__desc">{{ prize.desc }}</p>
<div class="prize-card__row">
<template v-if="prize.ownedAt">
<span class="prize-card__owned">Owned</span>
<ToggleSwitch size="sm" :model-value="prize.equipped ? 1 : 0"
@change="arcade.togglePrize(prize.slug, $event)" />
</template>
<button v-else class="prize-card__price"
:class="{ 'is-far': !affordable() }" @click="buy">
<TicketMark :count="prize.cost" />
</button>
</div>
</article>
</template>
/* tabs */
.arcade-tabs { display: flex; gap: 6px; padding-bottom: 14px;
border-bottom: 1px solid color-mix(in srgb, var(--metal-300) 14%, transparent); }
.arcade-tabs button { display: inline-flex; align-items: center; gap: 7px;
padding: 8px 16px; border: none; border-radius: 10rem; background: transparent;
color: color-mix(in srgb, var(--white) 60%, transparent);
font-weight: 700; font-size: 1.3rem; cursor: pointer; }
.arcade-tabs button.is-active {
background: color-mix(in srgb, var(--metal-300) 14%, transparent); color: var(--white); }
.arcade-tabs button .jp-img { font-size: 1.45rem; }
.arcade-tabs__badge { padding: 2px 7px; border-radius: 10rem;
background: var(--green-400); color: var(--green-950); font-size: 1rem; }
/* shop */
.arcade-shop__group-title { font-weight: 700; font-size: 1.1rem; letter-spacing: .1em;
text-transform: uppercase; color: color-mix(in srgb, var(--white) 45%, transparent);
margin-bottom: 10px; }
.arcade-shop__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
/* prize card */
.prize-card { display: flex; flex-direction: column; gap: 8px; padding: 14px;
border-radius: 12px; background: color-mix(in srgb, var(--metal-300) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 12%, transparent); }
.prize-card.is-owned {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green-500) 35%, transparent); }
.prize-card__art { display: grid; place-items: center; height: 64px; border-radius: 8px;
background: color-mix(in srgb, var(--metal-300) 10%, transparent); font-size: 3.2rem; }
.prize-card__name { font-weight: 700; font-size: 1.35rem; color: var(--white); }
.prize-card__desc { font-size: 1.15rem; line-height: 1.45;
color: color-mix(in srgb, var(--white) 62%, transparent); flex: 1; }
.prize-card__row { display: flex; align-items: center; justify-content: space-between; }
.prize-card__price { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border: none; border-radius: 10rem; background: var(--c-button-green-bg);
color: var(--metal-950); font-weight: 700; font-size: 1.2rem; cursor: pointer; }
.prize-card__price.is-far {
background: color-mix(in srgb, var(--metal-300) 14%, transparent);
color: color-mix(in srgb, var(--white) 55%, transparent); cursor: default; }
.prize-card__owned { font-weight: 700; font-size: 1.05rem; letter-spacing: .08em;
text-transform: uppercase; color: var(--green-500); }
.prize-card__pro-chip { display: inline-flex; padding: 3px 9px; margin-left: 8px;
border-radius: 10rem; background: var(--green-950); color: var(--green-500);
box-shadow: inset 0 0 0 1px rgba(133,252,100,.25); font-weight: 700;
font-size: 1rem; letter-spacing: .06em; text-transform: uppercase; }
/* ticket mark (img + count, used everywhere a ticket amount shows) */
.ticket-mark { display: inline-flex; align-items: center; gap: 5px;
font-weight: 700; }
.ticket-mark__img { width: 1.3em; height: 1.3em; }
<PopModal> + <ModalContent> + <ModalHeaderExplore> — same shell as card Btoast.pop() · ui/components/pop/util/toast.js — the earn moment rides its rail (options in The earn moment, below)useArcadeStore · ui/store/arcade.js — source in card A's drawer applies verbatim<script setup>
import TicketMark from './TicketMark.vue';
import TicketMeter from './TicketMeter.vue';
import { useAccountStore } from '../../store/account.js';
import { useUpsellStore } from '../../store/upsell.js';
// achievement: { slug, name, desc, emoji, tickets, earnedAt,
// progress, total, proOnly, secret, rarity }
const props = defineProps({ achievement: { type: Object, required: true } });
const account = useAccountStore();
const upsell = useUpsellStore();
const locked = () => !props.achievement.earnedAt;
const claimable = () => props.achievement.earnedAt && !props.achievement.claimedAt;
const arcade = useArcadeStore();
const onClick = () => {
// Pro-gated + not Pro: the row itself is the upsell hook
if (props.achievement.proOnly && !account.isPro) upsell.updateShowPro(true);
};
</script>
<template>
<article
class="achievement-row"
:class="{ 'is-earned': !locked(), 'is-locked': locked(), 'is-pro': achievement.proOnly,
'is-secret': achievement.tier === 'secret' && locked() }"
@click="onClick"
>
<span class="achievement-row__medal">
<template v-if="achievement.tier === 'secret' && locked()">?</template>
<img v-else class="jp-img" :src="emojiPng(achievement.emoji)" alt="" />
<span v-if="!locked()" class="achievement-row__check" aria-hidden="true">✓</span>
</span>
<span class="achievement-row__body">
<span class="achievement-row__name">
{{ achievement.tier === 'secret' && locked() ? '???' : achievement.name }}
<span v-if="achievement.proOnly" class="achievement-row__pro-chip">Pro</span>
</span>
<span class="achievement-row__desc">{{ achievement.desc }}</span>
<TicketMeter v-if="achievement.total && locked()"
:value="achievement.progress" :total="achievement.total" />
</span>
<span class="achievement-row__right">
<!-- the payout waits here: earned rows hold a Claim button
until tapped — tickets never land on their own -->
<button v-if="claimable()" class="achievement-row__claim"
@click.stop="arcade.claimAchievement(achievement.slug)">
Claim +{{ achievement.tickets }}
</button>
<!-- two facts, two marks: the medal's ✓ = earned, this = collected -->
<span v-else-if="achievement.claimedAt" class="achievement-row__claimed">Claimed +{{ achievement.tickets }}</span>
<TicketMark v-else :count="achievement.tickets" signed />
<span v-if="achievement.rarity != null" class="achievement-row__rarity">
{{ achievement.rarity }}% have this
</span>
</span>
</article>
</template>
.achievement-row { display: flex; align-items: center; gap: 14px; padding: 12px 14px;
border-radius: 12px; background: color-mix(in srgb, var(--metal-300) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 12%, transparent); }
/* earned = the same green inner ring an Owned prize card wears, plus a
check disc on the medallion — one language: green ring = yours */
.achievement-row.is-earned {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green-500) 35%, transparent); }
.achievement-row__check { position: absolute; right: -2px; bottom: -2px;
width: 16px; height: 16px; display: grid; place-items: center;
border-radius: 50%; background: var(--green-500); color: var(--metal-950);
font-size: 1rem; font-weight: 700; line-height: 1;
box-shadow: 0 0 0 2px var(--metal-950); }
.achievement-row__medal { position: relative; display: grid; place-items: center; width: 46px; height: 46px;
border-radius: 50%; background: color-mix(in srgb, var(--green-500) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green-500) 30%, transparent);
font-size: 2.2rem; flex: none; }
.achievement-row__body { flex: 1; min-width: 0; }
.achievement-row__name { font-weight: 700; font-size: 1.35rem; color: var(--white); }
/* the payout button — earned-unclaimed rows only */
.achievement-row__claim { border: none; cursor: pointer; white-space: nowrap;
padding: 6px 12px; border-radius: 10rem; font-weight: 700; font-size: 1.15rem;
background: var(--green-500); color: var(--metal-950); }
.achievement-row__claim:hover { background: var(--green-400); }
/* collected — quiet, past-tense */
.achievement-row__claimed { font-weight: 700; font-size: 1.15rem; white-space: nowrap;
color: color-mix(in srgb, var(--white) 55%, transparent); }
.achievement-row__desc { display: block; margin-top: 4px; font-size: 1.15rem;
color: color-mix(in srgb, var(--white) 62%, transparent); }
.achievement-row__right { display: flex; flex-direction: column;
align-items: flex-end; gap: 4px; flex: none; }
.achievement-row__rarity { font-size: 1rem;
color: color-mix(in srgb, var(--white) 45%, transparent); }
/* locked = grayscale medallion + muted text (the Xbox treatment) */
.achievement-row.is-locked .achievement-row__medal {
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 16%, transparent);
filter: grayscale(1); opacity: .6; }
.achievement-row.is-locked .achievement-row__name {
color: color-mix(in srgb, var(--white) 55%, transparent); }
.achievement-row.is-locked .achievement-row__desc {
color: color-mix(in srgb, var(--white) 40%, transparent); }
/* Pro-locked rows are clickable upsell hooks */
.achievement-row.is-pro { cursor: pointer; transition: box-shadow .15s; }
.achievement-row.is-pro:hover {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green-500) 40%, transparent); }
/* secret */
.achievement-row.is-secret .achievement-row__medal { background: transparent;
box-shadow: none; filter: none; opacity: 1; font-size: 1.8rem;
border: 1.5px dashed color-mix(in srgb, var(--metal-300) 40%, transparent);
color: color-mix(in srgb, var(--white) 40%, transparent); }
/* meter */
.ticket-meter { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.ticket-meter__bar { flex: 1; max-width: 160px; height: 8px; border-radius: 10rem;
background: color-mix(in srgb, var(--metal-300) 18%, transparent); overflow: hidden; }
.ticket-meter__fill { height: 100%; border-radius: 10rem; background: var(--green-600); }
.ticket-meter__num { font-size: 1.05rem;
color: color-mix(in srgb, var(--white) 55%, transparent); }
<PrimaryButton variant="green"> · ui/buttons/PrimaryButton.vue<ClaimBar> atom · cataloguseArcadeStore · ui/store/arcade.js — source in card A's drawer applies verbatim<script setup>
import { ref } from 'vue';
import { useArcadeStore } from '../../store/arcade.js';
const arcade = useArcadeStore();
const btn = ref(null);
const BURST = ['1f389', '2728', '1f3ab', '2728'];
async function claim() {
if (!arcade.claimable) return;
// burst first — the payoff must feel instant
BURST.forEach((code, i) => {
const s = document.createElement('span');
s.className = 'claim-bar__burst';
s.style.setProperty('--dx', `${(i % 2 ? 1 : -1) * (18 + i * 14)}px`);
s.style.setProperty('--dy', `${-26 - i * 10}px`);
s.innerHTML = `<img src="${emojiPng(code)}" alt="" />`;
btn.value.appendChild(s);
setTimeout(() => s.remove(), 950);
});
await arcade.claimDaily(); // server settles balance + streak
}
</script>
<template>
<aside class="claim-bar" :class="{ 'is-claimed': !arcade.claimable }">
<img class="jp-img" :src="emojiPng('1f381')" alt="" />
<p v-if="arcade.claimable" class="claim-bar__text">
<b>{{ arcade.streak }}-day streak</b>
</p>
<p v-else class="claim-bar__text">
Back tomorrow for more · <b>{{ arcade.streak }}-day streak</b>
</p>
<button ref="btn" class="claim-bar__btn" :disabled="!arcade.claimable" @click="claim">
{{ arcade.claimable ? 'Daily Collect' : 'Collected' }}
</button>
</aside>
</template>
<style scoped>
.claim-bar { display: flex; align-items: center; gap: 12px; padding: 12px 14px;
border-radius: 12px; background: color-mix(in srgb, var(--green-500) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green-500) 22%, transparent); }
.claim-bar.is-claimed { background: color-mix(in srgb, var(--metal-300) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 12%, transparent); }
.claim-bar__text { flex: 1; font-size: 1.3rem; line-height: 1.4;
color: color-mix(in srgb, var(--white) 85%, transparent); }
.claim-bar__text b { color: var(--white); }
.claim-bar__btn { position: relative; border: none; border-radius: 10rem;
padding: 9px 18px; background: var(--c-button-green-bg); color: var(--metal-950);
font-weight: 700; font-size: 1.3rem; cursor: pointer; }
.claim-bar__btn:disabled { background: color-mix(in srgb, var(--metal-300) 16%, transparent);
color: color-mix(in srgb, var(--white) 60%, transparent); cursor: default; }
.claim-bar__burst { position: absolute; left: 50%; top: 50%; pointer-events: none;
animation: claim-burst .9s ease-out forwards; }
.claim-bar__burst img { width: 1.6rem; height: 1.6rem; }
@keyframes claim-burst {
from { transform: translate(-50%, -50%) scale(.4); opacity: 1; }
to { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy)))
scale(1.15); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .claim-bar__burst { animation: none; opacity: 0; } }
</style>
| Tier | Which achievements | How you hear about it |
|---|---|---|
| Quiet · 11 | The learning-the-app firsts: Hello World, Chain Reaction, Keeper, Fan Behavior, Night Shift, That's Me, Detective, Hotkeys, Full Send, List Maker, Time Traveler | No toast. The pill grows its grape count badge (card A) — pull, not push |
| Milestone · 10 | Century Club, Thousandaire, Regular, Locked In, Window Shopper, Everywhere, Full House, Shelf Control, The Collector, Curator | The stamp toast (T4, below) |
| Secret · 7 | All seven | The toast's secret skin — S1, below |
| Option | What it says | New UI |
|---|---|---|
| T4 · The stamp ← PICKED | COMPLETED · the seal slams on Century Club, ink dries darker | 1 toast template |
| S1 · The gold stamp — the secret skin | SECRET FOUND · dark card, crown-gold ink | a `variant` prop on StampToast |
toast.pop() rail + lifecycle · toast.js + PopToast.vue — 3s / click / Escape untouched<StampToast> · new toast template — full source below · catalog entry lands with the pick'arcade-stamp'<script setup>
import { useArcadeStore } from '../../../store/arcade.js';
// achievement: { slug, name, emoji, tickets, tier }
const props = defineProps({ achievement: { type: Object, required: true } });
const emit = defineEmits(['remove']);
const arcade = useArcadeStore();
const view = () => { emit('remove'); arcade.openShelf(); };
</script>
<template>
<div class="stamp-toast" @mousedown="emit('remove')">
<span class="stamp-toast__seal" aria-hidden="true">
<span class="stamp-toast__ring">
<img class="jp-img" :src="emojiPng(achievement.emoji)" alt="" />
</span>
</span>
<span class="stamp-toast__body">
<span class="stamp-toast__word">Completed</span>
<b>{{ achievement.name }}</b>
</span>
<button class="stamp-toast__view" @mousedown.stop @click="view">
View achievement
</button>
</div>
</template>
<style scoped>
/* Entrance rides PopToast's stock item spring — only the seal
choreographs: a fast slam, then the ink "dries" darker over a
beat. Ink flips per scheme via --c-button-text-green. */
.stamp-toast { position: relative; display: flex; align-items: center; gap: 15px;
padding: 12px 20px 12px 14px; border-radius: 1.6rem;
background: var(--c-surface-primary);
box-shadow: 0px 16px 72px 0px #171A571F,
inset 0 0 0 1px var(--c-border-primary);
cursor: pointer; }
.stamp-toast__seal { position: relative; width: 56px; height: 56px;
display: grid; place-items: center; rotate: -10deg;
animation: stamp-slam .25s cubic-bezier(.2, 1, .3, 1) .2s both,
stamp-dry .9s ease-out .45s both; }
.stamp-toast__ring { position: relative; display: grid; place-items: center;
width: 52px; height: 52px; border-radius: 50%;
border: 2.5px solid var(--c-button-text-green); }
.stamp-toast__ring::after { content: ''; position: absolute; inset: 3px;
border-radius: 50%;
border: 1.5px dashed color-mix(in srgb, var(--c-button-text-green) 55%, transparent); }
.stamp-toast__ring .jp-img { width: 2.6rem; height: 2.6rem; }
.stamp-toast__body { display: flex; flex-direction: column; gap: 2px; }
.stamp-toast__word { font-weight: 800; font-size: .95rem; letter-spacing: .16em;
text-transform: uppercase; color: var(--c-button-text-green); }
.stamp-toast__body b { font-weight: 700; font-size: 1.45rem;
color: var(--c-text-primary); }
.stamp-toast__view { border: none; cursor: pointer; white-space: nowrap;
padding: 5px 11px; border-radius: 10rem; font-weight: 700; font-size: 1.1rem;
background: color-mix(in srgb, var(--green-500) 14%, transparent);
color: var(--c-button-text-green); }
@keyframes stamp-slam { from { opacity: 0; scale: 2.4; rotate: -32deg; }
to { opacity: .55; scale: 1; rotate: -10deg; } }
@keyframes stamp-dry { from { opacity: .55; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.stamp-toast__seal { animation: none; }
}
</style>
// PopToast.vue — one-line template switch (the only rail change).
// ToastMessage stays the default; the stamp registers here.
import ToastMessage from './templates/ToastMessage.vue';
import StampToast from './templates/StampToast.vue';
const TEMPLATES = { 'arcade-stamp': StampToast };
// template — replace the <ToastMessage v-for … /> line with:
<component
:is="TEMPLATES[t.template] || ToastMessage"
v-for="t in toast.toasts" :key="t.id"
@remove="toast.unpop(t.id)" v-bind="t.props || t"
class="pop-toast-item" />
// ui/utils/services/events/handlers.js — on arcade.achievement.earned
toast.pop({ template: 'arcade-stamp', achievement: a });
// timeout stays the default 3s; hideOn stays click + Escape
toast.pop() rail + lifecycle · toast.js + PopToast.vue — untouchedvariant prop on <StampToast> — the whole build is the diff below; no new template// props — one addition after `achievement`:
variant: { type: String, default: 'milestone' }, // 'milestone' | 'secret'
// template — root class + the word:
<div class="stamp-toast" :class="{ 'is-secret': variant === 'secret' }" …>
<span class="stamp-toast__word">
{{ variant === 'secret' ? 'Secret found' : 'Completed' }}
</span>
// style — the costume change. Dark in BOTH schemes (that's the point);
// ink = crown-gold #FFDB59 (IconIndex.vue:110, Doodle Gold's anchor).
.stamp-toast.is-secret {
background: var(--metal-950);
box-shadow: 0px 16px 72px 0px #171A5747,
inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 18%, transparent);
}
.stamp-toast.is-secret .stamp-toast__ring { border-color: #FFDB59; }
.stamp-toast.is-secret .stamp-toast__ring::after {
border-color: color-mix(in srgb, #FFDB59 55%, transparent); }
.stamp-toast.is-secret .stamp-toast__word { color: #FFDB59; }
.stamp-toast.is-secret .stamp-toast__body b { color: var(--white); }
.stamp-toast.is-secret .stamp-toast__view {
background: color-mix(in srgb, #FFDB59 16%, transparent);
color: #FFDB59; }
// ui/utils/services/events/handlers.js — on arcade.achievement.earned
function onAchievementEarned({ achievement: a }) {
arcade.applyEarn(a); // balance + shelf state — ALWAYS, every tier
if (a.tier === 'quiet') return; // no toast — the pill badge (card A) does the talking
toast.pop({
template: 'arcade-stamp',
achievement: a,
variant: a.tier, // 'milestone' | 'secret'
});
}
<ToggleSwitch size="sm"> · ui/input/ToggleSwitch.vue — the master switch<Select size="sm"> — the category pickers, same atom the existing string settings useui/components/AllSettings.vue — new group, zero new UI atoms<!-- New .settings-modal-group, after "Display". ONE row per prize
TYPE, not per prize — the row is the slot, its options are the
prizes you own in that slot. A category row hides until you own
something in it, and the whole group hides for non-players, so
Settings never grows for people who don't play.
CATEGORIES mirror the Prize Counter's own groups, so what you
bought and what you turn on read the same way. -->
<div v-if="arcade.ownedPrizes.length" class="settings-modal-group">
<Text type="body/l-heavy" text="Effects" />
<div class="settings-internal-group">
<!-- the master. Off silences every category at once and
REMEMBERS each pick, so flipping back restores them. -->
<div class="setting-row">
<div class="start">
<Text type="body/m-heavy" text="All effects" />
<Text type="body/m-reg" text="One switch for everything below." />
</div>
<div class="end">
<ToggleSwitch size="sm" :model-value="settings.get('effects-enabled') ? 1 : 0"
@change="settings.set('effects-enabled', !!$event)" />
</div>
</div>
<!-- the categories are a SUB-LIST under the master: smaller type,
indented, bulleted. One row per category, each a Select
over what you own in that slot. -->
<div class="setting-subrows">
<div v-for="cat in arcade.ownedCategories" :key="cat.slug"
class="setting-row setting-row--sub">
<div class="start">
<Text type="body/s-heavy" :text="cat.name" />
<Text type="body/s-reg" :text="cat.desc" />
</div>
<div class="end">
<Select size="sm"
:disabled="!settings.get('effects-enabled')"
:model-value="settings.get(cat.slug)"
:options="cat.options"
@change="settings.set(cat.slug, $event)" />
</div>
</div>
</div>
</div>
</div>
<style scoped>
/* The master keeps the standard setting-row size. The categories
read as its details — indented one step down like a bullet list,
but with NO bullet marks; the inset alone says "sub". */
.setting-subrows { display: flex; flex-direction: column; gap: 11px;
padding-top: 16px;
border-top: 1px solid var(--c-border-primary); }
.setting-row--sub { padding-left: 17px; }
</style>
// ui/store/arcade.js — the categories, derived from what you own.
// Every category ALWAYS offers "Off"; the rest are your prizes.
const CATEGORIES = [
{ slug: 'effect-combo-hover', name: 'Combo hover',
desc: 'What your saved combos do when you point at them.' },
{ slug: 'effect-emoji-hover', name: 'Emoji hover',
desc: 'What a single emoji does when you point at it.' },
{ slug: 'effect-copy', name: 'Copy effect',
desc: 'What happens the moment you copy.' },
{ slug: 'theme-doodle', name: 'App skin',
desc: 'Art on your sidebar and header.' },
];
ownedCategories: (s) => CATEGORIES
.map(c => ({
...c,
options: [{ value: 'off', label: 'Off' }].concat(
s.prizes.filter(p => p.ownedAt && p.category === c.slug)
.map(p => ({ value: p.option, label: p.name }))
),
}))
.filter(c => c.options.length > 1), // own nothing here → no row
// One slug per CATEGORY, not per prize. Adding a sixth combo hover
// later adds an option, never a slug and never a Settings row.
{ "slug": "effects-enabled", "name": "All effects",
"type": "boolean", "default": true, "device_specific": false, "is_pro": 0 },
{ "slug": "effect-combo-hover", "name": "Combo hover",
"type": "string", "default": "off", "device_specific": false, "is_pro": 0,
"options": ["off", "rumble", "desk-toy", "domino", "wave", "squeeze"] },
{ "slug": "effect-emoji-hover", "name": "Emoji hover",
"type": "string", "default": "off", "device_specific": false, "is_pro": 0,
"options": ["off", "ticklish"] },
{ "slug": "effect-copy", "name": "Copy effect",
"type": "string", "default": "off", "device_specific": false, "is_pro": 0,
"options": ["off", "fireworks"] },
{ "slug": "theme-doodle", "name": "App skin",
"type": "string", "default": "off", "device_specific": false, "is_pro": 0,
"options": ["off", "classic", "seasonal", "gold"] }
theme-doodle settings slug + equip switch · already spec'd in card Eui/components/pop/PopModal.vue:100 — Image() precache before the class flipsdoodle-themes.css · new stylesheet, two hooks on the Main.vue chrome/* The layout root carries .theme-doodle-<name> from the theme-doodle
settings slug ('off' | 'classic' | 'seasonal' | 'gold'). Two hooks
only: the header band and the sidebar. Content never gets doodled. */
.theme-doodle-classic { --doodle-url: url('assets/images/doodle-classic-balanced.svg'); }
/* ---- header hook -------------------------------------------- */
.theme-doodle-classic header.main-nav { position: relative; }
.theme-doodle-classic header.main-nav::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
/* one layer, always — doodle shapes must never overlap */
background: var(--doodle-url) top left / 300px auto repeat;
opacity: .15;
border-radius: inherit;
/* doodles thin out behind the center nav so the pills always win */
mask-image: linear-gradient(to right, black, transparent 38%, transparent 62%, black);
transition: opacity .3s;
}
.theme-doodle-classic header.main-nav > * {
position: relative;
z-index: 1;
}
/* ---- sidebar hook — RAILS + BLOOM, one layer, no overlap ----- */
/* One underlay; its mask is the UNION of two gradients (multiple
mask layers add by default):
RAILS — quiet doodle slivers down BOTH edges, always there no
matter how many lists (55% of the wash, outer 8px,
gone by 40px — never under row icons or text)
BLOOM — full width below the last list row, fading in over
110px from --content-end
Lots of lists = rails only. Few lists = rails + bloom.
The theme never disappears on heavy list users. */
.theme-doodle-classic aside.layout-sidebar { position: relative; }
.theme-doodle-classic aside.layout-sidebar::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: var(--doodle-url) top left / 300px auto repeat;
opacity: .15;
transition: opacity .3s;
mask-image:
linear-gradient(to right, rgba(0,0,0,.55) 8px, transparent 40px,
transparent calc(100% - 40px), rgba(0,0,0,.55) calc(100% - 8px)),
linear-gradient(to bottom, transparent var(--content-end, 62%),
black calc(var(--content-end, 62%) + 110px));
}
.theme-doodle-classic aside.layout-sidebar > * {
position: relative;
z-index: 1;
}
const settings = useSettingsStore();
const doodleTheme = ref('off');
// preload before flipping the class so the chrome never flashes
watch(() => settings.get('theme-doodle'), (name) => {
if (!name || name === 'off') { doodleTheme.value = 'off'; return; }
const img = new Image();
img.onload = () => { doodleTheme.value = name; };
img.src = DOODLE_ASSETS[name];
}, { immediate: true });
// layout root:
// <div class="layout" :class="doodleTheme !== 'off' && `theme-doodle-${doodleTheme}`">
// Sidebar.vue — keep --content-end fresh so the bloom starts where
// the lists stop (the rails don't need it; they're always on):
const asideEl = ref(null);
const navEl = ref(null); // wraps SearchSidebar + SubNavSidebar
onMounted(() => {
const ro = new ResizeObserver(() => {
asideEl.value.style.setProperty('--content-end',
`${navEl.value.offsetTop + navEl.value.offsetHeight + 8}px`);
});
ro.observe(navEl.value);
onUnmounted(() => ro.disconnect());
});
doodle-spooky.svg — authored Halloween tile · doodle-gold.svg — metallic-gradient ink + animated glint/* Every pack = card F's hooks + one url + one wash number.
The hooks read their opacity from var(--doodle-wash, .15) so a
light ink can run a stronger wash. */
.theme-doodle-classic {
--doodle-url: url('assets/images/doodle-classic-balanced.svg');
--doodle-wash: .15;
}
.theme-doodle-spooky {
--doodle-url: url('assets/images/doodle-spooky.svg');
--doodle-wash: .3; /* its own authored tile — pumpkins, ghosts,
bats, moons. Burnt-orange ink (#9A3412) is
far lighter than classic's near-black, so
the wash runs 2× or it disappears */
}
.theme-doodle-gold {
--doodle-url: url('assets/images/doodle-gold.svg');
--doodle-wash: .75; /* metallic gradient ink runs BOLD — gold is
the flex pack, it should look proud */
}
/* GOLD ONLY — the twinkle. A soft warm glow blinks on a few gold
shapes, briefly, three times per ~18s cycle, each blink at a
different spot (the glow repositions while invisible). Masked to
the doodle tile so only the shapes catch it; overshoot paints
white-on-white, invisible on light chrome. Quiet and occasional —
never a sweeping band. The two hooks run offset so they never
twinkle in sync. */
.theme-doodle-gold header.main-nav::after,
.theme-doodle-gold aside.layout-sidebar::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: radial-gradient(closest-side, rgba(255,252,235,.9),
rgba(255,252,235,0) 72%) no-repeat 18% 30% / 170px 170px;
mask-image: var(--doodle-url);
mask-size: 300px auto;
mask-repeat: repeat;
mask-position: top left;
opacity: 0;
animation: doodle-twinkle 18s linear infinite;
}
.theme-doodle-gold header.main-nav::after { border-radius: inherit; }
.theme-doodle-gold aside.layout-sidebar::after { animation-delay: -9s; }
@keyframes doodle-twinkle {
0% { background-position: 18% 30%; opacity: 0; }
4% { opacity: 0; }
6.5% { opacity: .55; }
9% { opacity: 0; }
10% { background-position: 74% 55%; }
36% { opacity: 0; }
38.5% { opacity: .55; }
41% { opacity: 0; }
42% { background-position: 40% 78%; }
68% { opacity: 0; }
70.5% { opacity: .5; }
73% { opacity: 0; }
74% { background-position: 18% 30%; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.theme-doodle-gold header.main-nav::after,
.theme-doodle-gold aside.layout-sidebar::after { animation: none; opacity: 0; }
}
| Action | Tickets | Daily cap | Wired to |
|---|---|---|---|
| Daily claim on Explore | +20 base, +5/streak day, max +40 | 1 | new account/arcade/claim |
| Copy an emoji or combo | +1 | first 20 count | existing copy ledger (account/copy) |
| Save a combo | +5 | first 3 | existing combo save |
| Follow a collection | +10 | first 3 | collection.followed SSE |
| Run a search | +1 | first 5 | existing new.search SSE |
| Earn an achievement | its bounty (10–2,000), paid when CLAIMED on the shelf | — | granted by server rules over the same events; paid via account/arcade/claim-achievement |
| Pro multiplier | 2× on everything | — | account.isPro |
| Name | How | Tickets | Tier | Gate |
|---|---|---|---|---|
| Hello, World | Copy your first emoji | 10 | Quiet | — |
| Century Club | Copy 100 emoji (progress bar) | 50 | Milestone | — |
| Thousandaire | Copy 1,000 emoji (progress bar) | 150 | Milestone | — |
| Chain Reaction | Build your first combo in the Action Bar | 20 | Quiet | — |
| Full Send | Build a max-size 8-emoji combo | 30 | Quiet | — |
| Keeper | Save a combo | 20 | Quiet | — |
| Fan Behavior | Follow your first collection | 10 | Quiet | — |
| Full House | Fill all six free collection slots | 30 | Milestone | — |
| Regular | Copy something 7 days in a row | 50 | Milestone | — |
| Locked In | 30-day copy streak | 150 | Milestone | — |
| Window Shopper | Visit Explore 7 days in a row | 40 | Milestone | — |
| Night Shift | Switch on dark mode | 10 | Quiet | — |
| That's Me | Set your skin tone | 10 | Quiet | — |
| Detective | Run 25 searches | 20 | Quiet | — |
| Hotkeys | Use a keyboard shortcut ( [ ] · ; · − + ) | 20 | Quiet | — |
| Everywhere | Install the Chrome extension | 60 | Milestone | hidden until launch |
| ??? ("Superfan") | Copy the same emoji 50 times | 100 | Secret | Secret |
| ??? ("Racecar") | Build a combo that reads the same forwards and backwards | 100 | Secret | Secret |
| ??? ("Secret Emoji") | Find all 5 emoji hidden around the app — each find teases the next; finds keep forever (hunt spec below) | 2,000 | Secret | Secret |
| ??? ("Konami") | Enter the code, anywhere: ↑ ↑ ↓ ↓ ← → ← → B A | 500 | Secret | Secret |
| ??? ("Speedrun") | Copy 10 emoji in 10 seconds | 50 | Secret | Secret |
| ??? ("Leap Day") | Use the app on February 29 | 200 | Secret | Secret |
| ??? ("Party Pooper") | Drag the poop emoji out of the Action Bar and drop it nowhere (was "Butterfingers", any emoji — reworked to poop-only) | 20 | Secret | Secret |
| List Maker | Create your first list | 40 | Quiet | Pro |
| Shelf Control | Keep three lists going | 60 | Milestone | Pro |
| Time Traveler | Turn on Recent Combos | 40 | Quiet | Pro |
| The Collector | Follow 10 collections | 80 | Milestone | Pro |
| Curator | Clone a collection and make it yours | 60 | Milestone | Pro |
| Prize | Cost | What it does | Alt names |
|---|---|---|---|
| Ticklish | 300 | Hovered emoji wiggle and giggle (single emoji, on the grid) | Poke, Wiggle Room |
| Copy Fireworks | 500 | A tiny firework pops at the cursor on every copy (rides the existing CopyInlay moment) | Pop-Off, Big Send |
| Combo Rumble | 800 | Combo hover · saved combos bounce and scrap with each other | Mosh Pit, Combo Brawl |
| Desk Toy | 600 | Combo hover · a 5-emoji combo swings like a Newton's cradle. The end one knocks through, clack clack. Fewer than 5, it plain-swings | Click-Clack, The Cradle |
| Domino Run | 400 | Combo hover · tips the first emoji into the next; the row topples in sequence and springs back up | Topple, Tip Over |
| Stadium Wave | 400 | Combo hover · the emoji do the wave down the row, one hop each, left to right | Crowd Work, The Wave |
| Squeeze Box | 400 | Combo hover · the combo compresses like an accordion, then sproings back apart | Sproing, Accordion |
| Doodle Classic | 1,000 | Skin your EmojiCopy in classic doodles | Scribble Mode |
| Doodle Seasonal | 600 | A new skin every season. Spooky in October, Cottagecore in spring | — |
| Doodle Gold | 2,000 + Pro | The gold skin. It actually glimmers (animated shine, Pro shelf only) | — |
| A Month of Pro | 7,500 | 30 days of Pro. Once a quarter, non-stackable | The Golden Ticket, Pro On the House |