Every reusable piece in the beta mockups, with its Vue invocation (and scoped CSS for new components). Copy a snippet straight into a .vue file.
Load order: tokens.css → app-components.css → components.css. Dark mode via html[data-scheme="dark"].
Typography is driven by tag (h1-h6 = DM Sans Black) or type (Inter body/...). color is a separate modifier.
<component :is="tag"> with class text f:<type> c:<color>/txt. Heading tags (h1-h6) auto-pick up DM Sans Black from tokens.css. Body sizes come from the .f\:body\/... utilities.<Text tag="h4" text="Get more with Pro" />
<Text
type="body/l-heavy"
text="Upgrade to Pro and enjoy more benefits."
/>
<Text
type="body/m-med"
color="tertiary"
text="Smaller, tertiary color"
/>
.vue file (from ui/)<script setup>
const props = defineProps(['tag', 'type', 'color', 'text']);
</script>
<template>
<component
:is="props.tag || 'span'"
class="text"
:class="[
props.type ? 'f:' + props.type : '',
props.color ? 'c:' + props.color + '/txt' : ''
]"
>
<slot>
{{ props.text }}
</slot>
</component>
</template>
Small status pill with a leading colored dot. Used as a "FREE PLAN" / status label in the account header.
.button. Variants use variant:green, variant:menu-back. Sizes size:sm!, size:xs. The leading dot in the marketing mockup is a separate pill class — the app's TextButton currently has label + optional iconStart/iconEnd (not a dot). If we want the dot too, it would be a new variant.<TextButton
variant="green"
label="FREE PLAN"
/>
<!-- If we want the dot, the cleanest add to
TextButton.vue is a `dot` prop:
<TextButton dot="green" label="FREE PLAN" />
-->
.vue file (from ui/)<script setup>
import { ref } from 'vue';
import Button from '@/buttons/Button.vue';
import Icon from '@/components/Icon.vue';
const props = defineProps(['label', 'variant', 'outline', 'disabled', 'active', 'iconStart', 'iconEnd', 'route', 'size']);
const active = ref(false);
const textSizes = {
lg: 'f:body/l-heavy',
md: 'f:body/m-heavy',
};
const defSize = 'lg';
defineExpose({ setActive: (v) => active.value = v });
</script>
<template>
<Button
:class="{
'text-button': true,
['text ' + textSizes[props.size || defSize]]: true,
['variant:' + (props.variant || 'black')]: props.variant,
['size:' + (props.size || defSize)]: true,
'active': active.value || props.active !== undefined,
'outline': props.outline !== undefined,
}"
:disabled="props.disabled"
:route="props.route"
>
<slot :isActive="active">
<slot name="iconStart">
<Icon :name="props.iconStart" v-if="props.iconStart" />
</slot>
<label>{{ props.label }}</label>
<Icon :name="props.iconEnd" v-if="props.iconEnd" />
</slot>
</Button>
</template>
<style>
.text-button {
height: 1.61em;
gap: .44em;
padding: 0;
background: none;
label, .icon {
/* invert clr/bg values to inherit them from default set, while applying to text only */
color: var(--bg);
transition: color .2s ease;
}
.icon {
font-size: max(1.11em, 1.6rem);
}
/* Color Variants, overrides same-name default color variants */
&.variant\:green {
--bg: var(--c-button-text-green);
&:hover {
--bg: var(--c-button-text-green-hover);
}
&:disabled {
--bg: var(--c-button-disabled-text);
}
}
&.variant\:menu-back {
--bg: var(--c-text-tertiary);
gap: 8px;
.icon {
font-size: 2rem;
}
}
&[class*='variant:'] {
&:hover {
color: inherit;
}
}
/* size variants */
&.size\:sm\! {
font-size: 14px;
--fw: 500;
}
/* lock xs to min font size */
&.size\:xs {
font-size: var(--fs-min);
}
}
</style>
Standard CTA. Variants: green (primary), black, gray, ghost. Sizes: sm/md/lg/block.
.button is the base (no padding/radius), and .primary-button / .small-button / .icon-button / .text-button extend it. Variants live on the .button class: variant:black, variant:green, variant:gray, variant:destructive, variant:green-light, variant:transparent. Sizes via size:lg|md|sm|xs. The workspace-only .button--block modifier (full-width) is the only extra in components.css since the app doesn't have a Vue equivalent — everything else uses his canonical class set.
<PrimaryButton variant="green" size="lg" label="Go Pro" />
<SmallButton variant="gray" size="sm" label="Skip" />
<!-- with icon -->
<PrimaryButton
variant="green"
iconStart="check"
label="Got it" />
.vue file (from ui/)<script setup>
const props = defineProps({
label: {
type: String,
default: 'Button',
},
size: {
type: String,
default: 'lg',
},
href: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
// the label can compact/collapse with an ellipsis responsively
labelCollapse: {
type: Boolean,
default: false,
},
id: {
type: String,
default: ''
},
route: {
type: [String, Object],
default: null,
},
});
const component = () => {
if ( props.route ) {
return 'RouterLink';
} else {
return props.href ? 'a' : 'button';
}
};
</script>
<template>
<component
:is="component()"
:id="props.id"
class="button"
:href="props.href || null"
:title="props.title || props.href || null"
:aria-label="props.label || 'Button'"
:class="{disabled: props.href}"
:tabindex="!props.disabled ? null : 0"
:disabled="props.disabled"
:to="props.route"
@click="props.disabled && $event.preventDefault()"
@mousedown.prevent="$event.target.blur()"
>
<slot />
</component>
</template>
<style>
.button {
height: 2rem;
padding: 0 1rem;
background: #9999;
display: flex;
flex-flow: row nowrap;
place-items: center;
justify-content: center;
cursor: pointer;
overflow-x: hidden;
.text, label {
/* width: min-content; */
/* text overflow */
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
/* goal is to maintain vertical center while not clipping descenders, and allowing overflow-x ellipsis to work */
/* height: 100%; */
/* line-height: 100%; */
/* display: flex;
align-items: center; */
/* margin-block: auto; */
/* line-height: 1; */
/* line-height: 1.6em !important; */
/* compensates for overlow-y being hidden, overrides typography settings */
/* hide scroll bar caused by overflow */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
/* states */
&:disabled {
cursor: not-allowed;
}
/* variant variants */
&.variant\:black {
--bg: var(--c-button-black-bg);
--clr: light-dark(var(--white), var(--black));
&:hover {
--bg: var(--c-button-black-hover-bg);
}
&:disabled {
--bg: var(--c-button-disabled-bg);
--clr: var(--c-button-disabled-text);
}
}
&.variant\:destructive {
--bg: var(--destructive-400);
--clr: var(--white);
&:hover {
--bg: var(--destructive-500);
}
&:disabled {
--bg: var(--destructive-400);
--clr: var(--destructive-300);
}
&.outline {
--bg: var(--c-surface-primary);
--clr: var(--c-text-destructive);
border: 1px solid var(--brdr, var(--clr));
&:disabled {
--bg: var(--c-surface-destructive-highlight);
--brdr: variant-mix(in srgb, var(--c-text-destructive) 10%, transparent );
label, .icon {
opacity: .5;
}
}
}
}
&.variant\:gray {
--bg: var(--c-button-gray-bg-2);
--clr: var(--c-text-primary);
&.active,
&:hover {
--bg: var(--c-button-gray-hover-bg-2);
}
&:disabled {
--bg: var(--c-button-disabled-bg);
--clr: var(--c-button-disabled-text);
}
}
&.variant\:green {
--bg: var(--c-button-green-bg);
--clr: var(--metal-950);
&:hover {
--bg: var(--c-button-green-bg-hover);
--clr: var(--white);
}
&:disabled {
--bg: var(--c-button-disabled-bg);
--clr: var(--c-button-disabled-text);
}
}
&.variant\:green-light {
--bg: var(--c-surface-highlight);
--clr: var(--c-button-dark-green);
&:hover {
--bg: var(--c-button-green-bg);
--clr: var(--metal-950);
}
&:disabled {
--bg: var(--c-button-disabled-bg);
--clr: var(--c-button-disabled-text);
}
}
&.variant\:transparent {
--bg: none;
--clr: var(--c-text-primary);
--brdr-clr: var(--c-border-primary);
&:disabled {
--bg: var(--c-button-disabled-bg);
--clr: var(--c-button-disabled-text);
}
&:active,
&.active {
--brdr-clr: transparent;
--bg: var(--c-surface-highlight);
}
&.outline {
border: 1px solid var(--brdr-clr);
}
}
}
</style>
// ─── PrimaryButton.vue ───
<script setup>
import Button from '@/buttons/Button.vue'
import Icon from '@/components/Icon.vue'
const props = defineProps(['label', 'variant', 'outline', 'disabled', 'active', 'iconStart', 'iconEnd', 'route', 'size'])
const textSizes = {
lg: 'f:body/l-heavy',
md: 'f:body/m-heavy',
sm: 'f:body/s-heavy',
xs: 'f:body/s-heavy',
}
const defSize = 'lg'
</script>
<template>
<Button
:class="{
'primary-button': true,
['text ' + textSizes[props.size || defSize]]: true,
['variant:' + props.variant]: props.variant,
['size:' + (props.size || defSize)]: true,
'active': props.active !== undefined,
'outline': props.outline !== undefined,
}"
:disabled="props.disabled"
:route="props.route"
>
<slot>
<Icon :name="props.iconStart" v-if="props.iconStart" weight="bold" />
<label>{{ props.label }}</label>
<Icon :name="props.iconEnd" v-if="props.iconEnd" weight="bold" />
</slot>
</Button>
</template>
<style>
.primary-button {
border-radius: 10rem
height: max(3.38em, 4.6rem)
gap: max(.66em, .8rem)
padding: 0 max(1.77em, .8rem)
background: var(--bg)
label, .icon {
color: var(--clr)
}
.icon {
font-size: max(1.11em, 1.6rem)
}
/* size variants */
/* lock xs to min font size */
&.size\:xs {
font-size: var(--fs-min)
}
}
.env\:mac .primary-button {
height: max(2.66em, 30px)
font-size: 1.2rem
}
</style>
// ─── SmallButton.vue ───
<script setup>
import Button from '@/buttons/Button.vue'
import Icon from '@/components/Icon.vue'
const props = defineProps(['label', 'variant', 'outline', 'disabled', 'active', 'iconStart', 'iconEnd', 'route', 'size'])
const textSizes = {
md: 'f:body/m-heavy',
sm: 'f:body/s-heavy',
}
const defSize = 'md'
</script>
<template>
<Button
:class="{
'small-button': true,
['text ' + textSizes[props.size || defSize]]: true,
['variant:' + props.variant]: props.variant,
['size:' + (props.size || defSize)]: true,
'active': props.active !== undefined,
'outline': props.outline !== undefined,
}"
:disabled="props.disabled"
:route="props.route"
>
<slot>
<Icon :name="props.iconStart" v-if="props.iconStart" />
<label>{{ props.label }}</label>
<Icon :name="props.iconEnd" v-if="props.iconEnd" />
</slot>
</Button>
</template>
<style>
.small-button {
border-radius: 10rem
height: max(3em, 3.3rem)
gap: max(.53em, .4rem)
padding: 0 max(1.3em, 1.4rem)
background: var(--bg)
label, .icon {
color: var(--clr)
}
.icon {
font-size: max(1.6em, 1.6rem)
}
/* size variants */
/* lock sm to min font size */
&.size\:sm {
font-size: var(--fs-min)
padding-inline: 1.4rem
}
}
</style>
Small green status pill with an optional leading check icon. Shipped in the latest build as ui/components/Pill.vue — used for the feedback "Earn $5" badge and similar beta surfaces.
text (string, rendered through <Text type="body/m-heavy">); leading (truthy → shows a fixed green check image, check-thick_green.png, 18×18 — it is not a configurable dot or emoji); and background (only "glass" is special — translucent gray with text-primary text; anything else / omitted → the default green pill with green-800 text). The old beta/beta-dark/soft/neutral/dot variants never shipped — they were a pre-build proposal.<Pill text="Earn $5" leading background="glass" /> <Pill text="Recent combos" />
.vue file (verbatim from ui/components/Pill.vue)<script setup>
const props = defineProps(['leading', 'text', 'background'])
</script>
<template>
<div class="pill" :class="'bg_' + props.background ?? ''">
<img v-if="props.leading" class="__leading"
:src="'https://cdn.joypixels.com/emojicopy/assets/icons/check-thick_green.png'" />
<Text type="body/m-heavy" :text="props.text"
:color="props.background === 'glass' ? 'text-primary' : 'green-800'" />
</div>
</template>
<style scoped>
.pill {
display: flex;
padding: var(--sp-05) var(--sp-3);
align-items: center;
gap: var(--sp-2);
border-radius: 100px;
border: 1px solid var(--green-400);
background: var(--green-300);
&.bg_glass {
border: 1px solid rgba(176, 181, 197, 0.14);
background: rgba(176, 181, 197, 0.12);
}
.__leading {
width: 18px;
height: 18px;
}
}
</style>
Yellow-bordered credit callout row (coin icon + title + subtitle) shown on the FeedbackModal success view. Ships as ui/components/CreditValue.vue.
title, subtitle (array-style defineProps, untyped strings, no defaults) — rendered as <Text type="body/m-heavy"> and <Text type="body/s-med"> beside a fixed 20×20 coins.png CDN image. No emits. Fixed 64px row height (min + max), 12px radius. Caveat: the border is hardcoded #FFF0CA, and the intended #FFF7E3 background is commented out with a note that a theme-aware token is needed — it currently falls back to var(--c-surface-primary).<CreditValue title="$5 Credit Added to Your Account" subtitle="Applies to any Pro purchase" />
.vue file (verbatim from ui/components/CreditValue.vue)<script setup>
const props = defineProps(['title', 'subtitle'])
</script>
<template>
<div class="credit-value">
<div class="__icon">
<img :src="'https://cdn.joypixels.com/emojicopy/assets/marketing/coins.png'" />
</div>
<div class="__text">
<Text type="body/m-heavy">{{ props.title }}</Text>
<Text type="body/s-med">{{ props.subtitle }}</Text>
</div>
</div>
</template>
<style scoped>
.credit-value {
display: flex;
width: 100%;
min-height: 64px;
max-height: 64px;
padding: 8px 16px;
align-items: center;
gap: 12px;
border-radius: 12px;
border: 1px solid #FFF0CA;
/*background: #FFF7E3 we need a theme-aware token for this*/
background: var(--c-surface-primary);
.__icon {
width: 20px;
height: 20px;
flex-shrink: 0;
aspect-ratio: 1/1;
img {
width: 20px;
height: 20px;
}
}
.__text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
flex: 1 0 0;
.text {
align-self: stretch;
}
}
}
</style>
“Copied!” feedback overlay that flashes a green check over an emoji cell (or action-bar button) and pops a tooltip above it. Ships as ui/components/CopyInlay.vue — used by Emoji.vue, Combo.vue and ActionBar.vue.
show (Boolean, default false); variant (String, default 'emoji' — 'action-bar' switches to the dark action-bar hover surface, white icon, pill radius); label (String, default 'Copied!'); offset (Array, default [0, 0], passed to Popper); shape (String, default 'default' — 'inherit' inherits border-radius). No emits: when show turns truthy it shows the overlay plus a body-teleported Popper tip for a fixed 1000ms, then hides itself and destroys the popper. The overlay is pointer-events: none and z-indexed via layer.assign(). Quirk: the Emoji, PopTip and controller imports are currently unused (an <Emoji> render is commented out in the template).<CopyInlay :show="showCopyUi && props.mode === 'copy'" /> <CopyInlay :show="showCopyInlay" variant="action-bar" :label="totalActionBarItems + ' Copied!'" :offset="[0, 12]" />
.vue file (verbatim from ui/components/CopyInlay.vue)<script setup>
import { ref, nextTick, watch, provide } from 'vue';
import Emoji from '@/components/Emoji.vue';
import Icon from '@/components/Icon.vue';
import PopTip from '@/components/pop/PopTip.vue';
import TipMessage from '@/components/pop/templates/TipMessage.vue';
import { layer, controller } from '@/utils/overlayer.js';
import { createPopper } from '@popperjs/core';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'emoji',
},
label: {
type: String,
default: 'Copied!',
},
offset: {
type: Array,
default: () => [0, 0],
},
shape: {
type: String,
default: 'default',
}
});
const isCopying = ref(false);
const $target = ref(null);
let $pop = null;
let $arrow = null;
const zIndex = ref(1);
const arrowPlacement = ref('bottom');
let popper;
provide('arrowPlacement', arrowPlacement);
watch ( () => props.show, (nv) => {
// console.log('CopyInlay show prop changed: ', nv);
if ( nv ) {
showUi();
}
})
const showUi = () => {
zIndex.value = layer.assign();
isCopying.value = true;
nextTick(() => {
$pop = document.querySelector('.copy-inlay-tip' + '_' + zIndex.value);
$arrow = $pop.querySelector('.pop-tip-arrow');
popMessage();
});
setTimeout(() => {
isCopying.value = false;
popper?.destroy();
}, 1000);
}
const popMessage = () => {
popper = createPopper($target.value, $pop, {
placement: 'top',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: props.offset,
}
},
{
name: 'arrow',
options: {
element: $arrow,
// padding: 10,
}
}
],
onFirstUpdate: (state) => {
arrowPlacement.value = 'bottom';
},
});
}
</script>
<template>
<div class="copy-inlay" ref="$target"
:class="{
['variant:'+props.variant]: true,
['shape:'+props.shape]: true}
">
<Transition>
<div class="copy-inlay-ui" v-if="isCopying">
<Icon name="check" weight="bold" />
</div>
</Transition>
<Teleport to="body">
<Transition>
<div class="pop-tip-box"
:class="{['copy-inlay-tip'+'_'+zIndex]: true}" :style="{ '--z': zIndex }" v-if="isCopying">
<TipMessage :variant="props.variant">
<Text tag="span" type="body/m-reg">{{ props.label }}</Text>
</TipMessage>
</div>
</Transition>
</Teleport>
<!-- <Emoji :str="props.str" /> -->
</div>
</template>
<style>
.copy-inlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
border-radius: var(--br);
overflow: hidden;
pointer-events: none;
/* performance */
transform: translateZ(0);
content-visibility: auto;
/* copied UI */
.copy-inlay-ui {
position: absolute;
z-index: 10;
display: flex;
justify-content: center;
width: 100%;
height: 100%;
inset: 0;
border-radius: var(--br, 16px);
background: var(--c-surface-highlight);
--ease: cubic-bezier(0.34, 3, 0.64, 1);
--translate-dur: 0s;
transition: opacity .3s var(--ease), scale .3s var(--ease), transform var(--translate-dur) ease-in;
&.v-enter-from {
scale: .8;
opacity: 0;
}
&.v-leave-active {
--ease: ease-in;
--translate-dur: .2s;
transform: translateY(100%);
}
.icon {
font-size: max(18px, calc(var(--cell-size) / 2));
color: var(--c-button-dark-green);
}
.template-tip-message {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
}
}
&.variant\:action-bar {
.copy-inlay-ui {
background: var(--c-surface-action-bar-hover);
--br: 999px;
--ease: cubic-bezier(0.34, 1, 0.64, 1);
.icon {
color: var(--white);
font-size: 2rem;
}
}
}
/* shape */
&.shape\:inherit {
.copy-inlay-ui {
--br: inherit;
}
}
}
[class*='copy-inlay-tip'] {
width: max-content;
}
</style>
Click-to-record keyboard-shortcut input that displays the current combo as key chips and validates new combos live. Ships as ui/input/KeyCombo.vue — used in AllSettings.vue and the input sandbox.
modelValue (Array, default [{ key: '(unset)', code: '' }]) — an array of { key, code } objects. Emits update:modelValue (v-model). Click toggles edit mode and starts capturing keys: a combo must start with a control key (Control/Meta/Shift), holds max 4 keys, and needs at least one control + one non-control key to commit. Alt aborts the edit (OS-level combo complications), and Escape, Enter, Delete, Backspace, arrows, CapsLock, q, +/-/=/_ are disallowed. Esc or blur cancels back to the previous value. Renders <ShortcutBadge variant="combo"> chips (Meta shows ⌘ on Mac, Win elsewhere; Digit codes are normalized) plus a trailing status icon: pencil (display), spinner (editing), green check (saved, 1s), red warning (invalid, 500ms).<KeyCombo v-model="keyComboValue_1" />
.vue file (verbatim from ui/input/KeyCombo.vue)<script setup>
import { ref, nextTick } from 'vue';
import ShortcutBadge from '@/components/ShortcutBadge.vue';
import IconButton from '@/buttons/IconButton.vue';
import Icon from '@/components/Icon.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => [{ key: '(unset)', code: '' }],
},
});
const emit = defineEmits(['update:modelValue']);
const mode = ref('display'); // 'edit' | 'display'
const combo = ref(props.modelValue);
const isComboSet = ref(false);
const isComboInvalid = ref(false);
const $el = ref(null);
let platform = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? 'mac' : 'win';
const displayMap = {
' ': 'Space',
'Control': 'CTRL',
'Meta': () => {
if ( platform === 'mac' ) {
return '⌘';
} else {
return 'Win';
}
},
'Alt': 'ALT',
'Shift': 'Shift',
};
const disallowedKeys = [
'Escape',
'Enter',
'Delete',
'Backspace',
'+',
'-',
'=',
'_',
'q',
'Alt', // because it causes OS-level key-combo complications
'ArrowUp',
'ArrowRight',
'ArrowDown',
'ArrowLeft',
'CapsLock', // because it is inconsistent (toggled) causing confusion
];
// 'control' keys which must be pressed first and are required to be part of a combo
const controlKeys = [
'Control',
'Meta',
'Shift',
];
const getDisplayKey = (obj) => {
// normalize Digit keys
if ( obj.code && obj.code.startsWith('Digit') ) {
return obj.code.replace('Digit', '');
}
if (displayMap[obj.key]) {
if (typeof displayMap[obj.key] === 'function') {
return displayMap[obj.key]();
} else {
return displayMap[obj.key];
}
} else {
return obj.key.length === 1 ? obj.key.toUpperCase() : obj.key;
}
};
const existsInCombo = (key) => {
return combo.value.some(k => k.key.toLowerCase() === key.toLowerCase());
};
const handleKeyDown = (event) => {
if ( event.repeat ) return;
// break on Alt key because it causes OS-level key-combo complications
if ( event.key === 'Alt' ) {
cancelEdit();
invalidFeedback();
return;
}
if ( disallowedKeys.includes(event.key) || existsInCombo(event.key) || combo.value.length >= 4 ) {
invalidFeedback();
return;
}
if ( !combo.value.length && controlKeys.includes(event.key) ) {
combo.value.push({ key: event.key, code: event.code });
} else if ( combo.value.length ) {
combo.value.push({ key: event.key, code: event.code });
} else {
invalidFeedback();
}
};
const invalidFeedback = () => {
isComboInvalid.value = true;
setTimeout(() => {
isComboInvalid.value = false;
}, 500);
return;
}
const handleKeyUp = (event) => {
// validate silently when key is disallowed, so user can build the combo without being interrupted
let silent = false;
if ( disallowedKeys.includes(event.key) ) {
silent = true;
}
validateCombo(silent);
};
const handleClick = () => {
if ( mode.value === 'display' ) {
startEdit();
} else {
cancelEdit();
}
};
const startEdit = () => {
combo.value = [];
mode.value = 'edit';
};
const cancelEdit = (event) => {
mode.value = 'display';
$el.value.blur();
nextTick(() => {
combo.value = props.modelValue;
});
};
const validateCombo = (silent = false) => {
// ensure at least one control key and one non-control key is present, and length is greater than 1
const hasControlKey = combo.value.some(k => controlKeys.includes(k.key));
const hasNonControlKey = combo.value.some(k => !controlKeys.includes(k.key));
if ( combo.value.length < 2 || !hasControlKey || !hasNonControlKey ) {
if ( silent ) return false;
cancelEdit();
invalidFeedback();
return;
}
// is valid
if ( silent ) return true;
emit('update:modelValue', combo.value);
mode.value = 'display';
$el.value.blur();
isComboSet.value = true;
setTimeout(() => {
isComboSet.value = false;
}, 1000);
};
</script>
<template>
<div
class="key-combo"
@keydown.stop.prevent="handleKeyDown"
@keyup.stop.prevent="handleKeyUp"
@keydown.esc="cancelEdit"
tabindex="-1"
@click="handleClick"
@blur="cancelEdit"
ref="$el"
:class="{ active: mode === 'edit' }"
>
<template v-if="mode === 'display' || combo.length">
<ShortcutBadge v-for="key in combo" :key="key.code" :keys="getDisplayKey(key)" variant="combo" />
<ShortcutBadge v-if="mode === 'edit' && !validateCombo(true)" keys="..." variant="combo" class="placeholder" />
</template>
<Text v-else tag="span" type="body/m-reg" class="placeholder">
<ShortcutBadge keys="CTRL" variant="combo" />
<ShortcutBadge :keys="displayMap['Meta']()" variant="combo" />
<ShortcutBadge keys="Shift" variant="combo" />
</Text>
<div class="action-icons">
<Icon v-if="isComboSet" name="check" color="var(--green-700)" weight="bold" class="icon-set" tabindex="-1" />
<Icon v-else-if="isComboInvalid" name="warning-circle" color="var(--destructive-400)" weight="bold" tabindex="-1" />
<Icon v-else-if="mode === 'display'" name="pencil-simple-line" color="var(--c-icon-gray)" weight="bold" tabindex="-1" />
<Icon v-else name="arrow-clockwise" class="spin" color="var(--metal-200)" tabindex="-1" />
</div>
</div>
</template>
<style>
.key-combo {
display: flex;
flex-flow: row nowrap;
gap: var(--sp-1);
align-items: center;
justify-content: end;
cursor: pointer;
padding: var(--sp-2) var(--sp-3);
border-radius: 99em;
border: 1px solid transparent;
transition: border-color .2s ease;
&:hover,
&.active {
border-color: var(--c-border-secondary);
}
* {
pointer-events: none;
}
.placeholder {
opacity: .6;
display: flex;
flex-flow: row nowrap;
gap: var(--sp-1);
}
.action-icons {
--size: 16px;
width: var(--size);
margin-left: var(--sp-2);
}
.icon {
font-size: var(--size);
}
}
</style>
Fixed bottom-left feedback card (“Earn $5” glass pill + copy) that opens the feedback modal. Ships as ui/components/Upsell/FeedbackPrompt.vue (rendered desktop-only in App.vue). Stays as shipped (settled 2026-07-10) — the minimized/dismissed-dock concept was dropped; closing the feedback modal just returns to the background. Only the inline Pro prompt changes: it becomes the full-width upsell strip (handoff/upsell-strip.html). Note: the verbatim .vue listing below mirrors this REPO’s copy, which lags staging — the live card reads “Help us improve!” + “Earn $5 credit, take a moment and give us your feedback.”
open and hide, but neither is ever emitted — the button calls feedback.showModal() on the Pinia feedback store directly (App.vue's @open binding is currently dead). Renders <Pill leading="green" text="Earn $5" background="glass" />, heavy + metal-950 message copy, and a black size="sm" PrimaryButton (imported under the alias SmallButton). Positioned fixed; left: 12px; bottom: 12px; max-width: 250px; goes static and full-width below 790px.<FeedbackPrompt @open="modals.feedback = true" @close="console.log('close')" />
.vue file (verbatim from ui/components/Upsell/FeedbackPrompt.vue)<script setup>
import SmallButton from '@/buttons/PrimaryButton.vue'
import Pill from '@/components/Pill.vue'
import { useFeedbackStore } from '@/store/feedback'
const emit = defineEmits(['open', 'hide'])
const feedback = useFeedbackStore()
</script>
<template>
<div class="feedback-prompt-card">
<Pill leading="green" text="Earn $5" background="glass" />
<div class="__message">
<Text type="body/m-heavy" text="Provide feedback!" />
<Text type="body/s-med" color="metal-950">
How's the beta treating you? Let us know
and get an immediate account credit.
</Text>
</div>
<SmallButton size="sm" variant="black" label="Let us know"
@click="feedback.showModal()" />
</div>
</template>
<style scoped>
.feedback-prompt-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
max-width: 250px;
padding: 16px;
border-radius: 16px;
background: var(--c-surface-secondary);
border: 1px solid var(--c-border-primary);
box-shadow: var(--shadow-popover);
position: fixed;
left: 12px;
bottom: 12px;
@media screen and (max-width: 790px) {
width: 100%;
position: relative;
}
.__message {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
}
</style>
Feedback survey modal (question steps + $5-credit thank-you view) driven by the Pinia feedback store. Ships as ui/modals/FeedbackModal.vue (mounted once in App.vue). Work in progress.
defineProps — App.vue's :popped="feedback.modal.show" reaches the root PopModal only as a fallthrough attr while the template itself hardcodes :popped="true"; open/close and question state live in useFeedbackStore() (hideModal(), currentQuestion, progress, submitted, submit()). Declares emits send and skip but never emits them. Question view: glass Question n of total Pill, question title, a textarea ComboField for the text type, a Stepper, and Skip/Next footer buttons with isValid gating Next. WIP caveats: the rating and checkbox variants are commented out (along with the RatingScale import); next() calls feedback.submit() and returns immediately, so the multi-step progress branch below it is unreachable; back() is empty (Skip does nothing); the scoped &__checkbox-grid selector has no parent and matches nothing. The submitted view shows the 🎉 thank-you message with <CreditValue> and a Done button.<FeedbackModal :popped="feedback.modal.show" @unpop="feedback.hideModal()" />
.vue file (verbatim from ui/modals/FeedbackModal.vue)<script setup>
import { computed, ref } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalMessage from '@/components/pop/templates/ModalMessage.vue'
import Pill from '@/components/Pill.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
//import RatingScale from '@/components/RatingScale.vue'
import Stepper from '@/components/Stepper.vue'
import ComboField from '@/input/ComboField.vue'
import FieldRegion from '@/input/FieldRegion.vue'
import CheckboxInputGroup from '@/input/CheckboxInputGroup.vue'
import CreditValue from '@/components/CreditValue.vue'
import { useFeedbackStore } from '@/store/feedback'
const emit = defineEmits(['send', 'skip'])
const feedback = useFeedbackStore()
const value = ref(null) //ref(props.question.type === 'checkbox' ? [] : null)
const isValid = computed(() => {
if (feedback.currentQuestion.type === 'text') {
return typeof value.value === 'string'
&& value.value.trim().length >= (feedback.currentQuestion.minLength ?? 1)
}
if (feedback.currentQuestion.type === 'rating') return value.value != null
if (feedback.currentQuestion.type === 'checkbox') return Array.isArray(value.value) && value.value.length >= 1
return false
})
const back = () => {
}
const next = () => {
feedback.submit()
return
if (feedback.progress.now === feedback.progress.total) {
feedback.submit()
} else {
feedback.updateProgress(feedback.progress.now + 1)
}
}
</script>
<template>
<PopModal :popped="true" width="560px" @unpop="feedback.hideModal()">
<TemplateModalMessage v-if="!feedback.submitted" emoji="">
<div class="__status">
<Pill leading="green" background="glass" :text="'Question ' + feedback.progress.now + ' of ' + feedback.progress.total" />
</div>
<Text type="body/xxl" color="text-primary" :text="feedback.currentQuestion.title" />
<!-- text variant -->
<FieldRegion v-if="feedback.currentQuestion.type === 'text'" style="text-align: left">
<ComboField
type="textarea"
:placeholder="feedback.currentQuestion.placeholder"
v-model="value"
/>
</FieldRegion>
<!-- rating variant -->
<!-- <RatingScale v-else-if="question.type === 'rating'"
v-model="value"
:ends="question.scaleEnds" /> -->
<!-- checkbox variant -->
<!-- <div v-else-if="question.type === 'checkbox'"
class="feedback-micro-prompt__checkbox-grid">
<CheckboxInputGroup
:data="question.options"
v-model="value"
layout="col"
/>
</div> -->
<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
<template #footer>
<PrimaryButton variant="gray" size="md" label="Skip"
@click="back()" />
<PrimaryButton variant="green" size="md" label="Next"
:disabled="!isValid"
@click="next()" />
</template>
</TemplateModalMessage>
<TemplateModalMessage v-else emoji="🎉">
<Text type="body/xxl" color="text-primary"
text="Thanks for shaping the beta!" />
<div class="credit-wrapper">
<CreditValue title="$5 Credit Added to Your Account" subtitle="Applies to any Pro purchase" />
</div>
<Text type="body/m-reg" color="text-secondary">
We appreciate your feedback as we work to deliver the best experience possible.
We've added a credit to your account which is applied automatically toward a Pro subscription.
</Text>
<PrimaryButton variant="green" size="md" label="Done" @click="feedback.hideModal()" />
</TemplateModalMessage>
</PopModal>
</template>
<style scoped>
.__status {
margin: -10px auto 0 auto;
}
&__checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-3);
width: 100%;
max-width: 420px;
}
.credit-wrapper {
margin: 10px auto;
width: 90%;
text-align: left;
}
</style>
Upsell/gate modal that maps a type key to canned image + title + description content for account-required and pro-required features. Ships as ui/modals/FeatureModal.vue (mounted once in App.vue).
popped; width (falls back to '524px'); type — a key into the internal featureContent map (account-required: account-required, use-favorites, hide-action-bar, sort-collections, unfollow-collection, remove-items; pro-required: sort-smart, create-list, sort-lists, save-combo, edit-list, clone-collection, delete-list); background (becomes backgroundClass="upsell-<background>"). Emits unpop, update:popped, confirm, cancel, signup, subscribe. CTA: pro-required types show the subscribe label ('Take 50% off now!' when IS_BETA === 'true', else 'Subscribe now!') and emit subscribe; account-required types show 'Sign Up Now', emit signup, hide the header close button, and close() is a no-op while IS_BETA === 'true'. Reuses the .list-settings-modal root class on PopModal.<FeatureModal :popped="upsell.feature.show" :type="upsell.feature.type" :background="upsell.feature.background"
@signup="login('join')" @subscribe="subscribe()" @unpop="upsell.updateShowFeature(false, null)" />
.vue file (verbatim from ui/modals/FeatureModal.vue)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalContent from '@/components/pop/templates/ModalContent.vue'
import ModalHeaderSettings from '@/components/pop/templates/ModalHeaderSettings.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import IconButton from '@/buttons/IconButton.vue'
import Icon from '@/components/Icon.vue'
import { getEnv } from '@shared/config/env'
const props = defineProps(['popped', 'width', 'type', 'background']);
const emit = defineEmits(['unpop', 'update:popped', 'confirm', 'cancel', 'signup', 'subscribe']);
const proImage = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const favoritesImage = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const boxDownImage = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
const featureContent = {
//account required
'account-required': {
image: proImage,
title: 'Account Required',
description: 'Enjoy early access with a free account.',
type: 'account-required',
},
'use-favorites': {
image: favoritesImage,
title: 'Account Required',
description: 'Create your free account to start adding items to your Favorites.',
type: 'account-required',
},
'hide-action-bar': {
image: boxDownImage,
title: 'Account Required',
description: 'Get more features—including hiding the action bar—with a free account.',
type: 'account-required',
},
'sort-collections': {
image: boxDownImage,
title: 'Account Required',
description: 'Curate from our growing set of in-demand emoji collections.',
type: 'account-required',
},
'unfollow-collection': {
image: boxDownImage,
title: 'Account Required',
description: 'Curate from our growing set of in-demand emoji collections.',
type: 'account-required',
},
'remove-items': {
image: boxDownImage,
title: 'Account Required',
description: 'Curate your recent emoji by choosing one or more to remove.',
type: 'account-required',
},
//subscription required
'sort-smart': {
image: proImage,
title: 'Subscription Required',
description: 'Enable smart sorting options and unlock even more.',
type: 'pro-required',
},
'create-list': {
image: proImage,
title: 'Subscription Required',
description: 'Create and curate your own lists to find your perfect theme.',
type: 'pro-required',
},
'sort-lists': {
image: proImage,
title: 'Subscription Required',
description: 'Create additional lists and access the perfect emoji more quickly.',
type: 'pro-required',
},
'save-combo': {
image: proImage,
title: 'Subscription Required',
description: 'Curate combos and organize them for fast copying at any time.',
type: 'pro-required',
},
'edit-list': {
image: proImage,
title: 'Subscription Required',
description: 'Rename this list or create your own lists with a pro account.',
type: 'pro-required',
},
'clone-collection': {
image: proImage,
title: 'Subscription Required',
description: 'Use this as a starting point for your own custom list.',
type: 'pro-required',
},
'delete-list': {
image: proImage,
title: 'Subscription Required',
description: 'Remove this list, or customize it and make it your own.',
type: 'pro-required',
},
}
const close = async () => {
const beta = await getEnv('IS_BETA')
if (beta === 'true' && props.type === 'account-required') {
return
}
emit('unpop');
emit('update:popped', false);
}
const subscribeLabel = computed(() => {
const beta = getEnv('IS_BETA')
if (beta === 'true') {
return 'Take 50% off now!'
}
return 'Subscribe now!'
})
const signupLabel = 'Sign Up Now'
</script>
<template>
<PopModal
:width="props.width || '524px'"
:popped="props.popped"
@unpop="close"
class="list-settings-modal"
:class="''"
:backgroundClass="'upsell-' + props.background"
v-bind="$attrs"
>
<TemplateModalContent>
<template #header>
<ModalHeaderSettings v-if="props.type !== 'account-required'" :title="''">
<template #controls="{ close }">
<IconButton icon="x" @click="close" size="md" variant="white" />
</template>
</ModalHeaderSettings>
</template>
<slot>
<div class="upsell-inner">
<img class="upsell-image" :src="featureContent[props.type].image" />
<div class="upsell-message">
<Text type="body/xxl" color="white" :text="featureContent[props.type].title" />
<Text type="body/m-reg" color="white" :text="featureContent[props.type].description" />
</div>
<PrimaryButton
:label="featureContent[props.type].type === 'pro-required' ? subscribeLabel : signupLabel"
variant="green" size="md" class="sign-up"
@click="featureContent[props.type].type === 'pro-required' ? emit('subscribe') : emit('signup')" />
</div>
</slot>
<template #footer v-if="$slots.footer">
<slot name="footer" :close="close" />
</template>
</TemplateModalContent>
</PopModal>
</template>
<style scoped>
.upsell-inner {
margin-top: -40px;
display: flex;
padding: 0 24px 16px 24px;
flex-direction: column;
align-items: center;
flex-shrink: 0;
.upsell-image {
width: 96px;
.icon {
width: 96px;
height: 96px;
}
}
.upsell-message {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
align-self: stretch;
max-width: 280px;
margin: 0 auto 16px auto;
}
}
</style>
Beta welcome/upsell modal with the upsell-doodle background and a 50%-off-for-life pitch as its default slot content. Ships as ui/modals/DoodleModal.vue (mounted once in App.vue).
popped only (array-style, untyped). Emits unpop, update:popped, confirm, cancel, subscribe. Wraps PopModal with backgroundClass="upsell-doodle" and reuses the .list-settings-modal root class. The default slot renders “Welcome!” + “Take 50% off for life if you upgrade now.” + a green Get 50% off now button that emits subscribe; controls and footer slots pass through. Quirk: the template reads props.width (fallback '768px') and props.title, but neither is declared in defineProps, so both are always undefined — width is effectively always 768px and the header title is empty.<DoodleModal :popped="upsell.doodle.show" :type="upsell.doodle.type"
@subscribe="subscribe()" @unpop="upsell.updateShowDoodle(false, null)" />
.vue file (verbatim from ui/modals/DoodleModal.vue)<script setup>
import PopModal from '@/components/pop/PopModal.vue'
import TemplateModalContent from '@/components/pop/templates/ModalContent.vue'
import ModalHeaderSettings from '@/components/pop/templates/ModalHeaderSettings.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
const props = defineProps(['popped'])
const emit = defineEmits(['unpop', 'update:popped', 'confirm', 'cancel', 'subscribe'])
const close = () => {
emit('unpop');
emit('update:popped', false);
}
</script>
<template>
<PopModal
:width="props.width || '768px'"
:popped="props.popped"
@unpop="close"
class="list-settings-modal"
:background-class="'upsell-doodle'"
v-bind="$attrs"
>
<TemplateModalContent>
<template #header>
<ModalHeaderSettings :title="props.title">
<template #controls="{ close }" v-if="$slots.controls">
<slot name="controls" :close="close" />
</template>
</ModalHeaderSettings>
</template>
<slot>
<h2>Welcome!</h2>
<Text type="body/xxl" text="Take 50% off for life if you upgrade now." />
<PrimaryButton label="Get 50% off now" variant="green" size="md" class="sign-up" @click="emit('subscribe')" />
</slot>
<template #footer v-if="$slots.footer">
<slot name="footer" :close="close" />
</template>
</TemplateModalContent>
</PopModal>
</template>
Generic surface with border, radius, and shadow. Use as a container for groups of content (e.g. inside pricing comparisons).
Card.vue — surfaces in the app are usually built per-context. Modifiers: card--flat (no shadow), card--accent (green border).Standard surface with shadow.
No drop shadow.
Green-tinted border.
<Card>…</Card> <Card variant="accent">…</Card>
.vue file<template>
<div class="card" :class="cardClasses">
<slot />
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'default',
validator: (v) => ['default', 'flat', 'accent'].includes(v)
}
})
const cardClasses = computed(() => {
const classes = {}
if (props.variant === 'flat') classes['card--flat'] = true
if (props.variant === 'accent') classes['card--accent'] = true
return classes
})
</script>
<style scoped>
.card {
background: var(--c-surface-secondary)
border: 1px solid var(--c-border-primary)
border-radius: var(--r-xl)
padding: var(--sp-7)
box-shadow: var(--shadow-card)
&--flat {
box-shadow: none
}
&--accent {
border-color: color-mix(in srgb, var(--green-600) 45%, var(--c-border-primary))
}
}
</style>
The dark modal shell used for paywalls and feedback prompts. Matches the official onboarding modal system (eyebrow pill + DM Sans Black title + inner content card + side-by-side gray + green CTAs).
components.css as .pop-modal__* classes and is new — it would be a wrapper component (PaywallModal / FeedbackMicroPrompt) on top of PopModal.<PopModal :show="modals.paywall" @hide="modals.paywall = false">
<template #eyebrow>Pro feature</template>
<template #title>Save combos with Pro</template>
<template #content>
<div class="icon">🧩</div>
<BulletRow color="white"
text="Keep your best emoji combos forever" />
<BulletRow color="white"
text="One click to copy any saved combo" />
<BulletRow color="white"
text="Sync across web, Chrome & desktop" />
</template>
<template #cta-secondary>Not now</template>
<template #cta-primary>Go Pro</template>
</PopModal>
.vue file (from ui/)<script setup>
import { layer, controller } from '@/utils/overlayer.js';
import { watch, onMounted, onUnmounted, ref, nextTick, provide } from 'vue';
const emit = defineEmits(['pop', 'unpop']);
const $el = ref(null);
let zIndex = ref(0);
let controllerId = null;
provide('unpop', () => {
unpop();
})
const props = defineProps({
popped: Boolean,
width: {
type: String,
default: 'unset'
},
height: {
type: String,
default: 'unset'
},
unpopOn: {
type: String,
default: 'keydown.escape, !resize'
},
scroll: {
type: String,
default: 'modal'
},
persistent: {
type: Boolean,
default: false
},
layerGroup: {
type: Number,
default: 2,
},
// invalid: { // could cause modal to shake or some indication that it cannot be closed yet, etc
// type: Boolean,
// default: false
// }
coverWhenSmall: {
type: Boolean,
default: false
},
// fill the entire screen on large and small viewports
fill: {
type: Boolean,
default: false
},
drawer: {
type: Boolean,
default: false
},
});
const scrollMode = props.scroll === 'content' || props.drawer ? 'content' : 'modal';
const dur = props.drawer ? { enter: 400, leave: 400 } : { enter: 300, leave: 210 };
const unpop = () => {
emit('unpop');
controller.destroy(controllerId);
// $el.value.removeEventListener('scroll', preventScroll);
}
// const containerStyles = () => {
// return {
// maxWidth: props.width,
// }
// }
const pop = () => {
zIndex.value = layer.assign(props.layerGroup);
// Wait for the DOM to be rendered
nextTick(() => {
// prevent scrolling of the background
$el.value.addEventListener('scroll', preventScroll, { passive: false });
// Add to event listener queue
controllerId = controller.add(props.unpopOn, () => {
emit('unpop');
}, false, null, $el.value);
})
}
const preventScroll = (e) => {
e.preventDefault();
}
watch(() => props.popped, v => v && pop());
onMounted(() => {
if ( props.popped ) {
pop();
}
});
onUnmounted(() => {
document.removeEventListener('scroll', preventScroll);
});
</script>
<template>
<Teleport to="body">
<Transition :duration="dur">
<div class="pop-modal no-scroll-bars"
:style="{zIndex: zIndex, '--w': props.width, '--h': props.height}"
:class="{
['scroll:'+scrollMode]: true,
[props.class]: props.class, 'cover-when-small': props.coverWhenSmall,
'fill': props.fill,
'drawer': props.drawer,
}"
v-if="props.popped"
ref="$el"
@click="unpop"
v-bind="$attrs"
>
<div class="pop-modal-scroll-wrapper">
<div class="pop-modal-container" @click.stop>
<slot />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style>
.pop-modal {
--pad-outer-block: var(--sp-md);
--h: initial;
width: 100%;
height: 100%;
height: 100vh;
height: 100dvh;
position: fixed;
inset: 0;
display: grid;
place-items: center;
overflow: scroll;
overscroll-behavior: contain;
scrollbar-gutter: stable;
/* .text {
--fluid-txt-max: var(--w);
--fluid-txt-basis: 100cqw;
} */
/* backdrop */
&::before {
content: '';
position: fixed;
z-index: -1;
inset: 0;
width: 100%;
background: #0008;
backdrop-filter: blur(2px) opacity(1);
will-change: backdrop-filter;
}
.pop-modal-scroll-wrapper {
width: 100%;
height: 100%;
display: block;
padding: var(--pad-outer-block) 0;
display: inherit;
place-items: inherit;
}
.pop-modal-container {
--br: 2.4em;
width: clamp(25rem, var(--w, 160rem), 93vw);
min-height: 10vh;
z-index: 1;
border-radius: var(--br);
/* use background unless template specifies otherwise */
&:not(:has(.use-own-bg)) {
background: var(--c-surface-primary);
}
}
/* Modes */
&.fill {
padding: 0;
.pop-modal-container {
--br: 0;
width: 100%;
min-height: 100%;
}
}
&.scroll\:content {
overflow: hidden;
.pop-modal-scroll-wrapper {
height: 100dvh;
/* padding: 0; */
}
.pop-modal-container {
min-height: unset;
/* height: 100dvh; */
/* max-height: min(var(--h, 100dvh), calc(100dvh - 2 * var(--pad-outer-block))); */
[class*="template-"] {
height: 100%;
max-height: min(var(--h, 100dvh), calc(100dvh - 2 * var(--pad-outer-block)));
/* height: 100dvh;
max-height: var(--h, calc(100dvh - 2 * var(--pad-outer-block))); */
}
section {
/* flex: 1 1 auto; */
/* height: 300px; */
overflow: scroll;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
}
}
&.drawer {
.pop-modal-scroll-wrapper {
padding-bottom: 0;
place-items: end;
}
.pop-modal-container {
width: 100%;
max-width: unset;
border-radius: var(--br) var(--br) 0 0;
[class*="template-"] {
height: 100dvh;
}
}
&:not(:has(footer)) [class*="template-"] {
padding-bottom: 0;
> section {
padding-bottom: var(--pad-outer-block);
}
}
}
/* Animation */
&.v-enter-active {
/* transition:
opacity .2s ease-out; */
&::before {
transition:
background-color .2s ease-out,
backdrop-filter .4s ease-out;
}
.pop-modal-container {
transition:
scale .3s cubic-bezier(0.175, 0.885, 0.32, 1.275),
opacity .2s ease-out;
}
}
&.v-leave-active {
/* transition:
opacity .2s ease-in; */
&::before {
transition:
background-color .2s ease-in,
backdrop-filter .2s ease-in;
}
.pop-modal-container {
transition:
scale .2s ease-in-out,
opacity .2s ease-in;
}
}
&.v-enter-from,
&.v-leave-to {
/* opacity: 0; */
&::before {
background: #0000;
backdrop-filter: blur(2px) opacity(0);
}
.pop-modal-container {
opacity: 0;
scale: .9;
}
}
&.drawer {
&.v-enter-active,
&.v-leave-active {
.pop-modal-container {
transition:
transform .4s cubic-bezier(0.3, 0, 0.3, 1);
opacity: unset;
scale: unset;
}
}
&.v-enter-from,
&.v-leave-to {
.pop-modal-container {
transform: translateY(var(--h, 100dvh));
}
}
}
}
@media ( max-width: 600px ) {
.pop-modal.cover-when-small {
padding: 0;
.pop-modal-container {
width: 100vw;
border-radius: 0;
}
}
}
</style>
Persistent entry point in the sidebar. Opens the on-demand feedback modal (rating + comment).
<FeedbackButton
text="Give feedback"
sub="You're in the beta — help shape it"
@click="modals.feedback = true"
/>
.vue file<template>
<button class="feedback-button" @click="$emit('click')" v-bind="$attrs">
<span class="feedback-button__icon jp-font">💬</span>
<span>
<span class="feedback-button__text">{{ text }}</span>
<span class="feedback-button__sub">{{ sub }}</span>
</span>
</button>
</template>
<script setup lang="ts">
defineProps<{
text?: string
sub?: string
}>()
defineEmits<{
click: []
}>()
</script>
<style scoped>
.feedback-button {
display: flex
align-items: center
gap: 10px
padding: 10px 12px
border-radius: var(--r-md)
background: var(--c-surface-highlight)
cursor: pointer
border: none
width: 100%
text-align: left
&:hover {
background: color-mix(in srgb, var(--c-surface-highlight) 110%, transparent)
transition: background 0.15s
}
&:active {
transform: scale(0.98)
}
}
.feedback-button__icon {
font-size: 1.6rem
flex: none
}
.feedback-button__text {
font-family: var(--ff-inter)
font-weight: 700
font-size: 1.3rem
color: var(--c-text-primary)
display: block
}
.feedback-button__sub {
display: block
font-weight: 400
font-size: 1.1rem
color: var(--c-text-tertiary)
}
</style>
The filled green circle check from the profile / banner designs (Settings › Profile › Banners). Inline SVG in green-600, drawn with currentColor so it retints on any surface. Used as the bullet marker in plan cards, the account header, and upgrade lists.
.check-circle (20px) / .check-circle--sm (16px). In-app: one-line Phosphor registration in the existing <Icon> system — staging currently hardcodes this glyph as a raster <img>.<svg class="check-circle" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path fill="currentColor" d="M10 1.875C8.39303 1.875 6.82214 2.35152 5.48599 3.24431C4.14984 4.1371 3.10844 5.40605 2.49348 6.8907C1.87852 8.37535 1.71761 10.009 2.03112 11.5851C2.34462 13.1612 3.11846 14.6089 4.25476 15.7452C5.39106 16.8815 6.8388 17.6554 8.41489 17.9689C9.99099 18.2824 11.6247 18.1215 13.1093 17.5065C14.594 16.8916 15.8629 15.8502 16.7557 14.514C17.6485 13.1779 18.125 11.607 18.125 10C18.1227 7.84581 17.266 5.78051 15.7427 4.25727C14.2195 2.73403 12.1542 1.87727 10 1.875ZM13.5672 8.56719L9.19219 12.9422C9.13414 13.0003 9.06521 13.0464 8.98934 13.0779C8.91346 13.1093 8.83213 13.1255 8.75 13.1255C8.66787 13.1255 8.58654 13.1093 8.51066 13.0779C8.43479 13.0464 8.36586 13.0003 8.30781 12.9422L6.43281 11.0672C6.31554 10.9499 6.24965 10.7909 6.24965 10.625C6.24965 10.4591 6.31554 10.3001 6.43281 10.1828C6.55009 10.0655 6.70915 9.99965 6.875 9.99965C7.04085 9.99965 7.19991 10.0655 7.31719 10.1828L8.75 11.6164L12.6828 7.68281C12.7409 7.62474 12.8098 7.57868 12.8857 7.54725C12.9616 7.51583 13.0429 7.49965 13.125 7.49965C13.2071 7.49965 13.2884 7.51583 13.3643 7.54725C13.4402 7.57868 13.5091 7.62474 13.5672 7.68281C13.6253 7.74088 13.6713 7.80982 13.7027 7.88569C13.7342 7.96156 13.7503 8.04288 13.7503 8.125C13.7503 8.20712 13.7342 8.28844 13.7027 8.36431C13.6713 8.44018 13.6253 8.50912 13.5672 8.56719Z"/>
</svg>
<!-- 16px bullet scale -->
<svg class="check-circle check-circle--sm" ...>
// The glyph is Phosphor's CheckCircle (fill weight) — the app's
// existing <Icon> system already speaks Phosphor, so no new SFC.
// 1. ui/components/IconIndex.vue — register it:
const PhCheckCircle = defineAsyncComponent(() =>
import("@phosphor-icons/vue/dist/icons/PhCheckCircle.vue.mjs"));
export const icons = [
...
{ name: 'check-circle', comp: PhCheckCircle, props: {weight: 'fill'} },
]
// 2. Use it (sizes via font-size — icon svg is 1em):
<Icon name="check-circle" color="var(--green-600)" />
// Replaces the hardcoded raster currently on staging:
// <img class="icon" src=".../icons/check-circle_green.png" />
Checked bullet line with the standard green circle check. Used in pricing cards, paywall bullets, and lists with reasons-to-upgrade.
Currently in progress by the dev.
color prop ("default" | "white").<BulletRow text="Save unlimited combos" /> <BulletRow text="No ads" color="white" /> <!-- check is an <Icon name="check-circle" /> inside -->
.vue file<template>
<div :class="['bullet-row', colorClass]">
<Icon name="check-circle" color="var(--green-600)" />
<span class="bullet-row__text">
<slot>{{ text }}</slot>
</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Icon from '@/components/Icon.vue'
const props = defineProps({
text: {
type: String,
default: ''
},
color: {
type: String,
default: 'default',
validator: (v) => ['default', 'white'].includes(v)
}
})
const colorClass = computed(() => {
if (props.color === 'white') {
return 'c:white/txt'
}
return ''
})
</script>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* the Icon svg is 1em — size via font-size; nudge onto the
text's optical center */
.bullet-row :deep(.icon) {
font-size: 16px;
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
App-store-style row with icon + title + subtitle. Used for the Chrome / Mac app callouts in the account header.
.on-dark when sitting on a dark surface (e.g. inside the dark account header).<AppRow icon="chrome.png" title="Chrome Extension" subtitle="One click from any tab" />
.vue file<template>
<a :href="href" :class="['app-row', { 'on-dark': onDark }]">
<img :src="icon" :alt="title" class="app-row__icon">
<span class="app-row__content">
<span class="app-row__title">{{ title }}</span>
<span class="app-row__subtitle">{{ subtitle }}</span>
</span>
</a>
</template>
<script setup>
defineProps({
icon: {
type: String,
required: true
},
title: {
type: String,
required: true
},
subtitle: {
type: String,
required: true
},
href: {
type: String,
default: '#'
},
onDark: {
type: Boolean,
default: false
}
})
</script>
<style scoped>
.app-row {
display: inline-flex
align-items: center
gap: 12px
padding: 10px 16px 10px 10px
border-radius: var(--r-md)
background: color-mix(in srgb, var(--metal-300) 14%, transparent)
transition: background .2s
text-decoration: none
color: inherit
&:hover {
background: color-mix(in srgb, var(--metal-300) 22%, transparent)
}
&.on-dark {
background: rgba(255, 255, 255, .08)
&:hover {
background: rgba(255, 255, 255, .14)
}
&__title {
color: var(--white)
}
&__subtitle {
color: rgba(255, 255, 255, .65)
}
}
}
.app-row__icon {
width: 32px
height: 32px
border-radius: var(--r-sm)
flex: none
object-fit: contain
}
.app-row__content {
display: flex
flex-direction: column
line-height: 1.25
}
.app-row__title {
font-family: var(--ff-inter)
font-weight: 700
font-size: 1.3rem
color: var(--c-text-primary)
}
.app-row__subtitle {
font-family: var(--ff-inter)
font-weight: 400
font-size: 1.1rem
color: var(--c-text-tertiary)
}
</style>
Interactive 1-N star input. v-model bound to Number. Used inside FeedbackModal.
Currently in progress by the dev.
<StarRating v-model="rating" :max="5" />
.vue file<template>
<div
class="star-rating"
:data-value="modelValue"
:data-hover="hoverValue"
role="radiogroup"
:aria-label="ariaLabel"
>
<button
v-for="n in max"
:key="n"
class="star-rating__star"
:class="{ 'is-active': n <= modelValue }"
type="button"
:aria-label="`${n} star${n !== 1 ? 's' : ''}`"
@click="$emit('update:modelValue', n)"
@mouseover="hoverValue = n"
@mouseout="hoverValue = 0"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
modelValue: {
type: Number,
required: true
},
max: {
type: Number,
default: 5
},
ariaLabel: {
type: String,
default: 'Rating'
}
})
defineEmits(['update:modelValue'])
const hoverValue = ref(0)
</script>
<style scoped>
.star-rating {
display: inline-flex
gap: 4px
padding: 4px 0
justify-content: center
width: 100%
}
.star-rating__star {
appearance: none
border: none
background: none
padding: 4px 2px
font-size: 3.2rem
line-height: 1
color: color-mix(in srgb, currentColor 22%, transparent)
cursor: pointer
transition: color .12s, transform .08s
&::before {
content: '★'
}
&:hover,
&:focus-visible {
outline: none
transform: scale(1.08)
color: color-mix(in srgb, var(--green-500) 60%, transparent)
}
&.is-active {
color: var(--green-500)
}
}
/* When the wrapper has data-hover="N", every star <= N highlights. */
.star-rating[data-hover="1"] &:nth-child(-n+1),
.star-rating[data-hover="2"] &:nth-child(-n+2),
.star-rating[data-hover="3"] &:nth-child(-n+3),
.star-rating[data-hover="4"] &:nth-child(-n+4),
.star-rating[data-hover="5"] &:nth-child(-n+5) {
color: color-mix(in srgb, var(--green-500) 65%, transparent)
transform: scale(1.05)
}
</style>
Green-dot pill at the top of every dark PopModal-based modal. Used in PaywallModal, FeedbackModal, FeedbackMicroPrompt, OnboardingModal.
PopModal shell.<PopModalEyebrow>Pro feature</PopModalEyebrow>
.vue file<template>
<div class="pop-modal__eyebrow">
<span class="pop-modal__eyebrow-dot"></span>
<slot />
</div>
</template>
<script setup>
// PopModalEyebrow — green-dot pill at the top of every dark PopModal-based modal.
// Used in PaywallModal, FeedbackModal, FeedbackMicroPrompt, OnboardingModal.
// Slot-driven label content. Always sits at the top of a dark PopModal shell.
</script>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex
align-items: center
gap: 8px
padding: 6px 14px 6px 12px
border-radius: var(--r-pill)
background: color-mix(in srgb, var(--metal-300) 12%, transparent)
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent)
font-family: var(--ff-inter)
font-weight: 700
font-size: 1.3rem
line-height: 1.4
color: var(--white)
}
.pop-modal__eyebrow-dot {
width: 10px
height: 10px
border-radius: 50%
background: var(--green-600)
flex: none
}
</style>
Pagination dots for multi-step flows. Shipped in the latest build as ui/components/Stepper.vue. modelValue (1-indexed) marks the active dot; active dot is solid white with soft glow.
modelValue (Number, required, 1-indexed 1..total) and total (Number, required, validator > 0). Declares update:modelValue in defineEmits but no dot click handler is wired — dots are display-only in the shipped file (the feedback-flow mockup spec wants them clickable; flagged). Used by FeedbackModal.vue.<Stepper :total="feedback.progress.total" :model-value="feedback.progress.now" />
.vue file (verbatim from ui/components/Stepper.vue)<script setup>
const props = defineProps({
/**
* Current step (1-indexed, 1..total)
*/
modelValue: {
type: Number,
required: true
},
/**
* Total number of steps
*/
total: {
type: Number,
required: true,
validator: (value) => value > 0
}
})
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<div class="pop-modal__stepper">
<span
v-for="index in total"
:key="index"
class="pop-modal__stepper-dot"
:class="{ 'is-active': modelValue === index }"
role="progressbar"
:aria-valuenow="modelValue"
:aria-valuemin="1"
:aria-valuemax="total"
:aria-label="`Step ${index} of ${total}`">
</span>
</div>
</template>
<style scoped>
.pop-modal__stepper {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 2px 0;
}
.pop-modal__stepper-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: color-mix(in srgb, var(--white) 25%, transparent);
transition: background .15s, box-shadow .2s;
cursor: pointer;
}
.pop-modal__stepper-dot.is-active {
background: var(--white);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--white) 12%, transparent);
}
</style>
Inset was/now/tag price strip used inside PaywallModal. Three props: was, now, tag.
<PaywallPrice was="$32" now="$16 / yr" tag="50% off · founding" />
.vue file<template>
<div class="paywall-price">
<span v-if="was" class="paywall-price__was">{{ was }}</span>
<span class="paywall-price__now">{{ now }}</span>
<span v-if="tag" class="paywall-price__tag">{{ tag }}</span>
</div>
</template>
<script setup>
defineProps({
was: String,
now: String,
tag: String
})
</script>
<style scoped>
.paywall-price {
width: 100%
padding: 14px 16px
border-radius: var(--r-md)
background: color-mix(in srgb, var(--metal-300) 12%, transparent)
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent)
text-align: center
line-height: 1.3
}
.paywall-price__was {
margin-right: 8px
font-size: 1.2rem
color: color-mix(in srgb, var(--white) 50%, transparent)
text-decoration: line-through
}
.paywall-price__now {
font-family: var(--ff-dm-sans)
font-weight: 900
font-size: 1.9rem
color: var(--white)
letter-spacing: -.02em
}
.paywall-price__tag {
display: block
margin-top: 4px
font-family: var(--ff-inter)
font-weight: 700
font-size: 1.05rem
letter-spacing: .12em
text-transform: uppercase
color: var(--green-500)
}
</style>
1-5 pill scale with end labels (Slow / Fast). v-model bound to Number. Used inside FeedbackMicroPrompt.
:labels.<RatingScale v-model="rating" :labels="['Slow', 'Fast']" />
.vue file<template>
<div class="rating-scale">
<span class="rating-scale__end">{{ labels[0] }}</span>
<div class="rating-scale__pills">
<button
v-for="n in 5"
:key="n"
:class="['rating-scale__pill', { 'is-active': modelValue === n }]"
:aria-pressed="modelValue === n"
@click="$emit('update:modelValue', n)"
>
{{ n }}
</button>
</div>
<span class="rating-scale__end">{{ labels[1] }}</span>
</div>
</template>
<script setup>
defineProps({
modelValue: {
type: Number,
default: 0
},
labels: {
type: Array,
default: () => ['Slow', 'Fast']
}
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.rating-scale {
display: flex
align-items: center
gap: 8px
width: 100%
}
.rating-scale__end {
font-family: var(--ff-inter)
font-weight: 500
font-size: 1.1rem
color: color-mix(in srgb, var(--white) 55%, transparent)
flex: none
}
.rating-scale__pills {
display: flex
gap: 6px
flex: 1
}
.rating-scale__pill {
flex: 1
height: 38px
border-radius: var(--r-pill)
border: 1px solid color-mix(in srgb, var(--metal-300) 14%, transparent)
background: color-mix(in srgb, var(--metal-300) 10%, transparent)
color: var(--white)
font-family: var(--ff-inter)
font-weight: 700
font-size: 1.3rem
cursor: pointer
transition: background 0.15s, border-color 0.15s
padding: 0
&:hover {
background: color-mix(in srgb, var(--metal-300) 18%, transparent)
}
&.is-active {
background: var(--green-500)
color: var(--metal-950)
border-color: var(--green-500)
}
}
</style>