The whole-window ad break guests and Free users hit on the web. Three options for the five-second bar and its exit.
Option B is the working pick; its ads audition on Creative in-house ads. The slot creative lives on Sidebar ads. The extension gets none of this — its upsell is on Extension upsell.
The bar is the exit: it fills for five seconds where the Continue button will be, then becomes it, and a ✕ fades in up top for hands already parked there. Creative here is the remove-ads pitch at the launch price: both real prices, never a save-% claim.
PromoBreak · new — the interstitial surface; one component, this card's exit if option A wins. Replaces the earlier ProTakeover two-beat specPopModal · ui/components/pop/PopModal.vue — chassis: fill + background-class="upsell-black"; receipts on card FPrimaryButton · ui/buttons/PrimaryButton.vue — variant="green"; size lg default renders f:body/l-heavy, exactly the stage's classes1f9f9, from the creative record.promo-break rows auto 1fr auto · topline, creative, exit bar — the bar keeps its 44px band at every viewport (R10).__price row · align-items: baseline — the $5 and the term share a baseline; that alignment is the row's whole job<template>
<div class="promo-break">
<div class="__topline">
<span class="__sorry">Sorry to interrupt your flow.</span>
<button v-show="done" class="__x" type="button" aria-label="Close" @click="finish">✕</button>
</div>
<div class="__center">
<img class="__emoji jp-font" :src="art" alt="">
<div class="__headline">{{ creative.headline }}</div>
<Text type="body/l-reg" color="metal-300">{{ creative.line }}</Text>
<div v-if="creative.price" class="__price">
<span class="__price-amount">$5</span>
<span class="__price-term">/mo or $48/yr</span>
</div>
<PrimaryButton variant="green" :label="creative.cta" @click="go" />
</div>
<button class="__meterbar" type="button" :disabled="!done" @click="finish">
<span v-if="!done" class="__meter" :style="{ width: pct }"></span>
<span class="__meter-label">{{ done ? 'Continue to EmojiCopy' : 'Continue in ' + left }}</span>
</button>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getEnv } from '@shared/config/env'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
const props = defineProps({ creative: { type: Object, required: true } })
const emit = defineEmits(['close'])
const WAIT_S = 5 // mirrors BREAK.barMs (store, card E)
const left = ref(WAIT_S)
const done = ref(false)
const pct = computed(() => (((WAIT_S - left.value) / WAIT_S) * 100) + '%')
const art = computed(() =>
'https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/' + props.creative.emoji + '.png')
/* done rides ONE setTimeout — the display interval only paints numerals.
If the interval dies, the exit still unlocks. */
let tick = null
let unlock = null
onMounted(() => {
unlock = setTimeout(() => { done.value = true }, WAIT_S * 1000)
tick = setInterval(() => { left.value = Math.max(left.value - 1, 0) }, 1000)
window.addEventListener('keydown', onKey)
})
onUnmounted(() => {
clearTimeout(unlock)
clearInterval(tick)
window.removeEventListener('keydown', onKey)
})
/* the chassis never unpops this surface (card F) — every exit is ours.
The free exits gate on done; the CTA bypasses the gate, engaging
with the creative always ends the break. */
const onKey = (e) => {
if (e.key === 'Escape' && done.value) emit('close')
}
const finish = () => {
if (done.value) emit('close')
}
const go = () => {
window.open(props.creative.href || getEnv('OAUTH_BASE_URL') + '/checkout')
emit('close')
}
</script>
<!-- chassis: fill takeover; a token-free unpop-on string means the
overlayer never closes it — the lock and every exit are the
component's own (card F) -->
<PopModal v-if="upsell.promoBreak.show" fill background-class="upsell-black"
unpop-on="never-unpop">
<PromoBreak :creative="upsell.promoBreak.creative"
@close="upsell.promoBreak.show = false" />
</PopModal>
// exits — the bar / ✕ / Escape from 5s · Upgrade to Pro any time
// trigger — maybeShowBreak() after a completed copy (card E)
/* full-bleed takeover: three bands, the bottom one is the exit.
Fixed-dark in both schemes (sales surface: flat metal-950, never
the doodle). No "Ad" chip — the sorry line is the acknowledgment.
Display type is DM Sans 900, the poster weight no app modal uses. */
.promo-break {
position: absolute;
inset: 0;
display: grid;
grid-template-rows: auto 1fr auto;
background: var(--metal-950);
font-family: var(--ff-inter);
text-align: center;
}
.promo-break .__topline {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 14px 16px;
}
.promo-break .__sorry {
font-size: 1.25rem;
color: var(--metal-400);
margin-right: auto; /* the spacer — pushes the ✕ to the edge */
text-align: left;
}
/* the ✕ — geometry is IconButton's own; translucent pill is a scoped
one-off (R13 caveat). Hidden until done; fades in, never shifts
layout. */
.promo-break .__x {
width: 30px;
height: 30px;
border: none;
border-radius: var(--r-pill);
background: rgba(255, 255, 255, .12);
color: var(--white);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
transition: opacity .2s;
}
.promo-break .__center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 32px;
}
.promo-break .__emoji { width: 64px; height: 64px; }
.promo-break .__headline {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.8rem;
line-height: 1.15;
color: var(--white);
max-width: 22ch;
}
/* type + color ride the Text props — only the measure is scoped */
.promo-break .__line { max-width: 40ch; }
.promo-break .__price {
display: flex;
align-items: baseline;
gap: 8px;
}
.promo-break .__price-amount {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 3.6rem;
line-height: 1;
color: var(--white);
}
.promo-break .__price-term { font-size: 1.3rem; color: var(--metal-300); }
/* the exit bar — fill while locked, whole-bar button at 0 */
.promo-break .__meterbar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 44px;
border: none;
padding: 0;
overflow: hidden;
background: rgba(255, 255, 255, .08);
cursor: default;
}
.promo-break .__meterbar:enabled { background: var(--green-500); cursor: pointer; }
.promo-break .__meter {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: var(--green-700);
transition: width 1s linear; /* smooths the per-second steps */
}
.promo-break .__meter-label {
position: relative;
font-size: 1.35rem;
font-weight: 700;
color: var(--white);
font-variant-numeric: tabular-nums; /* the numeral never jiggles */
}
.promo-break .__meterbar:enabled .__meter-label { color: var(--metal-950); }
The countdown rings the ✕, exactly where everyone expects an ✕ to live: the numeral counts down inside the ring, the ring closes at zero, and the ✕ takes its place. Quietest bar of the set; the removal offer is the standing footline.
PromoBreak · new — this card's exit if option B wins; same component contract as card A, ring + footline instead of the bar bandPopModal · ui/components/pop/PopModal.vue — chassis: fill + background-class="upsell-black"; receipts on card FSmallButton · ui/buttons/SmallButton.vue — variant="green", the creative CTA; renders f:body/s-heavy, exactly the stage's classes23f0, from the creative record.promo-break rows auto 1fr auto · topline, creative, footline (R10).__footline row · line + pill centered on one axis, gap owns the space (R11)<template>
<div class="promo-break">
<div class="__topline">
<span class="__sorry">Sorry to interrupt your flow.</span>
<span class="__ring">
<svg viewBox="0 0 34 34" aria-hidden="true">
<circle class="__ring-track" cx="17" cy="17" r="15" />
<circle class="__ring-fill" cx="17" cy="17" r="15"
stroke-dasharray="94.25" :stroke-dashoffset="ringOffset" />
</svg>
<span v-if="!done" class="__ring-num">{{ left }}</span>
<button v-else class="__x" type="button" aria-label="Close" @click="finish">✕</button>
</span>
</div>
<div class="__center">
<img class="__emoji jp-font" :src="art" alt="">
<div class="__headline">{{ creative.headline }}</div>
<Text type="body/l-reg" color="metal-300">{{ creative.line }}</Text>
<SmallButton variant="green" :label="creative.cta" @click="go" />
</div>
<div class="__footline">
<span><b>Done with ads?</b> Pro removes every one, forever.</span>
<button class="__pill" type="button" @click="removeAds">Remove ads</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getEnv } from '@shared/config/env'
import SmallButton from '@/buttons/SmallButton.vue'
const props = defineProps({ creative: { type: Object, required: true } })
const emit = defineEmits(['close'])
const WAIT_S = 5 // mirrors BREAK.barMs (store, card E)
const RING_LEN = 94.25 // 2π · r15, the dasharray above
const left = ref(WAIT_S)
const done = ref(false)
const ringOffset = computed(() => (left.value / WAIT_S) * RING_LEN)
const art = computed(() =>
'https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/' + props.creative.emoji + '.png')
let tick = null
let unlock = null
onMounted(() => {
unlock = setTimeout(() => { done.value = true }, WAIT_S * 1000)
tick = setInterval(() => { left.value = Math.max(left.value - 1, 0) }, 1000)
window.addEventListener('keydown', onKey)
})
onUnmounted(() => {
clearTimeout(unlock)
clearInterval(tick)
window.removeEventListener('keydown', onKey)
})
const onKey = (e) => {
if (e.key === 'Escape' && done.value) emit('close')
}
const finish = () => {
if (done.value) emit('close')
}
const go = () => {
window.open(props.creative.href || getEnv('OAUTH_BASE_URL') + '/checkout')
emit('close')
}
const removeAds = () => {
window.open(getEnv('OAUTH_BASE_URL') + '/checkout')
emit('close')
}
</script>
<PopModal v-if="upsell.promoBreak.show" fill background-class="upsell-black"
unpop-on="never-unpop">
<PromoBreak :creative="upsell.promoBreak.creative"
@close="upsell.promoBreak.show = false" />
</PopModal>
// exits — ✕ / Escape from 5s · Remove ads + creative CTA any time
// trigger — maybeShowBreak() after a completed copy (card E)
/* full-bleed takeover, same chrome as option A: flat metal-950,
never the doodle, no "Ad" chip. The exit is the ringed ✕; the
bottom band is a standing offer line, not a bar. */
.promo-break {
position: absolute;
inset: 0;
display: grid;
grid-template-rows: auto 1fr auto;
background: var(--metal-950);
font-family: var(--ff-inter);
text-align: center;
}
.promo-break .__topline {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 14px 16px;
}
.promo-break .__sorry {
font-size: 1.25rem;
color: var(--metal-400);
margin-right: auto;
text-align: left;
}
/* the ring — countdown and close share one 34px footprint, so the
exit appears exactly where the wait ran */
.promo-break .__ring { position: relative; width: 34px; height: 34px; flex: none; }
.promo-break .__ring svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg); /* fill starts at 12 o'clock */
}
.promo-break .__ring-track { fill: rgba(255, 255, 255, .06); stroke: rgba(255, 255, 255, .18); stroke-width: 2.5; }
.promo-break .__ring-fill {
fill: none;
stroke: var(--green-500);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 1s linear; /* smooths the steps */
}
.promo-break .__ring-num {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 1.15rem;
font-weight: 700;
color: var(--white);
font-variant-numeric: tabular-nums;
}
.promo-break .__ring .__x {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
border-radius: var(--r-pill);
background: none;
color: var(--white);
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
}
.promo-break .__center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 32px;
}
.promo-break .__emoji { width: 64px; height: 64px; }
.promo-break .__headline {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.8rem;
line-height: 1.15;
color: var(--white);
max-width: 22ch;
}
.promo-break .__line { max-width: 40ch; }
/* the standing offer — always-on, never gated, quiet */
.promo-break .__footline {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 16px;
background: rgba(255, 255, 255, .05);
font-size: 1.25rem;
color: var(--metal-300);
}
.promo-break .__footline b { color: var(--white); }
/* white pill so it never competes with the creative's green CTA;
scoped one-off (R13 caveat) */
.promo-break .__pill {
height: 30px;
padding: 0 14px;
border: none;
border-radius: var(--r-pill);
background: var(--white);
color: var(--metal-950);
font-weight: 700;
font-size: 1.25rem;
white-space: nowrap;
cursor: pointer;
}
The bar lives inside the Continue button itself: the fill sweeps through it for five seconds, then it goes green and live. The creative is display-only and the removal offer is the quiet line under the button — the least chrome of the set.
PromoBreak · new — this card's exit if option C wins; same component contract as card A, the loading button instead of a bar bandPopModal · ui/components/pop/PopModal.vue — chassis: fill + background-class="upsell-black"; receipts on card F1f4da, from the creative record.promo-break rows auto 1fr auto · topline + creative; the exit rides inside the creative column (R10).__center column · creative, button, never-line on one axis — gap owns every space (R11)<template>
<div class="promo-break">
<div class="__topline">
<span class="__sorry">Sorry to interrupt your flow.</span>
</div>
<div class="__center">
<img class="__emoji jp-font" :src="art" alt="">
<div class="__headline">{{ creative.headline }}</div>
<Text type="body/l-reg" color="metal-300">{{ creative.line }}</Text>
<button class="__go" type="button" :disabled="!done" @click="finish">
<span v-if="!done" class="__go-fill" :style="{ width: pct }"></span>
<span>{{ done ? 'Continue' : 'Continue in ' + left }}</span>
</button>
<button class="__never" type="button" @click="removeAds">Never see ads again · Go Pro</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getEnv } from '@shared/config/env'
const props = defineProps({ creative: { type: Object, required: true } })
const emit = defineEmits(['close'])
const WAIT_S = 5 // mirrors BREAK.barMs (store, card E)
const left = ref(WAIT_S)
const done = ref(false)
const pct = computed(() => (((WAIT_S - left.value) / WAIT_S) * 100) + '%')
const art = computed(() =>
'https://cdn.jsdelivr.net/joypixels/assets/11.0/png/unicode/64/' + props.creative.emoji + '.png')
let tick = null
let unlock = null
onMounted(() => {
unlock = setTimeout(() => { done.value = true }, WAIT_S * 1000)
tick = setInterval(() => { left.value = Math.max(left.value - 1, 0) }, 1000)
window.addEventListener('keydown', onKey)
})
onUnmounted(() => {
clearTimeout(unlock)
clearInterval(tick)
window.removeEventListener('keydown', onKey)
})
/* the creative is display-only in this option — no go() wiring.
The never-line bypasses the gate; Continue and Escape wait. */
const onKey = (e) => {
if (e.key === 'Escape' && done.value) emit('close')
}
const finish = () => {
if (done.value) emit('close')
}
const removeAds = () => {
window.open(getEnv('OAUTH_BASE_URL') + '/checkout')
emit('close')
}
</script>
<PopModal v-if="upsell.promoBreak.show" fill background-class="upsell-black"
unpop-on="never-unpop">
<PromoBreak :creative="upsell.promoBreak.creative"
@close="upsell.promoBreak.show = false" />
</PopModal>
// exits — Continue / Escape from 5s · Never see ads again any time
// trigger — maybeShowBreak() after a completed copy (card E)
/* full-bleed takeover, same chrome as option A: flat metal-950,
never the doodle, no "Ad" chip. The exit is the button itself. */
.promo-break {
position: absolute;
inset: 0;
display: grid;
grid-template-rows: auto 1fr auto;
background: var(--metal-950);
font-family: var(--ff-inter);
text-align: center;
}
.promo-break .__topline {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 14px 16px;
}
.promo-break .__sorry {
font-size: 1.25rem;
color: var(--metal-400);
margin-right: auto;
text-align: left;
}
.promo-break .__center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 32px;
}
.promo-break .__emoji { width: 64px; height: 64px; }
.promo-break .__headline {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.8rem;
line-height: 1.15;
color: var(--white);
max-width: 22ch;
}
.promo-break .__line { max-width: 40ch; }
/* the button that loads — fill sweeps inside it, then it goes green
and live. Scoped one-off (R13 caveat): no system button loads. */
.promo-break .__go {
position: relative;
overflow: hidden;
height: 44px;
min-width: 280px;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: var(--r-pill);
background: none;
color: var(--white);
font-family: var(--ff-inter);
font-size: 1.35rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: default;
}
.promo-break .__go:enabled {
border-color: transparent;
background: var(--green-500);
color: var(--metal-950);
cursor: pointer;
}
.promo-break .__go-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: rgba(255, 255, 255, .14);
transition: width 1s linear;
}
.promo-break .__go span { position: relative; }
.promo-break .__never {
border: none;
background: none;
font-family: var(--ff-inter);
font-size: 1.2rem;
color: var(--metal-400);
text-decoration: underline;
cursor: pointer;
}
One voice with the Sidebar ads slot creative — same lines, same bans: no counts, no save-%, no beta. The winning option renders whichever creative the rotation picks.
BREAK_CREATIVES + pickBreakCreative · new — data + one picker beside the gate in ui/store/upsell.jsgetProp / setProp · @shared/storage/local — promo_break_last_creative keeps the rotation honest.creative-grid · auto-fill minmax(230px, 1fr) — preview chrome only, the grid is this page's, not the product's (R10)/* every creative the break can serve. href absent = checkout.
price: true = the surface renders the launch-price block. */
const BREAK_CREATIVES = [
{ id: 'joypixels', emoji: '1f3a8', tiers: ['guest', 'free'],
headline: 'Made with JoyPixels.',
line: 'Every emoji in EmojiCopy is JoyPixels art. Explore the whole set.',
cta: 'Meet JoyPixels', href: 'https://joypixels.com' },
{ id: 'no-promos', emoji: '1f9f9', tiers: ['free'], price: true,
headline: 'Pro removes the ads.',
line: 'All of them, everywhere, for good.',
cta: 'Go Pro' },
{ id: 'recents', emoji: '23f0', tiers: ['free'],
headline: 'Recents that sort themselves.',
line: 'Smart Recents keeps what you actually use up front.',
cta: 'Go Pro' },
{ id: 'lists', emoji: '1f4da', tiers: ['free'],
headline: 'Build every list you want.',
line: 'Lists and collections go unlimited with Pro.',
cta: 'Go Pro' },
{ id: 'account', emoji: '2764', tiers: ['guest'],
headline: 'Keep your favorites.',
line: 'A free account saves favorites, combos, and settings.',
cta: 'Create free account' }
]
/* tier-filtered, never last time's */
const pickBreakCreative = async (tier) => {
const last = await getProp('promo_break_last_creative')
const pool = BREAK_CREATIVES.filter((c) => c.tiers.includes(tier) && c.id !== last)
return pool[Math.floor(Math.random() * pool.length)]
}
// maybeShowBreak(), card E — the only caller this.promoBreak.creative = await pickBreakCreative(useAccountStore().tier)
| Rule | Setting | Why it is not negotiable |
|---|---|---|
| The bar | 5s, visible | The bar is the promise that the break ends. Whichever option wins, the wait is shown honestly — a numeral, a fill, or both. The five-second countdown-to-continue is the one interstitial contract every user already knows. |
| Dismissible | At 0, not before | The bar's run is the lock: 5s, once a week, only ever after a completed action. House creative carries no third-party tags, so Chrome's ad-filter regime does not reach it, and the Search interstitial penalty is about content on load — this never fires on load (see Trigger). When the bar lands, the exit appears full-size exactly where the countdown ran, never shrunk or camouflaged, and Escape unlocks with it. |
| Self-labeling | None | No "Ad" chip, no sponsor line. This is first-party house creative — third-party ad-marking obligations do not apply, and the sorry line already owns the acknowledgment. Minimal ad language is the design call (8-14). |
| The forever exit | On every option | The break is the highest-intent moment for the no-ads offer — ad-supported apps that convert keep the remove-ads purchase visible at the ad moment, not buried in settings. A sells it as the creative, B and C carry the standing line. |
| Trigger | After a completed copy or insert | Never on load, never on route change, never at session start. Google's own full-screen formats all exclude first page load, and a promo on arrival is the textbook Search intrusive-interstitial trigger. 48% of our traffic is organic search. |
| Frequency | 1 per 7 days, hard | A 52-second session with 1.34 pages does not support more. Suppressed for 24h after any Pro modal, and never in the same session as the tutorial. |
| Session floor | 3rd session or later | Nobody's first two visits get a break. Also never inside the first 60 seconds of an app open. |
| Who sees it | Guests + Free | Both free tiers get breaks; the creative is picked by tier. Guests never get the $5 ask — their pool is the JoyPixels and free-account creatives (card D). Pro users never see it anywhere, ever. |
| Mobile web | Never | Every full-screen promo on load fails the mobile Better Ads standard regardless of countdown, and mobile is where the Search interstitial signal originated. Under 744px, free tiers get the inline row and nothing else. |
| Extension + desktop | Never | Chrome Web Store policy, not taste. Ads are a web-only surface. See the gating fix on Extension upsell. |
| Third-party ads in it | Never | House creative only. First-party promos are outside Chrome's ad-filtering regime entirely; a third-party tag drags in a certified CMP, GPC handling, and a COPPA determination for roughly $980 a month. |
promoBreak slice + two actions in ui/store/upsell.js; every table row is one named constantuseAccountStore · ui/store/account.js — tier reads 'guest' | 'free' | 'pro'; the audience row is one checkuseUpsellStore · ui/store/upsell.js — tutorial state already lives here; the tutorial suppression reads itruntime() · @shared/config/platform (apps/_shared/config/platform.js) — 'web' | 'extension' | 'desktop'; the web-only rows are one callgetProp / setProp · @shared/storage/local — the caps persist next to viewed_beta_tutorial/* state — joins the feature/doodle/tutorial/pro slices */
promoBreak: {
show: false,
creative: null
}
/* every rule on the card, one named value each */
const BREAK = {
barMs: 5000, // the bar's run; the exit unlocks at 0
capDays: 7, // 1 per 7 days, hard
sessionFloor: 3, // 3rd session or later
sessionWarmupMs: 60000, // never in the first minute of an open
proModalCooldownMs: 86400000, // 24h after any Pro modal
minViewportPx: 744 // under this: inline row only
}
/* actions */
async canShowBreak() {
const account = useAccountStore()
if (runtime() !== 'web') return false // extension + desktop: never (store policy)
if (account.tier === 'pro') return false // Pro sees nothing, ever
if (window.innerWidth < BREAK.minViewportPx) return false
if (this.tutorial.show || this.tutorial.shownThisSession) return false
const now = Date.now()
if (now - sessionStartedAt < BREAK.sessionWarmupMs) return false
const sessions = Number(await getProp('session_count') || 0)
if (sessions < BREAK.sessionFloor) return false
const lastSeen = Number(await getProp('promo_break_last_seen') || 0)
if (now - lastSeen < BREAK.capDays * 86400000) return false
const lastPro = Number(await getProp('pro_modal_last_seen') || 0)
if (now - lastPro < BREAK.proModalCooldownMs) return false
return true
},
async maybeShowBreak() {
if (!(await this.canShowBreak())) return
this.promoBreak.creative = await pickBreakCreative(useAccountStore().tier) // card D
this.promoBreak.show = true
setProp('promo_break_last_seen', String(Date.now()))
setProp('promo_break_last_creative', this.promoBreak.creative.id)
}
// ui/utils/services/emoji.js — every copy path funnels through the // service (five navigator.clipboard.writeText sites). After the copy // resolves, ask the gate — fire-and-forget, never blocks the copy: navigator.clipboard.writeText(str) useUpsellStore().maybeShowBreak() // The ONLY trigger. Never from: app mount, route change, session // start, tab focus. (Trigger row — 48% of our traffic is organic search.)
PopModal · ui/components/pop/PopModal.vue — every chassis claim re-verified in the latest client; persistent declared at :35 and read nowhere else in the fileoverlayer.js · ui/utils/overlayer.js — unpop matching is value.type.search(eventName): substring, no exact match, no validation — the token-free string lock rides exactly thisgetProp / setProp · @shared/storage/local — App.vue:100 uses the same pair for viewed_beta_tutorialVideoAd + PromptWrapper · ui/components/Upsell/ — the stage's z-index pair: VideoAd.vue:16 pins 100000; FeedbackPrompt renders through PromptWrapper.vue (fixed :79, z-index 100 at :83)/* build note 2 made real: .fill zeroes .pop-modal's own padding but
.pop-modal-scroll-wrapper keeps padding: var(--pad-outer-block) 0
— on a fill takeover that reads as a 24-32px backdrop letterbox.
Zero it for fill only; every other mode keeps its band. (The
scroll:content wrapper's padding: 0 line is commented out in
source — don't count on it.) */
.pop-modal.fill .pop-modal-scroll-wrapper { padding: 0 }
import { getProp, setProp } from '@shared/storage/local'
// write side — maybeShowBreak, the moment it flips show
setProp('promo_break_last_seen', String(Date.now()))
setProp('promo_break_last_creative', creative.id)
// read side — the gate, next time around
const lastSeen = Number(await getProp('promo_break_last_seen') || 0)