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.clearRect(0, 0, width, height)
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH) ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
// Dark overlay outside crop // Dim *outside* the crop with 4 strips. The previous destination-out
ctx.save() // trick using a semi-transparent fill made the crop area partially
ctx.fillStyle = 'rgba(0,0,0,0.55)' // transparent (revealing the black container bg) instead of leaving
ctx.fillRect(0, 0, width, height) // it untouched, so the inside ended up *darker* than the outside —
ctx.globalCompositeOperation = 'destination-out' // the inverse of what a crop overlay is meant to do.
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h) const cx2 = cropRect.x + cropRect.w
ctx.restore() 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 // Crop border + corner marks
ctx.strokeStyle = '#fff' ctx.strokeStyle = '#fff'
+145 -33
View File
@@ -10,29 +10,46 @@
<v-image :config="imageConfig" /> <v-image :config="imageConfig" />
</v-layer> </v-layer>
<v-layer ref="stickerLayerRef"> <v-layer ref="stickerLayerRef">
<v-text <template v-for="s in stickers" :key="s.id">
v-for="s in stickers" <v-image
:key="s.id" v-if="s.imageAsset"
:config="stickerConfig(s)" :config="imageStickerConfig(s)"
@click="selectSticker(s.id, $event)" @click="selectSticker(s.id, $event)"
@tap="selectSticker(s.id, $event)" @tap="selectSticker(s.id, $event)"
@dragend="onDragEnd(s.id, $event)" @dragend="onDragEnd(s.id, $event)"
@transformend="onTransformEnd(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-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer> </v-layer>
</v-stage> </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 <button
v-if="selectedId" v-if="handlePos"
class="sticker-canvas__delete" class="sticker-canvas__delete-handle"
type="button" type="button"
aria-label="Remove sticker" 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"> <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">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <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> </svg>
</button> </button>
@@ -109,8 +126,18 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
ro.disconnect() ro.disconnect()
detachPinchListeners() 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 ───────────────────────────────────────────────────────────────────── // ── Image ─────────────────────────────────────────────────────────────────────
const bgImage = ref<HTMLImageElement | null>(null) const bgImage = ref<HTMLImageElement | null>(null)
@@ -121,6 +148,21 @@ function loadImage() {
} }
watch(() => props.croppedUrl, () => loadImage(), { immediate: true }) 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 stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
const imageConfig = computed(() => ({ const imageConfig = computed(() => ({
image: bgImage.value, image: bgImage.value,
@@ -142,11 +184,12 @@ const transformerConfig = {
// ── Stickers ────────────────────────────────────────────────────────────────── // ── Stickers ──────────────────────────────────────────────────────────────────
const EMOJI_FONT_SIZE = 52 const EMOJI_FONT_SIZE = 52
const IMAGE_STICKER_BASE_SIZE = 96
function stickerConfig(s: StickerLayer) { function emojiStickerConfig(s: StickerLayer) {
return { return {
id: s.id, id: s.id,
text: stickerEmoji(s.type), text: emojiFor(s),
fontSize: EMOJI_FONT_SIZE, fontSize: EMOJI_FONT_SIZE,
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif', fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
x: s.x, 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' import { STICKERS } from '@/assets/stickers/index'
function stickerEmoji(type: string): string { import { CUSTOM_STICKER_ASSETS, customAssetUrl } from '@/assets/stickers/customAssets'
return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
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) { function selectSticker(id: string, e: any) {
@@ -173,6 +270,7 @@ function selectSticker(id: string, e: any) {
const node = layer?.findOne(`#${id}`) const node = layer?.findOne(`#${id}`)
const tr = transformerRef.value?.getNode() const tr = transformerRef.value?.getNode()
if (node && tr) tr.nodes([node]) if (node && tr) tr.nodes([node])
attachHandleTracking(id)
}) })
} }
@@ -180,6 +278,9 @@ function onStageClick(e: any) {
if (e.target === e.target.getStage()) { if (e.target === e.target.getStage()) {
selectedId.value = null selectedId.value = null
transformerRef.value?.getNode()?.nodes([]) transformerRef.value?.getNode()?.nodes([])
detachHandleListeners?.()
detachHandleListeners = null
handlePos.value = null
} }
} }
@@ -188,6 +289,9 @@ function removeSelected() {
emit('remove-sticker', selectedId.value) emit('remove-sticker', selectedId.value)
selectedId.value = null selectedId.value = null
transformerRef.value?.getNode()?.nodes([]) transformerRef.value?.getNode()?.nodes([])
detachHandleListeners?.()
detachHandleListeners = null
handlePos.value = null
} }
function onDragEnd(id: string, e: any) { function onDragEnd(id: string, e: any) {
@@ -203,14 +307,19 @@ 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 = { const s: StickerLayer = {
id: `${stickerId}-${Date.now()}`, id: `${seed}-${Date.now()}`,
type: stickerId, // `type` doubles as a coarse kind label so legacy serialization
x: stageW.value / 2, // keeps a non-empty value (the field is required server-side).
y: stageH.value / 2, type: payload.imageAsset ? 'image' : 'emoji',
scale: 1, emoji: payload.emoji,
rotation: 0, imageAsset: payload.imageAsset,
x: stageW.value / 2,
y: stageH.value / 2,
scale: 1,
rotation: 0,
} }
emit('add-sticker', s) emit('add-sticker', s)
trayOpen.value = false trayOpen.value = false
@@ -299,21 +408,24 @@ async function done() {
flex-shrink: 0; flex-shrink: 0;
} }
&__delete { &__delete-handle {
position: absolute; position: absolute;
top: var(--space-3); width: 28px;
right: var(--space-3); height: 28px;
width: 36px; margin: 0;
height: 36px; padding: 0;
border-radius: 50%; border-radius: 50%;
background: rgba(200, 30, 30, 0.85); background: rgba(200, 30, 30, 0.95);
border: none; border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: #fff;
z-index: 10; z-index: 10;
transform: translate(-50%, -50%);
touch-action: none;
} }
&__bar { &__bar {
+219 -80
View File
@@ -1,115 +1,254 @@
<template> <template>
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)"> <BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
<div class="sticker-tray"> <div class="sticker-tray">
<div class="sticker-tray__cats" role="tablist"> <!-- Recently used row surfaces the user's last picks first so
<button common stickers (a heart, a snowflake) are one tap away. -->
v-for="cat in STICKER_CATEGORIES" <section v-if="recents.length" class="sticker-tray__section">
:key="cat.id" <h3 class="sticker-tray__heading">Recent</h3>
type="button" <div class="sticker-tray__row">
role="tab" <button
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]" v-for="r in recents"
@click="activeCategory = cat.id" :key="r.key"
>{{ cat.label }}</button> type="button"
</div> class="sticker-tray__chip"
<div class="sticker-tray__grid" role="tabpanel"> :aria-label="r.label"
<button @click="pickRecent(r)"
v-for="s in visibleStickers" >
:key="s.id" <span v-if="r.kind === 'emoji'" class="sticker-tray__emoji" aria-hidden="true">{{ r.emoji }}</span>
type="button" <img v-else :src="r.url" alt="" class="sticker-tray__img" />
class="sticker-tray__item" </button>
:aria-label="s.label" </div>
@click="$emit('pick', s.id)" </section>
>
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span> <!-- Custom (non-emoji) sprites the user asked for. Renders alongside
<span class="sticker-tray__label">{{ s.label }}</span> recents so they're easy to grab without navigating away. -->
</button> <section class="sticker-tray__section">
</div> <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> </div>
</BaseBottomSheet> </BaseBottomSheet>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, onMounted } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue' import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index' import { CUSTOM_STICKER_ASSETS, type CustomStickerAsset } from '@/assets/stickers/customAssets'
import type { StickerCategory } from '@/assets/stickers/index'
defineProps<{ modelValue: boolean }>() defineProps<{ modelValue: boolean }>()
defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void (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(() => const RECENTS_KEY = 'pf.stickerTray.recents'
STICKERS.filter(s => s.category === activeCategory.value) 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.sticker-tray { .sticker-tray {
&__cats { &__section {
display: flex; margin-bottom: var(--space-4);
gap: var(--space-2); &:last-child { margin-bottom: 0; }
overflow-x: auto;
padding-bottom: var(--space-3);
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
} }
&__cat { &__heading {
padding: 6px 14px; font-size: var(--text-xs);
border-radius: 999px; font-weight: 700;
border: 1.5px solid var(--color-border); text-transform: uppercase;
font-size: var(--text-sm); letter-spacing: 0.06em;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
transition: all var(--duration-fast); margin: 0 0 var(--space-2);
}
&--active { &__hint {
background: var(--color-primary); 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);
}
&__chip {
width: 56px;
height: 56px;
display: flex;
align-items: center;
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), border-color var(--duration-fast);
&:active {
background: var(--color-surface-2);
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary-fg);
} }
} }
&__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-2);
}
&__item {
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;
cursor: pointer;
transition: background var(--duration-fast);
&:active { background: var(--color-surface-2); }
}
&__emoji { &__emoji {
font-size: 36px; font-size: 32px;
line-height: 1; line-height: 1;
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif; font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
} }
&__label { &__img {
font-size: 10px; width: 40px;
font-weight: 600; height: 40px;
color: var(--color-text-muted); object-fit: contain;
text-align: center; pointer-events: none;
line-height: 1.2; }
&__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> </style>
@@ -1,9 +1,8 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import StickerTray from '@/components/StickerTray.vue' 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', () => ({ vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: { default: {
name: 'BaseBottomSheet', name: 'BaseBottomSheet',
@@ -13,36 +12,63 @@ vi.mock('@/components/BaseBottomSheet.vue', () => ({
}, },
})) }))
describe('StickerTray', () => { beforeEach(() => {
it('renders one tab per sticker category', () => { localStorage.clear()
})
describe('StickerTray (emoji-keyboard picker)', () => {
it('renders the custom-sticker row', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } }) const wrapper = mount(StickerTray, { props: { modelValue: true } })
expect(wrapper.findAll('.sticker-tray__cat')).toHaveLength(STICKER_CATEGORIES.length) await flushPromises()
const chips = wrapper.findAll('.sticker-tray__chip')
expect(chips.length).toBeGreaterThanOrEqual(CUSTOM_STICKER_ASSETS.length)
}) })
it('starts on the seasonal category', () => { it('emits pick with imageAsset when a custom sticker is tapped', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } }) const wrapper = mount(StickerTray, { props: { modelValue: true } })
const seasonal = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Seasonal') await flushPromises()
expect(seasonal?.classes()).toContain('sticker-tray__cat--active') const santa = wrapper.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === 'Santa hat')!
const seasonalCount = STICKERS.filter(s => s.category === 'seasonal').length expect(santa).toBeTruthy()
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(seasonalCount) await santa.trigger('click')
})
it('switches the visible grid when a different category tab is clicked', 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')
const events = wrapper.emitted('pick') const events = wrapper.emitted('pick')
expect(events).toBeTruthy() 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 () => { it('forwards update:modelValue from the wrapped sheet', async () => {
+6
View File
@@ -40,6 +40,12 @@ export interface StickerLayer {
y: number y: number
scale: number scale: number
rotation: 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 { export interface Image {
+2 -2
View File
@@ -94,8 +94,8 @@
</li> </li>
</ol> </ol>
<p class="install-modal__footer"> <p class="install-modal__footer">
The app will appear on your home screen and open without The app will appear on your home screen. Open it from there
browser chrome the next time you launch it. and it runs like a regular app no address bar, no tabs.
</p> </p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -38,8 +38,8 @@
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<h1>Set up your frame</h1> <h1>Link this frame</h1>
<p class="subtitle">Create an account or sign in to link this frame.</p> <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 %} {% if already_claimed %}
<p class="claim-banner" role="status"> <p class="claim-banner" role="status">