The modal a guest sees when an action needs an account. Google does the sign-up.
PopModal · ui/components/pop/PopModal.vue — background-class="upsell-black" already ships (flat metal-950)AccountRequiredModal · ui/modals/AccountRequiredModal.vue — the chassis; this replaces its beta contentIconButton · ui/buttons/IconButton.vue — the white ✕, variant:white size:mdText · ui/components/Text.vue — every string in the modal; tag=h6 title, body/l-reg line, body/m-reg list + foot, body/s-reg legal; colors on the color prop (white / metal-300 / metal-400)GoogleButton · new — PrimaryButton geometry, white fill, official multicolor G; label "Create a free account".__inner column · one even gap: sp-6 between the four groups — copy, unlock list, button, feet.__copy column · gap: sp-2 — the title + line pair, subgrouped so the outer gap stays even.__list column · gap: sp-2, left-aligned inside the centered column — the unlocks read as a list, not prose (R10).__feet column · gap: sp-2 — foot links over the legal line, one quiet subgroup<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import IconButton from '@/buttons/IconButton.vue'
import GoogleButton from '@/buttons/GoogleButton.vue' // new — white pill + official G
import { login } from '@/utils/oauth/auth.js'
import { getEnv } from '@shared/config/env'
const props = defineProps(['popped', 'trigger', 'width']);
const emit = defineEmits(['unpop', 'update:popped']);
const close = () => {
emit('update:popped', false)
emit('unpop')
}
/* one line per gated action; 'generic' is the fallback */
const triggerLines = {
'generic': "Here's everything you unlock by signing up for free.",
'use-favorites': 'Favorites save to your free account.',
'save-combo': 'Save combos with a free account.',
'hide-action-bar': 'Hide the action bar with a free account.',
'sort-collections': 'Sort collections with a free account.',
'follow-collection': 'Follow collections with a free account.',
'remove-items': 'Clean up recents with a free account.',
}
/* the unlock list — Free-plan facts only, identical for every trigger */
const freeUnlocks = [
'Save your favorites',
'Save combos',
'Follow and sort collections',
'Same setup, every device',
]
</script>
<template>
<PopModal
:width="props.width || '480px'"
:popped="props.popped"
@unpop="close"
:background-class="'upsell-black'"
v-bind="$attrs"
>
<div class="create-account">
<IconButton icon="x" size="md" variant="white" class="__close" @click="close" />
<div class="__inner">
<div class="__copy">
<!-- h6, not h5 — the title stays one line at 480px -->
<Text tag="h6" color="white" text="Free account required" />
<Text type="body/l-reg" color="metal-300"
:text="triggerLines[props.trigger] || triggerLines.generic" />
</div>
<ul class="__list">
<li v-for="line in freeUnlocks" :key="line" class="__item">
<Icon name="check" /> <!-- ui/icons check; inline the Phosphor path if the set lacks it -->
<Text type="body/m-reg" color="white" :text="line" />
</li>
</ul>
<GoogleButton label="Create a free account" @click="login('join', { provider: 'google' })" />
<div class="__feet">
<Text type="body/m-reg" color="metal-400" class="__foot">
<a @click="login('join')">Sign up with email</a> ·
Have an account? <a @click="login()">Log in</a>
</Text>
<Text type="body/s-reg" color="metal-400" class="__legal">
By signing in with Google you agree to our
<a :href="getEnv('PRIVACY_URL')" target="_blank">Privacy Policy</a>.
</Text>
</div>
</div>
</div>
</PopModal>
</template>
<!-- fired from any account-gated action while signed out --> <AccountRequiredModal v-model:popped="showAccountModal" trigger="generic" /> // per-action trigger values + their lines: card B's map
.create-account {
position: relative;
width: 100%;
padding: 56px 24px 28px;
text-align: center;
}
.create-account .__close {
position: absolute;
top: 12px;
right: 12px;
}
/* spacing = gaps only: ONE even gap above and below the button; the
title + line pair is a subgroup with its own tighter gap. No margins
anywhere — Text renders spans, nothing to reset. */
.create-account .__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-6);
max-width: 400px;
margin: 0 auto; /* centering, not spacing */
}
.create-account .__copy {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
}
/* the unlock list — left-aligned inside the centered column */
.create-account .__list {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--sp-2);
margin: 0;
padding: 0;
list-style: none;
text-align: left;
}
.create-account .__item { display: flex; align-items: center; gap: 10px; }
/* fixed primitive on a fixed-dark surface — a theme-aware icon token
would flip with the page scheme */
.create-account .__item .icon { width: 16px; height: 16px; flex: none; color: var(--green-400); }
/* foot links + the legal line, one quiet subgroup */
.create-account .__feet {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
}
/* scoped one-off: link inks inside the foot/legal Text slots — no
system utility for inline links on a fixed-dark surface */
.create-account .__foot a,
.create-account .__legal a {
color: var(--c-white);
text-decoration: underline;
cursor: pointer;
}
.create-account .__foot a:hover,
.create-account .__legal a:hover { color: var(--green-400); }
/* 🟡 GoogleButton — ui/buttons/GoogleButton.vue (extends Button like
PrimaryButton; the G is an inline SVG asset, not an Icon name) */
.google-button {
gap: 12px;
width: 100%;
max-width: 320px;
height: max(3.38em, 4.6rem);
padding: 0 max(1.77em, .8rem);
border-radius: 10rem;
background: var(--white);
color: var(--metal-950);
transition: background .2s ease;
}
.google-button:hover { background: var(--metal-100); }
.google-button svg { width: 20px; height: 20px; flex: none; }
/** c-mapped primitives that are used in the designs */ --c-metal-50: var(--metal-50); --c-white: var(--white); --c-green-800: var(--green-800); --c-metal-300: var(--metal-300); /* new — the line under the title */ --c-metal-400: var(--metal-400); /* new — the foot */
props.trigger changes<!-- fired from the Add to Favorites row while signed out --> <AccountRequiredModal v-model:popped="showAccountModal" trigger="use-favorites" /> <!-- the full trigger → line map --> generic Here's everything you unlock by signing up for free. use-favorites Favorites save to your free account. save-combo Save combos with a free account. hide-action-bar Hide the action bar with a free account. sort-collections Sort collections with a free account. follow-collection Follow collections with a free account. remove-items Clean up recents with a free account.
.create-account {
position: relative;
width: 100%;
padding: 56px 24px 28px;
text-align: center;
}
.create-account .__close {
position: absolute;
top: 12px;
right: 12px;
}
/* spacing = gaps only: ONE even gap above and below the button; the
title + line pair is a subgroup with its own tighter gap. No margins
anywhere — Text renders spans, nothing to reset. */
.create-account .__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-6);
max-width: 400px;
margin: 0 auto; /* centering, not spacing */
}
.create-account .__copy {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
}
/* the unlock list — left-aligned inside the centered column */
.create-account .__list {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--sp-2);
margin: 0;
padding: 0;
list-style: none;
text-align: left;
}
.create-account .__item { display: flex; align-items: center; gap: 10px; }
/* fixed primitive on a fixed-dark surface — a theme-aware icon token
would flip with the page scheme */
.create-account .__item .icon { width: 16px; height: 16px; flex: none; color: var(--green-400); }
/* foot links + the legal line, one quiet subgroup */
.create-account .__feet {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
}
/* scoped one-off: link inks inside the foot/legal Text slots — no
system utility for inline links on a fixed-dark surface */
.create-account .__foot a,
.create-account .__legal a {
color: var(--c-white);
text-decoration: underline;
cursor: pointer;
}
.create-account .__foot a:hover,
.create-account .__legal a:hover { color: var(--green-400); }
.google-button {
gap: 12px;
width: 100%;
max-width: 320px;
height: max(3.38em, 4.6rem);
padding: 0 max(1.77em, .8rem);
border-radius: 10rem;
background: var(--white);
color: var(--metal-950);
transition: background .2s ease;
}
.google-button:hover { background: var(--metal-100); }
.google-button svg { width: 20px; height: 20px; flex: none; }
Three other ways to ask, each one a pattern from the comps below. What changes is the title, the surface, or the moment.
AccountRequiredModal · ui/modals/AccountRequiredModal.vue — card A's chassis, unchangedPopModal background-class="upsell-black", IconButton, Text, GoogleButton — all as card AtriggerCopy · new — title + line per gated action, replacing card A's line-only map.__inner column · one even gap: sp-6, now THREE groups — copy, button, feet (the list is gone)<script setup>
/* AccountRequiredModal.vue — card A's chassis, one change: the trigger
drives the TITLE as well as the line, and the unlock list comes out. */
import PopModal from '@/components/pop/PopModal.vue'
import IconButton from '@/buttons/IconButton.vue'
import GoogleButton from '@/buttons/GoogleButton.vue' // new in card A
import { computed } from 'vue'
import { login } from '@/utils/oauth/auth.js'
import { getEnv } from '@shared/config/env'
const props = defineProps(['popped', 'trigger', 'width']);
const emit = defineEmits(['unpop', 'update:popped']);
const close = () => {
emit('update:popped', false)
emit('unpop')
}
/* title + line per gated action; 'generic' is the fallback */
const triggerCopy = {
'generic': { title: 'Create a free account', line: 'Save your favorites, combos, and collections.' },
'use-favorites': { title: 'Save this emoji', line: 'Favorites live in your free account, on every device.' },
'save-combo': { title: 'Save this combo', line: 'Combos live in your free account, on every device.' },
'follow-collection': { title: 'Follow this collection', line: 'Followed collections live in your free account.' },
'sort-collections': { title: 'Sort your collections', line: 'Your order is saved to your free account.' },
'hide-action-bar': { title: 'Hide the action bar', line: 'Your layout is saved to your free account.' },
'remove-items': { title: 'Clean up your recents', line: 'Recents are saved to your free account.' },
}
const copy = computed(() => triggerCopy[props.trigger] || triggerCopy.generic)
</script>
<template>
<PopModal
:width="props.width || '480px'"
:popped="props.popped"
@unpop="close"
:background-class="'upsell-black'"
v-bind="$attrs"
>
<div class="create-account">
<IconButton icon="x" size="md" variant="white" class="__close" @click="close" />
<div class="__inner">
<div class="__copy">
<Text tag="h6" color="white" :text="copy.title" />
<Text type="body/l-reg" color="metal-300" :text="copy.line" />
</div>
<GoogleButton label="Create a free account" @click="login('join', { provider: 'google' })" />
<div class="__feet">
<Text type="body/m-reg" color="metal-400" class="__foot">
<a @click="login('join')">Sign up with email</a> ·
Have an account? <a @click="login()">Log in</a>
</Text>
<Text type="body/s-reg" color="metal-400" class="__legal">
By signing in with Google you agree to our
<a :href="getEnv('PRIVACY_URL')" target="_blank">Privacy Policy</a>.
</Text>
</div>
</div>
</div>
</PopModal>
</template>
<!-- unchanged from card A: one mount, one prop per gated action --> <AccountRequiredModal v-model:popped="showAccountModal" trigger="use-favorites" /> <!-- the full trigger → title / line map --> generic Create a free account / Save your favorites, combos, and collections. use-favorites Save this emoji / Favorites live in your free account, on every device. save-combo Save this combo / Combos live in your free account, on every device. follow-collection Follow this collection / Followed collections live in your free account. sort-collections Sort your collections / Your order is saved to your free account. hide-action-bar Hide the action bar / Your layout is saved to your free account. remove-items Clean up your recents / Recents are saved to your free account.
/* Card A's CSS, unchanged, minus two rules: with the list gone the
.__list / .__item blocks are dead and should not ship with this
direction. The column now holds three groups, not four, and the one
even gap does the rest. */
.create-account .__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-6); /* copy · button · feet */
max-width: 400px;
margin: 0 auto;
}
.create-account .__copy {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
}
/* deleted with this direction:
.create-account .__list { ... }
.create-account .__item { ... } */
PopMenu · ui/components/pop/PopMenu.vue — click-opened, teleported, placement="bottom-start", closes on click.outside, keydown.escapePopTip — PopTip.vue pops on mouseenter and bails on touch devices (isTouchDevice), so a tap never opens itAccountTip · new — the card content: title, line, GoogleButton, log-in linkGoogleButton (new in card A) at size:sm · Text for every string.account-tip column · gap: sp-3 · fixed 300px, hung 8px under the control<script setup>
/* AccountTip.vue — the same ask at control scale. PopMenu, not
PopModal: it hangs off the control the guest just tapped, covers
nothing, and the page stays live behind it. */
import PopMenu from '@/components/pop/PopMenu.vue'
import GoogleButton from '@/buttons/GoogleButton.vue' // new in card A
import { computed } from 'vue'
import { login } from '@/utils/oauth/auth.js'
const props = defineProps({
trigger: { type: String, default: 'generic' },
})
const tipCopy = {
'generic': { title: 'Create a free account', line: 'A free account saves your setup.' },
'use-favorites': { title: 'Save this emoji', line: 'Favorites need a free account.' },
'save-combo': { title: 'Save this combo', line: 'Combos need a free account.' },
'follow-collection': { title: 'Follow this collection', line: 'Following needs a free account.' },
}
const copy = computed(() => tipCopy[props.trigger] || tipCopy.generic)
</script>
<template>
<PopMenu placement="bottom-start" :offset="[0, 8]" width="300px" open-on="click">
<!-- the control itself: the heart, the follow button, the combo save -->
<slot />
<template #pop>
<div class="account-tip">
<div class="__copy">
<Text type="body/l-med" color="white" :text="copy.title" />
<Text type="body/s-reg" color="metal-300" :text="copy.line" />
</div>
<GoogleButton label="Create a free account" size="sm" @click="login('join', { provider: 'google' })" />
<Text type="body/s-reg" color="metal-400" class="__foot">
Have an account? <a @click="login()">Log in</a>
</Text>
</div>
</template>
</PopMenu>
</template>
<!-- guests only: wrap the control, and the tip replaces the modal.
PopMenu opens itself on the click, so there is no state to hold. -->
<AccountTip v-if="!user.isSignedIn" trigger="use-favorites">
<FavoriteButton :emoji="emoji" />
</AccountTip>
<FavoriteButton v-else :emoji="emoji" />
<!-- same wrapper on the other light gates -->
<AccountTip trigger="follow-collection"><GroupSidebarButton ... /></AccountTip>
<AccountTip trigger="save-combo"><ActionBarButton icon="bookmark" ... /></AccountTip>
/* scoped to AccountTip.vue — the surface is fixed dark on a light app,
so the inks are primitives, not theme-aware tokens */
.account-tip {
display: flex;
flex-direction: column;
gap: var(--sp-3); /* copy · button · foot */
width: 300px;
padding: 16px;
border-radius: var(--r-md);
background: var(--metal-950);
box-shadow: 0 16px 40px rgba(15, 16, 18, .35);
text-align: left;
}
.account-tip .__copy {
display: flex;
flex-direction: column;
gap: 4px;
}
/* GoogleButton at control scale: same component, one size down */
.account-tip .google-button {
max-width: none;
height: 4rem;
font-size: 1.35rem;
}
.account-tip .__foot a {
color: var(--white);
text-decoration: underline;
cursor: pointer;
}
.account-tip .__foot a:hover { color: var(--green-400); }
AccountCornerCard · new — NOT a modal: no scrim, no focus trap, the app stays usable behind itIconButton variant:white size:sm for the X · GoogleButton (new in card A) · Text for every stringApp.vue for guests only · the VideoAd.vue stub is bottom-LEFT, this is bottom-RIGHT, and they must never show together.account-corner column · gap: sp-3 · fixed 320px, 16px off the bottom-right corner<script setup>
/* AccountCornerCard.vue — the quiet ask. Not a modal: no scrim, no
focus trap, nothing blocked. It fades in after the guest has really
used the app, and it can be sent away. */
import IconButton from '@/buttons/IconButton.vue'
import GoogleButton from '@/buttons/GoogleButton.vue' // new in card A
import { login } from '@/utils/oauth/auth.js'
import { getEnv } from '@shared/config/env'
const props = defineProps({ shown: { type: Boolean, default: false } })
const emit = defineEmits(['dismiss'])
</script>
<template>
<Transition name="corner-card">
<aside v-if="props.shown" class="account-corner" aria-label="Create a free account">
<IconButton icon="x" size="sm" variant="white" class="__close" @click="emit('dismiss')" />
<div class="__copy">
<Text type="body/l-med" color="white" text="Save your setup" />
<Text type="body/s-reg" color="metal-300"
text="Favorites, combos, and collections live in a free account, on every device." />
</div>
<GoogleButton label="Create a free account" size="sm" @click="login('join', { provider: 'google' })" />
<div class="__feet">
<Text type="body/s-reg" color="metal-400" class="__foot">
Have an account? <a @click="login()">Log in</a>
</Text>
<Text type="body/s-reg" color="metal-400" class="__legal">
By signing in with Google you agree to our
<a :href="getEnv('PRIVACY_URL')" target="_blank">Privacy Policy</a>.
</Text>
</div>
</aside>
</Transition>
</template>
<!-- App.vue, guests only, mounted once beside the modal -->
<AccountCornerCard :shown="showAccountCard" @dismiss="dismissAccountCard" />
/* WHEN it fires is the open decision — the threshold below is a
placeholder so the surface can be reviewed, not a recommendation. */
const showAccountCard = computed(() =>
!user.isSignedIn && !cardDismissed.value && guestActivity.copies >= 10
)
const dismissAccountCard = () => {
cardDismissed.value = true
localStorage.setItem('ec:account-card-dismissed', Date.now())
}
/* scoped to AccountCornerCard.vue — fixed dark card, fixed inks */
.account-corner {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 40; /* over the app, under any modal */
display: flex;
flex-direction: column;
gap: var(--sp-3); /* copy · button · feet */
width: 320px;
padding: 20px 18px 16px;
border-radius: var(--r-md);
background: var(--metal-950);
box-shadow: 0 18px 46px rgba(15, 16, 18, .40);
text-align: left;
}
.account-corner .__close { position: absolute; top: 8px; right: 8px; }
.account-corner .__copy { display: flex; flex-direction: column; gap: 4px; padding-right: 24px; }
.account-corner .__feet { display: flex; flex-direction: column; gap: 6px; }
.account-corner .google-button { max-width: none; height: 4rem; font-size: 1.35rem; }
.account-corner .__foot a,
.account-corner .__legal a { color: var(--white); text-decoration: underline; cursor: pointer; }
.account-corner .__foot a:hover,
.account-corner .__legal a:hover { color: var(--green-400); }
/* fade up, and it must never animate on every route change */
.corner-card-enter-active { transition: opacity .2s ease, transform .3s cubic-bezier(.175,.885,.32,1.275); }
.corner-card-leave-active { transition: opacity .15s ease, transform .2s ease-in; }
.corner-card-enter-from,
.corner-card-leave-to { opacity: 0; transform: translateY(12px); }
Account-required prompts from other apps, redrawn to scale. Reference only, nothing here ships.
Log in or sign up to get smarter responses, upload files and images, and more.
Sign in to save conversations, search past answers, and pick up where you left off.
Add images on the go without breaking your flow.
Already have an account? Log in
Save instagram's post so it's easy to find later.
Log inSign in to make your opinion count.