An achievement screen, a daily check in, and a free first month of Pro at the end. Mocked under the working name Press Start; alternates in the note at the bottom.
<ArcadePill> · new atom, shared with the full Arcade (explore-arcade.html card A) — the Starter ships it firstuseArcadeStore · ui/store/arcade.js — starter scope: achievements + check-in only, no tickets (source in card 2's drawer)ui/pages/Explore.vue — the search/controls row, right group, before the view toggle.arcade-pill · emoji + label + dot flow inline — a simple run of items<script setup>
import { useArcadeStore } from '@/store/arcade'
const arcade = useArcadeStore()
/* JoyPixels PNG, never the emoji webfont — its color formats don't
load in every browser (the pill-dot-swap C decision) */
const emojiPng = cp => `https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/${cp}.png`
</script>
<template>
<button class="arcade-pill" type="button" :aria-label="arcade.name" @click="arcade.open()">
<img class="__emoji" :src="emojiPng('1f579')" alt="" />
<Text type="body/m-heavy" color="text-secondary" :text="arcade.name" />
<span v-if="arcade.checkinWaiting" class="__news" />
</button>
</template>
<!-- ui/pages/Explore.vue — right group of the search row, before the view toggle --> <ArcadePill />
.arcade-pill {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 5px 12px 5px 9px;
border: none;
border-radius: var(--r-pill);
background: var(--c-surface-tertiary);
line-height: 1;
cursor: pointer;
}
.arcade-pill .__emoji {
width: 19px;
height: 19px;
flex: none;
}
/* the news dot — grape, same value the full Arcade specs, so nothing
restyles later. Not a tokens.css color on purpose: it must never
read as the app's green. */
.arcade-pill .__news {
position: absolute;
top: -4px;
right: -4px;
width: 11px;
height: 11px;
border-radius: 50%;
background: #7C3AED;
box-shadow: 0 0 0 2px var(--c-surface-primary);
}
<PopModal> · ui/components/pop/PopModal.vue — the shell; background-class="upsell-black", width 640<IconButton icon="x" variant="white"> · the close<Text> — every string; fixed-dark surface, so c-mapped primitives (white / metal-300)<ArcadeModal> surface · starter scope — no tabs, no balance, one list<CrownGoal> + <AchievementRow> atoms · shared with the full Arcade — same ids, same shapes, nothing rebuilds lateruseArcadeStore · ui/store/arcade.js — source below.arcade-modal column · an unknown quantity of rows, one even gap: var(--sp-3).achievement-row · medal + body + action flow in a row<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import IconButton from '@/buttons/IconButton.vue'
import CrownGoal from '@/components/arcade/CrownGoal.vue'
import AchievementRow from '@/components/arcade/AchievementRow.vue'
import { useArcadeStore } from '@/store/arcade'
const arcade = useArcadeStore()
const emojiPng = cp => `https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/${cp}.png`
</script>
<template>
<PopModal :width="'640px'" :popped="arcade.isOpen" @unpop="arcade.close()"
:background-class="'upsell-black'">
<div class="arcade-modal">
<div class="__head">
<img class="__emoji" :src="emojiPng('1f579')" alt="" />
<Text tag="h5" color="white" :text="arcade.name" />
<IconButton icon="x" size="md" variant="white" class="__close" @click="arcade.close()" />
</div>
<CrownGoal />
<AchievementRow :achievement="arcade.daily" />
<AchievementRow v-for="a in arcade.sorted" :key="a.id" :achievement="a" />
</div>
</PopModal>
</template>
import { defineStore } from 'pinia'
/* Persistence mirrors surface.js: manual getProp/setProp per key,
hydrate() on mount. Definitions are shared with the full Arcade —
the ids and copy below never change when the rest arrives. */
export const useArcadeStore = defineStore('arcade', {
state: () => ({
name: 'Press Start', // ONE string — pill + modal titles read it
isOpen: false,
showFreeMonth: false,
checkins: [], // ISO local dates, max 7, never resets
achievements: [
{ id: 'hello-world', emoji: '1f44b', name: 'Hello, World', desc: 'Copy your first emoji' },
{ id: 'playing-favorites', emoji: '2764', name: 'Playing Favorites', desc: 'Add an emoji to Favorites' },
{ id: 'chain-reaction', emoji: '26a1', name: 'Chain Reaction', desc: 'Build your first combo in the Action Bar' },
{ id: 'keeper', emoji: '1f4cc', name: 'Keeper', desc: 'Save a combo' },
{ id: 'fan-behavior', emoji: '1f4e3', name: 'Fan Behavior', desc: 'Follow your first collection' },
{ id: 'night-shift', emoji: '1f319', name: 'Night Shift', desc: 'Switch on dark mode' },
{ id: 'hotkeys', emoji: '2328', name: 'Hotkeys', desc: 'Use a keyboard shortcut' },
{ id: 'detective', emoji: '1f50d', name: 'Detective', desc: 'Run 25 searches', target: 25, progress: 0 },
{ id: 'century-club', emoji: '1f4af', name: 'Century Club', desc: 'Copy 100 emoji', target: 100, progress: 0 },
],
}),
getters: {
daily() { /* the check-in pseudo-achievement — card 3 */ },
sorted() { /* earned first, then locked, page order stable */ },
checkinWaiting() {
return this.checkins.length < 7 && !this.checkins.includes(todayLocal())
},
crownProgress() { /* (earned + checkins.length/7) of 10, 0..1 */ },
crownReady() { return this.crownProgress === 1 },
},
actions: {
open() { this.isOpen = true },
close() { this.isOpen = false },
checkIn() { /* stamp todayLocal(), optimistic, silent retry */ },
complete(id) { /* mark earned — fired by the app's own events */ },
},
})
.arcade-modal {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-5);
}
.arcade-modal .__head {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.arcade-modal .__head .__emoji { width: 22px; height: 22px; flex: none; }
.arcade-modal .__head .__close { margin-left: auto; } /* pin right, not spacing */
/* CrownGoal — the month-of-Pro header row. crown-gold #FFDB59 is the
full-Arcade spec value (not a tokens.css color on purpose). */
.crown-goal {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
border-radius: var(--r-md);
background: color-mix(in srgb, #FFDB59 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, #FFDB59 26%, transparent);
}
.crown-goal .__emoji { width: 20px; height: 20px; flex: none; }
.crown-goal .__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.crown-goal .__bar {
max-width: 220px;
height: 7px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 18%, transparent);
overflow: hidden;
}
.crown-goal .__bar span { display: block; height: 100%; border-radius: var(--r-pill); background: #FFDB59; }
/* AchievementRow — shared atom; variants below are the whole state set */
.achievement-row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
border-radius: var(--r-md);
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);
}
.achievement-row.is-earned { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green-500) 35%, transparent); }
.achievement-row.is-locked .__medal { filter: grayscale(1); opacity: .6; }
.achievement-row .__medal {
position: relative;
display: grid; /* centering a single glyph */
place-items: center;
width: 40px;
height: 40px;
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);
flex: none;
}
.achievement-row .__medal img { width: 19px; height: 19px; }
.achievement-row .__check {
position: absolute;
right: -2px;
bottom: -2px;
width: 14px;
height: 14px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--green-500);
color: var(--metal-950);
font-size: .9rem;
box-shadow: 0 0 0 2px var(--metal-950);
}
.achievement-row .__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.achievement-row .__progress { display: flex; align-items: center; gap: var(--sp-2); }
.achievement-row .__progress .__bar { max-width: 140px; }
.achievement-row .__bar span { background: var(--green-600); }
<AchievementRow> · the daily variant of the same atom (card 2's source + CSS apply verbatim) — this card is a state change only<AchievementRow :achievement="arcade.daily" />
<!-- before today's tap: the one interactive row on the screen -->
arcade.daily → is-daily · name 'Daily check in' · bar 4/7 · button 'Check in'
<!-- arcade.checkIn() stamps todayLocal(), then -->
arcade.daily → is-daily-done · bar 5/7 · button 'Checked in' (disabled)
desc 'Back tomorrow for day 6'
.achievement-row.is-daily {
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);
}
.achievement-row .__checkin {
border-radius: var(--r-pill);
padding: 8px 17px;
background: var(--green-500);
color: var(--metal-950);
white-space: nowrap;
}
.achievement-row.is-daily-done {
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);
}
.achievement-row.is-daily-done .__checkin {
background: color-mix(in srgb, var(--metal-300) 16%, transparent);
color: color-mix(in srgb, var(--white) 60%, transparent);
}
<ArcadeModal> screen as card 2 — this card is the arcade.crownReady state<CrownGoal> ready variant — gold wash + the one gold button in the apparcade.crownReady === true // all nine earned + 7 of 7 check-ins
<CrownGoal /> // renders is-ready: gold wash deepens, the progress
// bar leaves, the button arrives
// desc 'All ten, done. Your first month of Pro is free.'
// button 'Start my free month' → arcade.startFreeMonth()
.crown-goal.is-ready {
background: color-mix(in srgb, #FFDB59 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, #FFDB59 45%, transparent);
}
.crown-goal .__start {
border-radius: var(--r-pill);
padding: 8px 17px;
background: #FFDB59;
color: var(--metal-950);
white-space: nowrap;
}
Every achievement, and seven check ins. Your first month of Pro is free: lists, recent combos, and follow as many collections as you like.
Card required to start. Renews after the first month, cancel any time in Settings.
<PopModal> width 480 · background-class="upsell-black" — stacked over the Arcade modal, the same layering ProModal already rides<PrimaryButton> · variant="gray" + variant="green", size="lg"<Text> — every string; c-mapped primitives on the fixed-dark surface<PopModalEyebrow> · catalog — reads arcade.name<FreeMonthModal> surface · the confirm between the gold button and checkout.free-month column · one even gap: var(--sp-4), spacing by gaps only.__footer · gray hugs its text, green takes the rest (truncation fix below)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import { useArcadeStore } from '@/store/arcade'
const arcade = useArcadeStore()
</script>
<template>
<PopModal :width="'480px'" :popped="arcade.showFreeMonth"
@unpop="arcade.dismissFreeMonth()" :background-class="'upsell-black'">
<div class="free-month">
<PopModalEyebrow>{{ arcade.name }}</PopModalEyebrow>
<Text tag="h3" color="white" text="All ten, done" />
<Text type="body/l-reg" color="metal-300"
text="Every achievement, and seven check ins. Your first month of Pro is free: lists, recent combos, and follow as many collections as you like." />
<Text type="body/s-reg" color="metal-400"
text="Card required to start. Renews after the first month, cancel any time in Settings." />
<div class="__footer">
<PrimaryButton variant="gray" label="Maybe later" @click="arcade.dismissFreeMonth()" />
<PrimaryButton variant="green" label="Start my free month" @click="arcade.startFreeMonth()" />
</div>
</div>
</PopModal>
</template>
.free-month {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-6) var(--sp-5) var(--sp-5);
text-align: left;
}
/* the app's lg button is fixed-width and ellipsizes long labels
("Start my f…"). Gray hugs its text, green takes the rest — the
promise never truncates. Known PopModal 2-button issue. */
.free-month .__footer {
display: flex;
gap: var(--sp-3);
}
.free-month .__footer .button { width: auto; flex: 0 0 auto; padding-left: 20px; padding-right: 20px; }
.free-month .__footer .button:last-child { flex: 1 1 auto; }
.free-month .__footer .button label { overflow: visible; text-overflow: clip; white-space: nowrap; }