fix(uploader,setup): beta-test polish — crop overlay, sticker delete, emoji keyboard, copy

- crop: invert overlay shading; the destination-out trick on a
  semi-transparent fill was leaving the *inside* of the crop more
  transparent than the outside, so the keep-area read as darker
  than the discard-area. Replace with 4 explicit dim-strips.
- stickers: floating trash handle now glues to the selected
  sticker's top-right corner instead of an off-canvas X that
  testers missed.
- stickers: replace the curated grid with an emoji-keyboard
  picker — recently-used row, custom-sprite row (santa hat as
  inline SVG), then an input that pops the OS emoji keyboard.
  Recents persist in localStorage; legacy stickers fall back to
  the old STICKERS table.
- pwa-install modal: drop "browser chrome" — beta tester read it
  as the literal Chrome browser.
- /setup landing page: tighten "Set up your frame" copy.
This commit is contained in:
2026-05-09 15:17:06 -04:00
parent 00121aaec9
commit 5a0db3cd60
9 changed files with 466 additions and 152 deletions
@@ -0,0 +1,15 @@
import santaHatUrl from './santa-hat.svg?url'
export interface CustomStickerAsset {
id: string
label: string
url: string
}
export const CUSTOM_STICKER_ASSETS: CustomStickerAsset[] = [
{ id: 'santa-hat', label: 'Santa hat', url: santaHatUrl },
]
export function customAssetUrl(id: string): string | undefined {
return CUSTOM_STICKER_ASSETS.find(a => a.id === id)?.url
}
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M 8 52 L 56 52 Q 50 30 42 12 Q 32 22 24 32 Q 14 42 8 52 Z" fill="#cf2030"/>
<path d="M 24 32 Q 14 42 8 52 Q 18 50 28 44 Q 26 38 24 32 Z" fill="#a8141d" opacity="0.55"/>
<rect x="6" y="48" width="52" height="10" rx="5" fill="#ffffff"/>
<ellipse cx="14" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
<ellipse cx="28" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
<ellipse cx="42" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
<ellipse cx="52" cy="53" rx="2" ry="1.4" fill="#ececec"/>
<circle cx="42" cy="12" r="7.5" fill="#ffffff"/>
<circle cx="40" cy="10" r="2" fill="#f4f4f4" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 678 B

+12 -7
View File
@@ -216,13 +216,18 @@ function draw() {
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
// Dark overlay outside crop
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.55)'
ctx.fillRect(0, 0, width, height)
ctx.globalCompositeOperation = 'destination-out'
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
ctx.restore()
// Dim *outside* the crop with 4 strips. The previous destination-out
// trick using a semi-transparent fill made the crop area partially
// transparent (revealing the black container bg) instead of leaving
// it untouched, so the inside ended up *darker* than the outside —
// the inverse of what a crop overlay is meant to do.
const cx2 = cropRect.x + cropRect.w
const cy2 = cropRect.y + cropRect.h
ctx.fillStyle = 'rgba(0,0,0,0.6)'
ctx.fillRect(0, 0, width, cropRect.y) // top
ctx.fillRect(0, cy2, width, height - cy2) // bottom
ctx.fillRect(0, cropRect.y, cropRect.x, cropRect.h) // left
ctx.fillRect(cx2, cropRect.y, width - cx2, cropRect.h) // right
// Crop border + corner marks
ctx.strokeStyle = '#fff'
+136 -24
View File
@@ -10,29 +10,46 @@
<v-image :config="imageConfig" />
</v-layer>
<v-layer ref="stickerLayerRef">
<v-text
v-for="s in stickers"
:key="s.id"
:config="stickerConfig(s)"
<template v-for="s in stickers" :key="s.id">
<v-image
v-if="s.imageAsset"
:config="imageStickerConfig(s)"
@click="selectSticker(s.id, $event)"
@tap="selectSticker(s.id, $event)"
@dragend="onDragEnd(s.id, $event)"
@transformend="onTransformEnd(s.id, $event)"
/>
<v-text
v-else
:config="emojiStickerConfig(s)"
@click="selectSticker(s.id, $event)"
@tap="selectSticker(s.id, $event)"
@dragend="onDragEnd(s.id, $event)"
@transformend="onTransformEnd(s.id, $event)"
/>
</template>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
</v-stage>
<!-- Delete selected sticker button -->
<!-- Floating delete handle follows the selected sticker so the user
doesn't have to hunt for an off-canvas X. Trash icon (not X) reads
as "remove" not "close." -->
<button
v-if="selectedId"
class="sticker-canvas__delete"
v-if="handlePos"
class="sticker-canvas__delete-handle"
type="button"
aria-label="Remove sticker"
@click="removeSelected"
:style="{ left: handlePos.x + 'px', top: handlePos.y + 'px' }"
@pointerdown.stop
@click.stop="removeSelected"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
@@ -109,8 +126,18 @@ onMounted(() => {
onBeforeUnmount(() => {
ro.disconnect()
detachPinchListeners()
detachHandleListeners?.()
})
// Pinch-resize and the parent's stickers prop both bypass Konva's
// drag/transform events, so refresh the handle when the props change.
watch(() => props.stickers, () => {
if (!selectedId.value) return
const layer = stickerLayerRef.value?.getNode()
const node = layer?.findOne(`#${selectedId.value}`)
if (node) refreshHandle(node)
}, { deep: true })
// ── Image ─────────────────────────────────────────────────────────────────────
const bgImage = ref<HTMLImageElement | null>(null)
@@ -121,6 +148,21 @@ function loadImage() {
}
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
// ── Custom-sprite asset preloader ─────────────────────────────────────────────
// Image-based stickers need an HTMLImageElement loaded before Konva can
// paint them; preload all known custom assets up front so the first
// pick renders without a flicker.
const imageAssetCache = ref<Record<string, HTMLImageElement>>({})
onMounted(() => {
for (const a of CUSTOM_STICKER_ASSETS) {
const i = new Image()
i.onload = () => {
imageAssetCache.value = { ...imageAssetCache.value, [a.id]: i }
}
i.src = a.url
}
})
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
const imageConfig = computed(() => ({
image: bgImage.value,
@@ -142,11 +184,12 @@ const transformerConfig = {
// ── Stickers ──────────────────────────────────────────────────────────────────
const EMOJI_FONT_SIZE = 52
const IMAGE_STICKER_BASE_SIZE = 96
function stickerConfig(s: StickerLayer) {
function emojiStickerConfig(s: StickerLayer) {
return {
id: s.id,
text: stickerEmoji(s.type),
text: emojiFor(s),
fontSize: EMOJI_FONT_SIZE,
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
x: s.x,
@@ -160,9 +203,63 @@ function stickerConfig(s: StickerLayer) {
}
}
function imageStickerConfig(s: StickerLayer) {
return {
id: s.id,
image: s.imageAsset ? imageAssetCache.value[s.imageAsset] : null,
x: s.x,
y: s.y,
width: IMAGE_STICKER_BASE_SIZE,
height: IMAGE_STICKER_BASE_SIZE,
scaleX: s.scale,
scaleY: s.scale,
rotation: s.rotation,
draggable: true,
offsetX: IMAGE_STICKER_BASE_SIZE / 2,
offsetY: IMAGE_STICKER_BASE_SIZE / 2,
}
}
import { STICKERS } from '@/assets/stickers/index'
function stickerEmoji(type: string): string {
return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
import { CUSTOM_STICKER_ASSETS, customAssetUrl } from '@/assets/stickers/customAssets'
function emojiFor(s: StickerLayer): string {
// New stickers carry the glyph directly; legacy stickers fall back to
// the curated table by id.
if (s.emoji) return s.emoji
return STICKERS.find(x => x.id === s.type)?.emoji ?? ''
}
// Screen-space position of the delete handle. Tracks the selected
// sticker's transformed bbox so the handle stays glued to its top-right
// corner through drag/resize/rotate.
const handlePos = ref<{ x: number; y: number } | null>(null)
let detachHandleListeners: (() => void) | null = null
function refreshHandle(node: any) {
const box = node.getClientRect()
// Sit just outside the top-right of the sticker; nudge inward a touch
// so it never disappears beyond the stage edge for a corner sticker.
const margin = 4
const half = 14
let x = box.x + box.width + margin - half
let y = box.y - margin - half
// Clamp inside stage so the button is always tappable.
x = Math.max(half, Math.min(stageW.value - half, x))
y = Math.max(half, Math.min(stageH.value - half, y))
handlePos.value = { x, y }
}
function attachHandleTracking(id: string) {
detachHandleListeners?.()
detachHandleListeners = null
const layer = stickerLayerRef.value?.getNode()
const node = layer?.findOne(`#${id}`)
if (!node) { handlePos.value = null; return }
refreshHandle(node)
const update = () => refreshHandle(node)
node.on('dragmove.handle transform.handle transformend.handle dragend.handle', update)
detachHandleListeners = () => node.off('.handle')
}
function selectSticker(id: string, e: any) {
@@ -173,6 +270,7 @@ function selectSticker(id: string, e: any) {
const node = layer?.findOne(`#${id}`)
const tr = transformerRef.value?.getNode()
if (node && tr) tr.nodes([node])
attachHandleTracking(id)
})
}
@@ -180,6 +278,9 @@ function onStageClick(e: any) {
if (e.target === e.target.getStage()) {
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
detachHandleListeners?.()
detachHandleListeners = null
handlePos.value = null
}
}
@@ -188,6 +289,9 @@ function removeSelected() {
emit('remove-sticker', selectedId.value)
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
detachHandleListeners?.()
detachHandleListeners = null
handlePos.value = null
}
function onDragEnd(id: string, e: any) {
@@ -203,10 +307,15 @@ function onTransformEnd(id: string, e: any) {
})
}
function addStickerFromTray(stickerId: string) {
function addStickerFromTray(payload: { emoji?: string; imageAsset?: string }) {
const seed = payload.imageAsset ?? payload.emoji ?? 'sticker'
const s: StickerLayer = {
id: `${stickerId}-${Date.now()}`,
type: stickerId,
id: `${seed}-${Date.now()}`,
// `type` doubles as a coarse kind label so legacy serialization
// keeps a non-empty value (the field is required server-side).
type: payload.imageAsset ? 'image' : 'emoji',
emoji: payload.emoji,
imageAsset: payload.imageAsset,
x: stageW.value / 2,
y: stageH.value / 2,
scale: 1,
@@ -299,21 +408,24 @@ async function done() {
flex-shrink: 0;
}
&__delete {
&__delete-handle {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 36px;
height: 36px;
width: 28px;
height: 28px;
margin: 0;
padding: 0;
border-radius: 50%;
background: rgba(200, 30, 30, 0.85);
border: none;
background: rgba(200, 30, 30, 0.95);
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
z-index: 10;
transform: translate(-50%, -50%);
touch-action: none;
}
&__bar {
+208 -69
View File
@@ -1,115 +1,254 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
<div class="sticker-tray">
<div class="sticker-tray__cats" role="tablist">
<!-- Recently used row surfaces the user's last picks first so
common stickers (a heart, a snowflake) are one tap away. -->
<section v-if="recents.length" class="sticker-tray__section">
<h3 class="sticker-tray__heading">Recent</h3>
<div class="sticker-tray__row">
<button
v-for="cat in STICKER_CATEGORIES"
:key="cat.id"
v-for="r in recents"
:key="r.key"
type="button"
role="tab"
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]"
@click="activeCategory = cat.id"
>{{ cat.label }}</button>
</div>
<div class="sticker-tray__grid" role="tabpanel">
<button
v-for="s in visibleStickers"
:key="s.id"
type="button"
class="sticker-tray__item"
:aria-label="s.label"
@click="$emit('pick', s.id)"
class="sticker-tray__chip"
:aria-label="r.label"
@click="pickRecent(r)"
>
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
<span class="sticker-tray__label">{{ s.label }}</span>
<span v-if="r.kind === 'emoji'" class="sticker-tray__emoji" aria-hidden="true">{{ r.emoji }}</span>
<img v-else :src="r.url" alt="" class="sticker-tray__img" />
</button>
</div>
</section>
<!-- Custom (non-emoji) sprites the user asked for. Renders alongside
recents so they're easy to grab without navigating away. -->
<section class="sticker-tray__section">
<h3 class="sticker-tray__heading">Stickers</h3>
<div class="sticker-tray__row">
<button
v-for="a in CUSTOM_STICKER_ASSETS"
:key="a.id"
type="button"
class="sticker-tray__chip"
:aria-label="a.label"
@click="pickCustom(a)"
>
<img :src="a.url" :alt="a.label" class="sticker-tray__img" />
</button>
</div>
</section>
<!-- Emoji input focusing the field opens the OS emoji keyboard
on iOS/Android. Desktop users can paste or type any emoji.
We capture the *last* grapheme so users can pick several in a
row without the field accumulating. -->
<section class="sticker-tray__section">
<h3 class="sticker-tray__heading">Any emoji</h3>
<p class="sticker-tray__hint">Tap the box, then pick from your keyboard's emoji button.</p>
<input
ref="emojiInputRef"
type="text"
class="sticker-tray__emoji-input"
inputmode="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
placeholder="😀 🎉 🐶 …"
aria-label="Emoji input — use your keyboard's emoji button"
@input="onEmojiInput"
/>
</section>
</div>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, onMounted } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
import type { StickerCategory } from '@/assets/stickers/index'
import { CUSTOM_STICKER_ASSETS, type CustomStickerAsset } from '@/assets/stickers/customAssets'
defineProps<{ modelValue: boolean }>()
defineEmits<{
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'pick', stickerId: string): void
(e: 'pick', payload: { emoji?: string; imageAsset?: string }): void
}>()
const activeCategory = ref<StickerCategory>('seasonal')
interface RecentItem {
key: string
kind: 'emoji' | 'image'
label: string
emoji?: string
imageAsset?: string
url?: string
}
const visibleStickers = computed(() =>
STICKERS.filter(s => s.category === activeCategory.value)
)
const RECENTS_KEY = 'pf.stickerTray.recents'
const MAX_RECENTS = 12
const recents = ref<RecentItem[]>([])
const emojiInputRef = ref<HTMLInputElement>()
onMounted(() => {
recents.value = loadRecents()
})
function loadRecents(): RecentItem[] {
try {
const raw = localStorage.getItem(RECENTS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as RecentItem[]
// Re-hydrate image URLs from the asset registry — assets may move
// across builds, but the id stays stable.
return parsed.map(r => {
if (r.kind === 'image' && r.imageAsset) {
const a = CUSTOM_STICKER_ASSETS.find(x => x.id === r.imageAsset)
return { ...r, url: a?.url, label: a?.label ?? r.label }
}
return r
}).filter(r => r.kind === 'emoji' ? !!r.emoji : !!r.url)
} catch {
return []
}
}
function persistRecents() {
try {
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.value))
} catch { /* quota / private mode — non-fatal */ }
}
function rememberRecent(item: RecentItem) {
recents.value = [item, ...recents.value.filter(r => r.key !== item.key)].slice(0, MAX_RECENTS)
persistRecents()
}
function firstGrapheme(input: string): string | null {
if (!input) return null
// Intl.Segmenter understands ZWJ-joined emoji ("👨👩👧" is one grapheme,
// not five) so multi-codepoint emoji come through whole.
if (typeof Intl !== 'undefined' && (Intl as any).Segmenter) {
const seg = new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' })
const list = [...seg.segment(input)] as Array<{ segment: string }>
return list[list.length - 1]?.segment ?? null
}
// Fallback for older browsers without Intl.Segmenter.
return [...input].pop() ?? null
}
function onEmojiInput(e: Event) {
const el = e.target as HTMLInputElement
const grapheme = firstGrapheme(el.value)
el.value = ''
if (!grapheme) return
const item: RecentItem = {
key: `emoji:${grapheme}`,
kind: 'emoji',
label: grapheme,
emoji: grapheme,
}
rememberRecent(item)
emit('pick', { emoji: grapheme })
}
function pickCustom(a: CustomStickerAsset) {
rememberRecent({
key: `image:${a.id}`,
kind: 'image',
label: a.label,
imageAsset: a.id,
url: a.url,
})
emit('pick', { imageAsset: a.id })
}
function pickRecent(r: RecentItem) {
rememberRecent(r)
if (r.kind === 'emoji' && r.emoji) {
emit('pick', { emoji: r.emoji })
} else if (r.kind === 'image' && r.imageAsset) {
emit('pick', { imageAsset: r.imageAsset })
}
}
</script>
<style scoped lang="scss">
.sticker-tray {
&__cats {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-3);
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&__section {
margin-bottom: var(--space-4);
&:last-child { margin-bottom: 0; }
}
&__cat {
padding: 6px 14px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-sm);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
background: transparent;
&__heading {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-fg);
}
margin: 0 0 var(--space-2);
}
&__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
&__hint {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0 0 var(--space-2);
line-height: 1.4;
}
&__row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
&__item {
&__chip {
width: 56px;
height: 56px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--space-2) var(--space-1);
border-radius: var(--radius-sm);
border: none;
background: transparent;
justify-content: center;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
transition: background var(--duration-fast);
transition: background var(--duration-fast), border-color var(--duration-fast);
&:active { background: var(--color-surface-2); }
&:active {
background: var(--color-surface-2);
border-color: var(--color-primary);
}
}
&__emoji {
font-size: 36px;
font-size: 32px;
line-height: 1;
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
}
&__label {
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
text-align: center;
line-height: 1.2;
&__img {
width: 40px;
height: 40px;
object-fit: contain;
pointer-events: none;
}
&__emoji-input {
width: 100%;
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
// 16px+ avoids iOS Safari's auto-zoom-on-focus.
font-size: 18px;
font-family: inherit;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
}
}
}
</style>
@@ -1,9 +1,8 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import StickerTray from '@/components/StickerTray.vue'
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
import { CUSTOM_STICKER_ASSETS } from '@/assets/stickers/customAssets'
// Stub the bottom sheet so the modal contents are always rendered inline
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
@@ -13,36 +12,63 @@ vi.mock('@/components/BaseBottomSheet.vue', () => ({
},
}))
describe('StickerTray', () => {
it('renders one tab per sticker category', () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
expect(wrapper.findAll('.sticker-tray__cat')).toHaveLength(STICKER_CATEGORIES.length)
beforeEach(() => {
localStorage.clear()
})
it('starts on the seasonal category', () => {
describe('StickerTray (emoji-keyboard picker)', () => {
it('renders the custom-sticker row', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const seasonal = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Seasonal')
expect(seasonal?.classes()).toContain('sticker-tray__cat--active')
const seasonalCount = STICKERS.filter(s => s.category === 'seasonal').length
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(seasonalCount)
await flushPromises()
const chips = wrapper.findAll('.sticker-tray__chip')
expect(chips.length).toBeGreaterThanOrEqual(CUSTOM_STICKER_ASSETS.length)
})
it('switches the visible grid when a different category tab is clicked', async () => {
it('emits pick with imageAsset when a custom sticker is tapped', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const fun = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Fun')!
await fun.trigger('click')
const funCount = STICKERS.filter(s => s.category === 'fun').length
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(funCount)
expect(fun.classes()).toContain('sticker-tray__cat--active')
})
it('emits "pick" with the sticker id when an item is clicked', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const firstItem = wrapper.find('.sticker-tray__item')
await firstItem.trigger('click')
await flushPromises()
const santa = wrapper.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === 'Santa hat')!
expect(santa).toBeTruthy()
await santa.trigger('click')
const events = wrapper.emitted('pick')
expect(events).toBeTruthy()
expect(typeof events![0][0]).toBe('string')
expect(events![0][0]).toEqual({ imageAsset: 'santa-hat' })
})
it('emits pick with the typed emoji when the input fires', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const input = wrapper.find('.sticker-tray__emoji-input')
expect(input.exists()).toBe(true)
;(input.element as HTMLInputElement).value = '🎉'
await input.trigger('input')
const events = wrapper.emitted('pick')
expect(events).toBeTruthy()
expect(events![0][0]).toEqual({ emoji: '🎉' })
})
it('clears the input after each emoji pick so the next pick is independent', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const input = wrapper.find('.sticker-tray__emoji-input').element as HTMLInputElement
input.value = '😀'
await wrapper.find('.sticker-tray__emoji-input').trigger('input')
expect(input.value).toBe('')
})
it('persists picks to a Recent row across mounts', async () => {
const first = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const input = first.find('.sticker-tray__emoji-input')
;(input.element as HTMLInputElement).value = '🌈'
await input.trigger('input')
const second = mount(StickerTray, { props: { modelValue: true } })
await flushPromises()
const headings = second.findAll('.sticker-tray__heading').map(h => h.text())
expect(headings).toContain('Recent')
const recentChip = second.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === '🌈')
expect(recentChip).toBeTruthy()
})
it('forwards update:modelValue from the wrapped sheet', async () => {
+6
View File
@@ -40,6 +40,12 @@ export interface StickerLayer {
y: number
scale: number
rotation: number
/** New stickers carry the emoji glyph directly; legacy stickers leave this
* blank and the renderer falls back to the STICKERS lookup by `type`. */
emoji?: string
/** Set for image-based stickers (e.g. 'santa-hat') that aren't representable
* as a single Unicode emoji. Mutually exclusive with `emoji`. */
imageAsset?: string
}
export interface Image {
+2 -2
View File
@@ -94,8 +94,8 @@
</li>
</ol>
<p class="install-modal__footer">
The app will appear on your home screen and open without
browser chrome the next time you launch it.
The app will appear on your home screen. Open it from there
and it runs like a regular app no address bar, no tabs.
</p>
</div>
</div>
+2 -2
View File
@@ -38,8 +38,8 @@
</head>
<body>
<div class="card">
<h1>Set up your frame</h1>
<p class="subtitle">Create an account or sign in to link this frame.</p>
<h1>Link this frame</h1>
<p class="subtitle">Create an account, or sign in if you already have one. The frame will link to whichever account you use here.</p>
{% if already_claimed %}
<p class="claim-banner" role="status">