Two tooltip-scale answers for the moment a Free user taps Follow with all 5 collections already followed. Awaiting pick: A or B.
<SmallButton> · <PopTip>/<TipMessage> · <PopToast> — rest state is zero new components<!-- ListRow.vue — the .status cell. Two tiny changes:
1 · at-cap no longer disables the button for signed-in users
(guests keep disabled + "Login to Manage"; Pro never sees this)
2 · the at-cap tip says FULL at a glance (the picked treatment)
The tap-at-cap wiring belongs to the picked answer surface — its
PopMenu wraps this button (parent usage in that card's drawer). -->
<div class="status">
<PopTip :offset="[0, 8]">
<SmallButton
size="default"
:variant="props.following ? 'green' : 'gray'"
:icon-start="props.following ? 'check' : 'plus'"
:disabled="props.disabled || props.pending"
:label="props.following ? 'Following' : 'Follow'"
@click="emit('toggle-follow', props.collection)"
/>
<template #pop>
<!-- T1 · words lead — a pure string swap on today's line -->
<TipMessage>
<span>{{ props.disabled ? 'Login to Manage' : (props.following ? 'Unfollow' : (!props.canFollow ? 'Collections full — 5 of 5' : 'Follow')) }}</span>
</TipMessage>
</template>
</PopTip>
</div>
<!-- T2 · filled slots — the at-cap tip becomes markup, so the ternary
splits into one span per branch (same conditions): -->
<TipMessage>
<span v-if="props.disabled">Login to Manage</span>
<span v-else-if="props.following">Unfollow</span>
<span v-else-if="!props.canFollow" class="tip-full"><span class="tip-blocks" aria-hidden="true"><span class="tip-block" /><span class="tip-block" /><span class="tip-block" /><span class="tip-block" /><span class="tip-block" /></span>Collections full</span>
<span v-else>Follow</span>
</TipMessage>
<!-- T3 · count chip — same split; only the at-cap span differs: -->
<span v-else-if="!props.canFollow" class="tip-full">Collections full<span class="tip-chip">5/5</span></span>
<style scoped>
/* FULL tip add-ons — scoped in ListRow.vue, so they reach the
TipMessage slot content it owns. T1 needs none of this. */
.tip-full {
display: flex;
align-items: center;
}
/* T2 · filled slots */
.tip-blocks {
display: flex;
gap: 3px;
margin-right: 8px;
}
.tip-block {
width: 8px;
height: 8px;
border-radius: 2px;
background: rgba(255, 255, 255, .38);
flex: none;
}
/* T3 · count chip */
.tip-chip {
margin-left: 8px;
padding: 2px 7px;
border-radius: 10rem;
background: rgba(255, 255, 255, .14);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .12);
font-size: 1rem;
font-weight: 700;
letter-spacing: .02em;
font-variant-numeric: tabular-nums;
}
</style>
Cat Lovers wants in — your five are full.
Pick who sits out.
<FollowLimitTip> — new small surface on the tooltip skin (PopMenu's click-popper + TipMessage's arrow)<PopMenu> positioning · <TipMessage> surface · <SmallButton> · list store icons via useEmojiService.getEmojiCharacters<script setup>
import { ref } from 'vue'
import SmallButton from '@/buttons/SmallButton.vue'
import { useEmojiService } from '@/utils/services/emoji'
const props = defineProps({
collection: { // the tapped collection — it "wants in"
type: Object,
required: true,
},
followed: { // the user's five followed collections, in order
type: Array,
required: true,
},
})
const emit = defineEmits(['swap', 'go-pro'])
// 'answer' | 'swap' — in-place morph: same width, height animates ~200ms
const view = ref('answer')
const icon = (c) => useEmojiService.getEmojiCharacters(c.icon || '1f600').join('')
</script>
<template>
<div class="wants-tip">
<template v-if="view === 'answer'">
<div class="wants-tip__tiles" aria-hidden="true">
<span v-for="c in props.followed" :key="c.id" class="wants-tip__tile jp-font">{{ icon(c) }}</span>
<span class="wants-tip__tile wants-tip__tile--wanted jp-font">{{ icon(props.collection) }}</span>
</div>
<p class="wants-tip__line"><b>{{ props.collection.title }}</b> wants in — your five are full.</p>
<div class="wants-tip__actions">
<SmallButton size="sm" variant="gray" class="wants-tip__ghost" label="Swap one out" @click="view = 'swap'" />
<SmallButton size="sm" variant="green" label="Go unlimited" @click="emit('go-pro')" />
</div>
</template>
<template v-else>
<div class="wants-tip__head">
<button class="wants-tip__back" aria-label="Back" @click="view = 'answer'">‹</button>
Swap one out
</div>
<p class="wants-tip__prompt">Pick who sits out.</p>
<ul class="wants-tip__rows">
<li v-for="c in props.followed" :key="c.id" class="wants-tip__row" @click="emit('swap', c)">
<span class="jp-font">{{ icon(c) }}</span>
<span class="wants-tip__row-title">{{ c.title }}</span>
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M213.66,181.66l-32,32a8,8,0,0,1-11.32-11.32L188.69,184H48a8,8,0,0,1,0-16H188.69l-18.35-18.34a8,8,0,0,1,11.32-11.32l32,32A8,8,0,0,1,213.66,181.66Zm-139.32-64a8,8,0,0,0,11.32-11.32L67.31,88H208a8,8,0,0,0,0-16H67.31L85.66,53.66A8,8,0,0,0,74.34,42.34l-32,32a8,8,0,0,0,0,11.32Z"/></svg>
</li>
</ul>
</template>
<span class="wants-tip__arrow"><svg viewBox="0 0 100 50" xmlns="http://www.w3.org/2000/svg"><path d="M0 50 C65 -10, 35 -10, 100 50 Z"/></svg></span>
</div>
</template>
<!-- Parent usage (ListRow.vue .status cell) — PopMenu's click-popper wraps
the Follow button when the tap needs the answer (signed-in Free at cap);
every other state keeps today's PopTip branch untouched. ListRow reads
the lists store for the five followed — same store Explore.vue reads. -->
<PopMenu v-if="!props.disabled && !props.following && !props.canFollow"
placement="top-end" :offset="[0, 10]">
<SmallButton
size="default"
variant="gray"
icon-start="plus"
label="Follow"
:disabled="props.pending"
/>
<template #pop="{ unpop }">
<FollowLimitTip
:collection="props.collection"
:followed="lists.followed"
@swap="(out) => { emit('swap-follow', { out, wanted: props.collection }); unpop() }"
@go-pro="() => { emit('go-pro', props.collection); unpop() }"
/>
</template>
</PopMenu>
<style scoped>
/* FollowLimitTip — the tooltip skin grown up. Values verbatim from
the mock. Placement comes from PopMenu's popper (top-end, offset
[0,10]) — the mock's absolute anchoring is dropped; relative here
just anchors the arrow. */
.wants-tip {
position: relative;
width: 256px;
padding: 12px;
border-radius: var(--r-md);
background: var(--c-surface-tooltip-2);
box-shadow: var(--shadow-popover), 0 12px 32px rgba(0, 0, 0, .18);
display: flex;
flex-direction: column;
gap: 10px;
}
.wants-tip__arrow {
position: absolute;
top: 100%;
right: 40px;
width: 1.7rem;
height: 1rem;
overflow: hidden;
}
.wants-tip__arrow svg { width: 100%; height: auto; transform: rotate(180deg); }
.wants-tip__arrow path { fill: var(--c-surface-tooltip-2); }
/* flipped placement (popper auto-flip near the viewport top) — apply
.wants-tip--below when the popper lands under the button; only the
arrow swaps sides (the vertical anchoring is popper's job) */
.wants-tip--below .wants-tip__arrow {
top: auto;
bottom: 100%;
align-items: end;
}
.wants-tip--below .wants-tip__arrow svg { transform: rotate(0deg); }
.wants-tip__tiles {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.wants-tip__tile {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex: none;
border-radius: var(--r-xs);
background: rgba(255, 255, 255, .08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .12);
font-size: 15px;
line-height: 1;
}
.wants-tip__tile--wanted {
background: color-mix(in srgb, var(--green-600) 10%, transparent);
box-shadow: none;
border: 1.5px dashed color-mix(in srgb, var(--green-600) 55%, transparent);
rotate: -6deg;
transition: rotate .2s;
animation: wanted-float 3.2s ease-in-out infinite;
}
.wants-tip:hover .wants-tip__tile--wanted { rotate: 0deg; }
@keyframes wanted-float {
0%, 100% { translate: 0 0; }
50% { translate: 0 -3px; }
}
@media (prefers-reduced-motion: reduce) {
.wants-tip__tile--wanted { animation: none; }
}
.wants-tip__line {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.3rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 72%, transparent);
b { color: var(--white); font-weight: 700; }
}
.wants-tip__actions {
display: flex;
gap: 8px;
}
.wants-tip__actions .button { flex: 1; justify-content: center; }
/* ghost-on-dark secondary — the only non-stock button skin on this surface */
.wants-tip__ghost {
background: rgba(255, 255, 255, .10) !important;
}
.wants-tip__ghost label { color: var(--white) !important; }
.wants-tip__ghost:hover { background: rgba(255, 255, 255, .16) !important; }
/* swap state (in-place morph — same width, height animates ~200ms) */
.wants-tip__head {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.2rem;
color: var(--white);
}
.wants-tip__back {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border: none;
background: none;
color: rgba(255, 255, 255, .55);
font-size: 1.4rem;
cursor: pointer;
padding: 0;
}
.wants-tip__prompt {
margin: -6px 0 0;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.1rem;
color: rgba(255, 255, 255, .55);
}
.wants-tip__rows {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.wants-tip__row {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 6px;
border-radius: var(--r-xs);
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.2rem;
color: rgba(255, 255, 255, .9);
cursor: pointer;
}
.wants-tip__row:hover { background: rgba(255, 255, 255, .08); }
.wants-tip__row .jp-font { font-size: 18px; line-height: 1; flex: none; }
.wants-tip__row-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wants-tip__row svg { flex: none; opacity: .55; }
.wants-tip__row:hover svg { opacity: 1; color: var(--green-500); }
</style>
<FollowLimitMenu> — header line + 2 rows + inline accordion (one small surface component) · pill--soft — workspace pill skin, not in ui/ yet (CSS below)<PopMenu> carries ALL positioning/teleport/close/transition · <MenuSurface> tokens · <ToastMessage><script setup>
import { ref } from 'vue'
import { useEmojiService } from '@/utils/services/emoji'
const props = defineProps({
collection: { // the tapped collection — it "wants in"
type: Object,
required: true,
},
followed: { // the user's five followed collections, in order
type: Array,
required: true,
},
})
const emit = defineEmits(['swap', 'go-pro'])
// root state = header + two rows (~130px); the swap list expands inline
const swapOpen = ref(false)
const icon = (c) => useEmojiService.getEmojiCharacters(c.icon || '1f600').join('')
</script>
<template>
<div class="lineup-menu">
<div class="lineup-menu__header"><b>{{ props.collection.title }}</b> wants in — you're at 5.</div>
<div class="lineup-menu__divider"></div>
<button class="lineup-menu__row" @click="emit('go-pro')">
<svg class="lineup-menu__icon--green" width="16" height="16" viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17Z"/></svg>
<span class="lineup-menu__row-label">Go unlimited</span>
<span class="pill pill--soft" style="font-size:1rem; padding:2px 8px;">PRO</span>
</button>
<button class="lineup-menu__row" :aria-expanded="swapOpen" @click="swapOpen = !swapOpen">
<svg class="lineup-menu__icon--gray" width="16" height="16" viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M213.66,181.66l-32,32a8,8,0,0,1-11.32-11.32L188.69,184H48a8,8,0,0,1,0-16H188.69l-18.35-18.34a8,8,0,0,1,11.32-11.32l32,32A8,8,0,0,1,213.66,181.66Zm-139.32-64a8,8,0,0,0,11.32-11.32L67.31,88H208a8,8,0,0,0,0-16H67.31L85.66,53.66A8,8,0,0,0,74.34,42.34l-32,32a8,8,0,0,0,0,11.32Z"/></svg>
<span class="lineup-menu__row-label">Swap one out</span>
<svg class="lineup-menu__caret" :class="{ 'lineup-menu__caret--open': swapOpen }" width="14" height="14" viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></svg>
</button>
<div class="lineup-menu__swap" :class="{ 'is-open': swapOpen }">
<div class="lineup-menu__swap-clip">
<div class="lineup-menu__prompt">Pick who sits out.</div>
<button v-for="c in props.followed" :key="c.id" class="lineup-menu__swap-row" @click="emit('swap', c)">
<span class="jp-font">{{ icon(c) }}</span>
<span class="lineup-menu__swap-title">{{ c.title }}</span>
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M213.66,181.66l-32,32a8,8,0,0,1-11.32-11.32L188.69,184H48a8,8,0,0,1,0-16H188.69l-18.35-18.34a8,8,0,0,1,11.32-11.32l32,32A8,8,0,0,1,213.66,181.66Zm-139.32-64a8,8,0,0,0,11.32-11.32L67.31,88H208a8,8,0,0,0,0-16H67.31L85.66,53.66A8,8,0,0,0,74.34,42.34l-32,32a8,8,0,0,0,0,11.32Z"/></svg>
</button>
</div>
</div>
</div>
</template>
<!-- Parent usage (ListRow.vue .status cell) — PopMenu wraps the Follow
button when the tap needs the answer (signed-in Free at cap); every
other state keeps today's PopTip branch untouched. ListRow reads the
lists store for the five followed — same store Explore.vue reads. -->
<PopMenu v-if="!props.disabled && !props.following && !props.canFollow"
placement="bottom-end" :offset="[0, 6]">
<SmallButton
size="default"
variant="gray"
icon-start="plus"
label="Follow"
:disabled="props.pending"
/>
<template #pop="{ unpop }">
<FollowLimitMenu
:collection="props.collection"
:followed="lists.followed"
@swap="(out) => { emit('swap-follow', { out, wanted: props.collection }); unpop() }"
@go-pro="() => { emit('go-pro', props.collection); unpop() }"
/>
</template>
</PopMenu>
<style scoped>
/* FollowLimitMenu — MenuSurface tokens, values verbatim from the mock.
Placement/teleport/layering come from PopMenu (bottom-end, offset
[0,6]) — the mock's absolute anchoring is dropped. */
.lineup-menu {
width: 264px;
padding: var(--sp-2);
border-radius: var(--r-md);
border: 1px solid var(--c-border-primary);
background: var(--c-surface-dropdown);
box-shadow: var(--shadow-popover), 0 12px 32px rgba(0, 0, 0, .10);
}
.lineup-menu__header {
padding: 6px 10px 8px;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.2rem;
color: var(--c-text-tertiary);
b { font-weight: 700; color: var(--c-text-primary); }
}
.lineup-menu__divider {
height: 1px;
margin: 0 4px 4px;
background: var(--c-border-primary);
}
.lineup-menu__row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 0 10px;
border: none;
border-radius: var(--r-sm);
background: none;
cursor: pointer;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.3rem;
color: var(--c-text-primary);
text-align: left;
}
.lineup-menu__row:hover { background: var(--c-surface-dropdown-hover); }
.lineup-menu__row-label { flex: 1; min-width: 0; }
.lineup-menu__row svg { flex: none; }
.lineup-menu__icon--green { color: var(--c-icon-green); }
.lineup-menu__icon--gray { color: var(--c-icon-dark-gray); }
.lineup-menu__caret { color: var(--c-icon-gray, var(--metal-500)); }
.lineup-menu__caret--open { rotate: 180deg; }
/* inline accordion — grid-rows 0fr→1fr per the behavior spec (200ms;
reduced-motion snaps). The mock renders the open state statically. */
.lineup-menu__swap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms;
}
.lineup-menu__swap.is-open { grid-template-rows: 1fr; }
.lineup-menu__swap-clip {
overflow: hidden;
min-height: 0;
}
@media (prefers-reduced-motion: reduce) {
.lineup-menu__swap { transition: none; }
}
.lineup-menu__prompt {
padding: 6px 10px 2px;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.2rem;
color: var(--c-text-tertiary);
}
.lineup-menu__swap-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 32px;
padding: 0 10px;
border: none;
border-radius: var(--r-sm);
background: none;
cursor: pointer;
font-family: var(--ff-inter);
font-weight: 500;
font-size: 1.2rem;
color: var(--c-text-primary);
text-align: left;
}
.lineup-menu__swap-row:hover { background: var(--c-surface-dropdown-hover); }
.lineup-menu__swap-row .jp-font { font-size: 16px; line-height: 1; flex: none; }
.lineup-menu__swap-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lineup-menu__swap-row svg { flex: none; color: var(--c-icon-green); opacity: 0; transition: opacity 120ms; }
.lineup-menu__swap-row:hover svg { opacity: 1; }
/* pill--soft — the PRO tag skin (workspace proposal from
assets/components.css — not in ui/ yet, so it rides along here) */
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: var(--r-pill);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.1rem;
letter-spacing: .02em;
text-transform: uppercase;
line-height: 1;
}
.pill--soft { background: var(--c-surface-highlight); color: light-dark(var(--green-900), var(--green-400)); }
</style>