How the Chrome extension sells Pro without carrying a single promo. It cannot carry one: Chrome Web Store policy bans serving ads in an extension, and Manifest V3 bans the remotely hosted code an ad tag is made of. So the extension sells at the moment someone hits a limit, and nowhere else.
The web version of this is Interstitial ad. The install tab already exists as Extension install page.
Nothing is hidden and nothing is added. The crown is already the app's Pro mark, and it already ships on these rows.
Icon · ui/components/IconIndex.vue:110 — crown-gold: PhCrownSimple, weight fill, #FFDB59. The app's one Pro markActionMenuRow · ui/menus/ActionMenuRow.vue — the row; the crown rides its icon-end slot when !account.isProGroupManagement · GroupManagement.vue:51,120 — the "New list" rows already crown for non-ProGroupHeading / GroupSidebarButton · GroupHeading.vue:93,128,137,152, GroupSidebarButton.vue:91 — Sort and view by / List settings / Clone / Delete, same patternaccount.isPro · ui/store/account.js:163 — subscription?.status?.active; the only gate condition.locked-row row · emoji + label + crown; label { flex: 1 } pushes the crown to the row end.popup__body column · preview chrome only — the real rows live in the shipping menus<!-- Already shipping. The crown appears on the icon-end slot whenever
the account is not Pro: -->
<!-- GroupManagement.vue:51 — "New list" -->
<ActionMenuRow
label="New list"
:icon-end="!account.isPro ? 'crown-gold' : 'plus'" />
<!-- GroupHeading.vue:91-95 — Smart Recents sort, same shape -->
<ActionMenuRow
label="Sort and view by" sub="Sort list items"
:icon-end="!account.isPro ? 'crown-gold' : 'sort-ascending'"
@click="!account.isPro ? emit('clickAction', 'sort-smart-list') : next()" />
<!-- The extension changes ONE thing: where the crown tap lands. Web
routes these clickActions to FeatureModal / ProModal; the extension
routes the same events to the in-popup GateSheet (card B). Rows,
crown, and gate condition are untouched. -->
Favorites free — never crowned. crown-simple marks sign-in gates
(GroupHeading.vue:87), crown-gold marks Pro. Two
different asks, two different marks.
New list crown-gold while !account.isPro — Pro lifts the list
cap (PLANS-PRICING.md: infinite favorite lists).
Smart Recents crown-gold while !account.isPro — the up-to-96 window
and full settings are Pro (PLANS-PRICING.md: Smart
Recents).
/* The .popup frame is page furniture; nothing in this pane ships.
It exists so the mock can be rebuilt pixel-for-pixel. */
.locked-row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--c-surface-secondary);
}
.locked-row__emoji { width: 20px; height: 20px; }
.locked-row__label {
flex: 1; /* pushes the crown to the end */
font-family: var(--ff-inter);
font-weight: 600;
font-size: 1.35rem;
color: var(--c-text-primary);
}
.locked-row__crown { width: 16px; height: 16px; color: var(--c-icon-yellow); flex: none; }
/* a locked row dims its label one step — the crown carries the state */
.locked-row--muted .locked-row__label { color: var(--c-text-tertiary); }
Checkout has to leave the popup. Chrome killed extension payments in 2021, and the popup unmounts the moment focus leaves it, so no flow survives inside it. One tap, straight to an already-signed-in tab.
GateSheet · new — the in-popup sheet; two states, explain then offer, pinned to the popup floorSmallButton · ui/buttons/SmallButton.vue — the CTA; label/variant/size props, size="sm" renders text f:body/s-heavyButton variant:green · ui/buttons/Button.vue:173 — --c-button-green-bg; ships todayText · ui/components/Text.vue — all four strings; type + color propsui/modals/ProModal.vue:22 — getEnv('OAUTH_BASE_URL') + '/checkout'.gate-sheet column · align-start, gap: 8px; margin-top: auto pins the sheet to the popup floor so the rows above never move<script setup>
import { ref, watch } from 'vue'
import SmallButton from '@/buttons/SmallButton.vue'
import Text from '@/components/Text.vue'
import { getEnv } from '@shared/config/env'
const props = defineProps(['feature', 'start'])
/* one entry per crowned row. 'new-list' is final copy; the rest land
with the crown-row decision flagged in card A. 'pro' is the generic
offer the side panel footer opens (card C) — no explain state. */
const features = {
'new-list': {
explainTitle: 'Lists are yours to build.',
explainLine: 'Any theme, any order. Pro lifts the cap on how many you keep.',
offerTitle: 'Build every list you want.',
},
'pro': {
offerTitle: 'Get more with Pro!',
},
}
/* first crown tap explains, a repeat tap on the same row sells. All of
it resets when the popup closes — every popup session explains first. */
const state = ref(props.start || 'explain')
watch(() => props.feature, () => { state.value = 'explain' })
const advance = () => { state.value = 'offer' }
defineExpose({ advance })
const checkout = () => {
/* the same path ProModal.vue:22 opens on web — tabs.create is the
extension-safe form, the popup window is not a normal tab */
chrome.tabs.create({ url: getEnv('OAUTH_BASE_URL') + '/checkout' })
}
</script>
<template>
<div class="gate-sheet" :class="'gate-sheet--' + state">
<template v-if="state === 'explain'">
<Text type="body/m-heavy" class="__title" :text="features[props.feature].explainTitle" />
<Text type="body/s-med" class="__line" :text="features[props.feature].explainLine" />
</template>
<template v-else>
<Text type="body/m-heavy" color="white" class="__title" :text="features[props.feature].offerTitle" />
<Text type="body/s-med" color="metal-300" class="__line" text="$5/mo or $48/yr." />
<SmallButton label="Upgrade to Pro" variant="green" size="sm" class="__cta" @click="checkout" />
<Text type="body/s-med" color="metal-400" class="__foot" text="Opens emojicopy.com" />
</template>
</div>
</template>
<!-- the popup mounts one sheet at the floor of the body; the crown
rows' clickAction routes here in the extension runtime -->
<GateSheet v-if="gatedFeature" ref="$gate" :feature="gatedFeature" />
// the crown-row handler, extension runtime
const onCrownTap = (feature) => {
if (gatedFeature.value === feature) $gate.value.advance()
else gatedFeature.value = feature
}
<!-- this card's left slot --> first tap on New list — state 'explain'
<!-- this card's right slot --> repeat tap — advance() — state 'offer'
.gate-sheet {
margin-top: auto; /* pins to the popup floor */
padding: 16px;
border-radius: 14px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
/* first tap — information, in the app's own surface inks */
.gate-sheet--explain {
background: var(--c-surface-secondary);
border: 1px solid var(--c-border-primary);
}
.gate-sheet--explain .__title { color: var(--c-text-primary); }
.gate-sheet--explain .__line { color: var(--c-text-secondary); }
/* second tap — the offer, fixed-dark like every sales surface.
Offer inks ride the Text color prop (white / metal-300 / metal-400). */
.gate-sheet--offer { background: var(--metal-950); }
.gate-sheet--offer .__cta { margin-top: 6px; } /* optical nudge above the
CTA — a margin on a child (R11 deviation): log in DEV-NOTES if kept,
or absorb it into the sheet gap */
The row is gone the instant the account is Pro, not greyed and not swapped for a thank-you. The single loudest complaint against extensions that do this is that they keep nagging people who already paid.
ProFooterRow · new — crown + label + chevron; one row of panel furniture, no state of its ownIcon · ui/components/IconIndex.vue:110 — crown-gold (PhCrownSimple fill #FFDB59) + arrow-caret-right for the chevron; both shipText · ui/components/Text.vue — the label, body/s-heavyaccount.isPro · ui/store/account.js:163 — the row's entire lifecycle: mounted while false, gone when trueGateSheet — card B's sheet, opened straight at its offer state with the generic pro entry; the CSS this card needs is repeated below.pro-footer-row row · gap: 9px; label { flex: 1 } pushes the chevron to the end; border-top splits the row from the grid<script setup>
import Icon from '@/components/Icon.vue'
import Text from '@/components/Text.vue'
const emit = defineEmits(['open'])
</script>
<template>
<!-- the !account.isPro condition rides the invocation, not the
component — the row itself has no state at all -->
<button class="pro-footer-row" type="button" @click="emit('open')">
<Icon name="crown-gold" />
<Text type="body/s-heavy" class="__label" text="EmojiCopy Pro" />
<Icon name="arrow-caret-right" class="__chevron" />
</button>
</template>
<ProFooterRow
v-if="runtime() === 'extension' && !account.isPro"
@open="showPanelOffer = true" />
<!-- the tap opens card B's sheet straight at the offer — the row tap
IS the request to hear it, so no explain step here -->
<GateSheet v-if="showPanelOffer" feature="pro" start="offer" />
.pro-footer-row {
display: flex;
align-items: center;
width: 100%;
gap: 9px;
padding: 11px 13px;
border-top: 1px solid var(--c-border-primary);
background: var(--c-surface-secondary);
cursor: pointer;
}
.pro-footer-row .__label {
flex: 1; /* pushes the chevron to the end */
text-align: left;
color: var(--c-text-primary);
}
.pro-footer-row .__chevron { color: var(--c-text-tertiary); }
/* the sheet the tap opens — card B's offer state */
.gate-sheet {
margin-top: auto;
padding: 16px;
border-radius: 14px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.gate-sheet--offer { background: var(--metal-950); }
.gate-sheet--offer .__cta { margin-top: 6px; }
| Never | Because |
|---|---|
| Any ad, anywhere | Three independent walls: Chrome Web Store's Ads policy states AdSense may not be used to serve ads in Products; Limited Use bans using extension data for personalized ads; MV3 bans remotely hosted code, which is what an ad tag is. AdSense's placement policy also names desktop applications, so the Tauri app is ad-free by policy too. The internal line should be "ads are a web-only surface", not "the extension won't show ads". |
| A promo on third-party pages | The content script already runs on every http/https page at document_start. Painting anything promotional there is the fastest possible route to a takedown — injecting ads into web pages is a distinct purpose that cannot be combined with any other functionality. |
| chrome.notifications | Not in the manifest today. Adding it re-triggers the permission-change warning for every existing user, and the Spam policy names promotions directly. |
| A toolbar badge for selling | Badge real user-state events or nothing. It isn't used for anything today. |
| A full-screen takeover in the popup or panel | No comparable extension does this. Quality Guidelines hold persistent UI to a "minimal distractions" standard and name side panels that hijack browsing as a violation. A footer row passes; a recurring panel interstitial does not. |
| A permanent pay-us banner with nothing gated | If the Pro delta isn't real enough to gate, the right answer is no extension upsell at all. The best-rated extension in the study set sells nothing in-extension and holds 4.7. |
| A sign-in wall on the core loop | Signup-gated extensions see 30-40% immediate uninstall. At sub-1% conversion that never recovers. |
| Retroactively gating what today's users have | Whatever Pro gates must be new capability. Existing "Emoji Keyboard by JoyPixels" users keep what they have. Needs a decision before build. |
apps/chrome/manifest.json — the review surface today: permissions storage · identity · activeTab · clipboardWrite · sidePanel; content script at document_start on all http/https; no notifications, no badge codeapps/chrome + vite — what CWS actually evaluates; dead code counts the same as live code.rules · a native table, page furniture — this card is policy, nothing on it ships"permissions": ["storage", "identity", "activeTab", "clipboardWrite", "sidePanel"],
"content_scripts": [{
"run_at": "document_start",
"matches": ["http://*/*", "https://*/*", ...],
"js": ["js/content-script.js"]
}]
no "notifications" permission adding one re-prompts every existing user
no chrome.action badge logic the toolbar badge is unused today
# fails the chrome build when any ad-network hostname survives into the
# compiled artifact — review evaluates the bundle, dead code counts
hits=$(grep -rEoh 'doubleclick|googlesyndication|adsbygoogle|adservice|googletagservices' dist/chrome | sort -u)
if [ -n "$hits" ]
then
echo "ad-network hostnames in the chrome bundle:"
echo "$hits"
exit 1
fi
.rules { width: 100%; border-collapse: collapse; }
.rules th, .rules td {
padding: 10px 14px;
border-bottom: 1px solid var(--c-border-primary);
font-family: var(--ff-inter);
font-size: 1.3rem;
line-height: 1.5;
text-align: left;
vertical-align: top;
color: var(--c-text-primary);
}
.rules th {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--metal-500);
}
.rules td:first-child { white-space: nowrap; color: var(--c-text-tertiary); font-weight: 700; }
.rules .no { color: light-dark(#8a5a00, #FFCF56); font-weight: 700; }
ui/pages/MyEmoji.vue:31 — computed(async () => …) resolves to a Promise, so !isBeta is permanently false; InlineAd is dead in every runtimeui/App.vue:73 — the same computed done right (getEnv is synchronous). Copy itapps/_shared/config/platform.js — runtime(): 'web' | 'extension' | 'desktop'; mode() returns 'attached' for the extension in every context, so mode() !== 'mini' protects nothingInlineAd + VideoAd · ui/components/Upsell/ — the two slots the launch flip turns on<!-- before -->
<div v-if="!isBeta && (route.name === 'my-emoji' || route.name === 'all-emoji')" class="desktop-only">
<VideoAd v-if="!account.isPro && mode() !== 'mini'" />
</div>
<!-- after — the runtime guard, once, on the outer div -->
<div v-if="runtime() === 'web' && !isBeta && (route.name === 'my-emoji' || route.name === 'all-emoji')" class="desktop-only">
<VideoAd v-if="!account.isPro" />
</div>
// and the import, wherever it isn't already there
import { mode, runtime } from '@shared/config/platform'
// :31 before — computed(async) resolves to a Promise; a Promise is
// truthy, so !isBeta is false forever and InlineAd never renders
const isBeta = computed(async () => {
const beta = await getEnv('IS_BETA')
return beta === 'true'
})
// :31 after — getEnv is synchronous; App.vue:73 already does this
const isBeta = computed(() => getEnv('IS_BETA') === 'true')
<!-- :60-61 after — runtime guard on the wrapper, promo guards on the slot -->
<div v-if="runtime() === 'web'" class="mobile-only">
<InlineAd v-if="!isBeta && !account.isPro" />
</div>
<!-- :90 — InlineMessage renders here behind !account.isPro &&
mode() !== 'mini', while AllEmoji.vue:61 renders the same message
with NO guard. Pick one rule — runtime() === 'web' &&
!account.isPro — and apply it in both files. -->
/* class renames — EasyList ships ##.inline-ad as a global element hide,
so the inline slot is already blank for adblock users no matter what
the guards say. Filters match DOM classes, never .vue filenames —
renaming the files is optional, renaming the classes is not. */
.inline-ad → .house-promo--inline
.video-ad → .video-promo
# build guard, chrome build only — review evaluates the compiled
# artifact, so dead code counts. Full rule set in card D.
hits=$(grep -rEoh 'doubleclick|googlesyndication|adsbygoogle|adservice|googletagservices' dist/chrome | sort -u)
if [ -n "$hits" ]
then
echo "ad-network hostnames in the chrome bundle:"
echo "$hits"
exit 1
fi