Compare commits

..

2 Commits

Author SHA1 Message Date
football2801 bdb717de2e chore(build): drop unused imports + rebuild bundle
CI / test (push) Has been cancelled
vue-tsc -b is stricter than --noEmit; the StickerTray emoji input
ref and the StickerCanvas customAssetUrl import were unused.
2026-05-09 15:18:29 -04:00
football2801 5a0db3cd60 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.
2026-05-09 15:17:06 -04:00
27 changed files with 480 additions and 168 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'
+145 -33
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)"
@click="selectSticker(s.id, $event)"
@tap="selectSticker(s.id, $event)"
@dragend="onDragEnd(s.id, $event)"
@transformend="onTransformEnd(s.id, $event)"
/>
<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 } 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,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 = {
id: `${stickerId}-${Date.now()}`,
type: stickerId,
x: stageW.value / 2,
y: stageH.value / 2,
scale: 1,
rotation: 0,
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,
rotation: 0,
}
emit('add-sticker', s)
trayOpen.value = false
@@ -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 {
+217 -80
View File
@@ -1,115 +1,252 @@
<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">
<button
v-for="cat in STICKER_CATEGORIES"
:key="cat.id"
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)"
>
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
<span class="sticker-tray__label">{{ s.label }}</span>
</button>
</div>
<!-- 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="r in recents"
:key="r.key"
type="button"
class="sticker-tray__chip"
:aria-label="r.label"
@click="pickRecent(r)"
>
<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
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[]>([])
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);
margin: 0 0 var(--space-2);
}
&--active {
background: var(--color-primary);
&__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);
}
&__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);
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 {
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', () => {
beforeEach(() => {
localStorage.clear()
})
describe('StickerTray (emoji-keyboard picker)', () => {
it('renders the custom-sticker row', async () => {
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 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)
})
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')
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>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{B as e,C as t,F as n,N as r,S as i,Y as a,_ as o,g as s,h as c,p as l,t as u,x as d,y as f}from"./_plugin-vue_export-helper-BNDVmFr7.js";import{n as p,t as m}from"./BaseBottomSheet-Bsol3Sat.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=u(t({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(t,{emit:u}){let y=t,b=u;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=c(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(c,u)=>(r(),o(m,{"model-value":t.modelValue,label:`Choose frames`,"onUpdate:modelValue":u[1]||=e=>c.$emit(`update:modelValue`,e)},{default:e(()=>[u[2]||=s(`h2`,{class:`device-picker__title`},`Add to frames`,-1),u[3]||=s(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),s(`div`,h,[(r(!0),f(l,null,n(t.devices,e=>(r(),f(`label`,{key:e.id,class:`device-picker__row`},[s(`input`,{type:`checkbox`,class:`device-picker__check`,checked:t.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),s(`span`,_,a(e.name),1),s(`span`,v,a(e.orientation),1)]))),128))]),i(p,{variant:`primary`,class:`device-picker__confirm`,disabled:t.selected.length===0||t.uploading,onClick:u[0]||=e=>c.$emit(`confirm`)},{default:e(()=>[d(a(t.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
@@ -1 +0,0 @@
import{J as e,M as t,P as n,S as r,_ as i,b as a,g as o,h as s,p as c,t as l,x as u,y as d,z as f}from"./_plugin-vue_export-helper-eepT72yB.js";import{n as p,t as m}from"./BaseBottomSheet-BMI-Oljh.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:l}){let y=r,b=l;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=s(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(s,l)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":l[1]||=e=>s.$emit(`update:modelValue`,e)},{default:f(()=>[l[2]||=o(`h2`,{class:`device-picker__title`},`Add to frames`,-1),l[3]||=o(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),o(`div`,h,[(t(!0),d(c,null,n(r.devices,n=>(t(),d(`label`,{key:n.id,class:`device-picker__row`},[o(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(n.id),onChange:e=>x(n.id)},null,40,g),o(`span`,_,e(n.name),1),o(`span`,v,e(n.orientation),1)]))),128))]),u(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:l[0]||=e=>s.$emit(`confirm`)},{default:f(()=>[a(e(r.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{F as e,H as t,K as n,M as r,S as i,g as a,h as o,q as s,t as c,y as l}from"./_plugin-vue_export-helper-eepT72yB.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=c(i({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(i){let c=i,d=t(0),f=t(!1),p=0,m=0,h=!1,g=null,_=o(()=>Math.min(d.value/c.threshold,1)),v=o(()=>f.value?1:Math.min(d.value/c.threshold,1)),y=o(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||c.isAtTop&&!c.isAtTop()||e.touches.length===1&&(p=e.touches[0].clientY,m=e.touches[0].clientX,h=!0,g=null)}function x(e){if(!h)return;let t=e.touches[0].clientX-m,n=e.touches[0].clientY-p;if(g===null){if(Math.abs(t)<6&&Math.abs(n)<6)return;g=Math.abs(t)>Math.abs(n)?`x`:`y`}if(g===`x`||n<=0){h=!1,d.value=0;return}if(c.isAtTop&&!c.isAtTop()){h=!1,d.value=0;return}let r=n<c.maxPull?n*.5:c.maxPull*.5+(n-c.maxPull)*.1;d.value=Math.min(r,c.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=c.threshold){f.value=!0,d.value=c.threshold*.7;try{await c.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(t,i)=>(r(),l(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[a(`div`,{class:n([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:s({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(r(),l(`div`,u)):(r(),l(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:s({transform:`rotate(${_.value*180}deg)`})},[...i[0]||=[a(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),a(`polyline`,{points:`6 13 12 19 18 13`,stroke:`currentColor`,"stroke-width":`2.5`,fill:`none`,"stroke-linecap":`round`,"stroke-linejoin":`round`},null,-1)]],4))],6),a(`div`,{class:`ptr__content`,style:s(y.value)},[e(t.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
import{C as e,I as t,J as n,N as r,U as i,g as a,h as o,q as s,t as c,y as l}from"./_plugin-vue_export-helper-BNDVmFr7.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=c(e({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(e){let c=e,d=i(0),f=i(!1),p=0,m=0,h=!1,g=null,_=o(()=>Math.min(d.value/c.threshold,1)),v=o(()=>f.value?1:Math.min(d.value/c.threshold,1)),y=o(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||c.isAtTop&&!c.isAtTop()||e.touches.length===1&&(p=e.touches[0].clientY,m=e.touches[0].clientX,h=!0,g=null)}function x(e){if(!h)return;let t=e.touches[0].clientX-m,n=e.touches[0].clientY-p;if(g===null){if(Math.abs(t)<6&&Math.abs(n)<6)return;g=Math.abs(t)>Math.abs(n)?`x`:`y`}if(g===`x`||n<=0){h=!1,d.value=0;return}if(c.isAtTop&&!c.isAtTop()){h=!1,d.value=0;return}let r=n<c.maxPull?n*.5:c.maxPull*.5+(n-c.maxPull)*.1;d.value=Math.min(r,c.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=c.threshold){f.value=!0,d.value=c.threshold*.7;try{await c.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(e,i)=>(r(),l(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[a(`div`,{class:s([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:n({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(r(),l(`div`,u)):(r(),l(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:n({transform:`rotate(${_.value*180}deg)`})},[...i[0]||=[a(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),a(`polyline`,{points:`6 13 12 19 18 13`,stroke:`currentColor`,"stroke-width":`2.5`,fill:`none`,"stroke-linecap":`round`,"stroke-linejoin":`round`},null,-1)]],4))],6),a(`div`,{class:`ptr__content`,style:n(y.value)},[t(e.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
@@ -1 +0,0 @@
import{G as e,H as t,J as n,K as r,M as i,P as a,S as o,b as s,f as c,g as l,h as u,p as d,q as f,t as p,v as m,y as h}from"./_plugin-vue_export-helper-eepT72yB.js";import{n as g,r as _,t as v}from"./index-BO5caB_f.js";var y=t(null),b=t(!1);function x(){return typeof window>`u`?!1:window.matchMedia?.(`(display-mode: standalone)`).matches?!0:window.navigator.standalone===!0}function S(){if(typeof navigator>`u`)return!1;let e=navigator.userAgent,t=e.includes(`Mac`)&&navigator.maxTouchPoints>1;return/iPhone|iPod/.test(e)||t}var C=!1;function w(){C||typeof window>`u`||(C=!0,b.value=x(),window.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),y.value=e}),window.addEventListener(`appinstalled`,()=>{y.value=null,b.value=!0}),window.matchMedia?.(`(display-mode: standalone)`).addEventListener(`change`,e=>{b.value=e.matches}))}w();function T(){let e=S(),t=u(()=>y.value!==null);async function n(){let e=y.value;if(!e)return!1;await e.prompt();let t=await e.userChoice;return y.value=null,t.outcome===`accepted`}return{isStandalone:b,isIOS:e,canPromptInstall:t,install:n}}var E={class:`settings`},D={key:0,class:`settings__section`},O={class:`settings__section`},k={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},A=[`aria-checked`,`aria-label`,`onClick`],j={class:`theme-swatch__label`},M={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},N={class:`settings__section`},P={class:`settings__row`},F={class:`settings__row-value`},I={class:`install-modal__card`},L={id:`install-modal-title`,class:`install-modal__title`},R={class:`install-modal__steps`},z={key:0},B={key:1},V={key:0},H=p(o({__name:`SettingsView`,setup(o){let p=_(),{saveTheme:y}=g(),{isStandalone:b,isIOS:x,canPromptInstall:S,install:C}=T(),w=u(()=>p.user?.theme??`warm-craft`),H=t(!1);function U(e){y(e)}async function W(){!await C()&&!S.value&&(H.value=!0)}return(t,o)=>(i(),h(`main`,E,[o[18]||=l(`h1`,{class:`settings__title`},`Settings`,-1),e(b)?m(``,!0):(i(),h(`section`,D,[o[3]||=l(`h2`,{class:`settings__section-title`},`Install app`,-1),o[4]||=l(`p`,{class:`settings__hint`},` Pin pictureFrame to your home screen so it opens like a native app. `,-1),e(S)?(i(),h(`button`,{key:0,type:`button`,class:`settings__install`,onClick:W},` Install pictureFrame `)):(i(),h(`button`,{key:1,type:`button`,class:`settings__install`,onClick:o[0]||=e=>H.value=!0},` Add to Home Screen `))])),l(`section`,O,[o[6]||=l(`h2`,{class:`settings__section-title`},`Theme`,-1),l(`div`,k,[(i(!0),h(d,null,a(e(v),e=>(i(),h(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":w.value===e.id,"aria-label":e.label,class:r([`theme-swatch`,{"theme-swatch--active":w.value===e.id}]),style:f({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>U(e.id)},[o[5]||=l(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[l(`span`,{class:`theme-swatch__bar`}),l(`span`,{class:`theme-swatch__dot`})],-1),l(`span`,j,n(e.label),1),w.value===e.id?(i(),h(`span`,M,``)):m(``,!0)],14,A))),128))])]),l(`section`,N,[o[8]||=l(`h2`,{class:`settings__section-title`},`Account`,-1),l(`div`,P,[o[7]||=l(`span`,{class:`settings__row-label`},`Signed in as`,-1),l(`span`,F,n(e(p).user?.email),1)]),o[9]||=l(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)]),H.value?(i(),h(`div`,{key:1,class:`install-modal`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`install-modal-title`,onClick:o[2]||=c(e=>H.value=!1,[`self`])},[l(`div`,I,[l(`button`,{type:`button`,class:`install-modal__close`,"aria-label":`Close`,onClick:o[1]||=e=>H.value=!1},`×`),l(`h2`,L,n(e(x)?`Add to your iPhone home screen`:`Add to your home screen`),1),l(`ol`,R,[e(x)?(i(),h(`li`,z,[...o[10]||=[s(` Tap the `,-1),l(`strong`,null,`Share`,-1),s(` icon at the bottom of Safari (the square with an up-arrow). `,-1)]])):(i(),h(`li`,B,[...o[11]||=[s(` Open your browser's menu (usually the three dots `,-1),l(`strong`,null,``,-1),s(` in the top right). `,-1)]])),l(`li`,null,[o[13]||=s(` Scroll down and tap `,-1),o[14]||=l(`strong`,null,`Add to Home Screen`,-1),e(x)?m(``,!0):(i(),h(`span`,V,[...o[12]||=[s(`or `,-1),l(`strong`,null,`Install app`,-1)]])),o[15]||=s(`. `,-1)]),o[16]||=l(`li`,null,[s(` Tap `),l(`strong`,null,`Add`),s(` in the top right to confirm. `)],-1)]),o[17]||=l(`p`,{class:`install-modal__footer`},` The app will appear on your home screen and open without browser chrome the next time you launch it. `,-1)])])):m(``,!0)]))}}),[[`__scopeId`,`data-v-6f8a8b72`]]);export{H as default};
@@ -0,0 +1 @@
import{C as e,F as t,J as n,K as r,N as i,U as a,Y as o,f as s,g as c,h as l,p as u,q as d,t as f,v as p,x as m,y as h}from"./_plugin-vue_export-helper-BNDVmFr7.js";import{n as g,r as _,t as v}from"./index-DdJ5jHP4.js";var y=a(null),b=a(!1);function x(){return typeof window>`u`?!1:window.matchMedia?.(`(display-mode: standalone)`).matches?!0:window.navigator.standalone===!0}function S(){if(typeof navigator>`u`)return!1;let e=navigator.userAgent,t=e.includes(`Mac`)&&navigator.maxTouchPoints>1;return/iPhone|iPod/.test(e)||t}var C=!1;function w(){C||typeof window>`u`||(C=!0,b.value=x(),window.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),y.value=e}),window.addEventListener(`appinstalled`,()=>{y.value=null,b.value=!0}),window.matchMedia?.(`(display-mode: standalone)`).addEventListener(`change`,e=>{b.value=e.matches}))}w();function T(){let e=S(),t=l(()=>y.value!==null);async function n(){let e=y.value;if(!e)return!1;await e.prompt();let t=await e.userChoice;return y.value=null,t.outcome===`accepted`}return{isStandalone:b,isIOS:e,canPromptInstall:t,install:n}}var E={class:`settings`},D={key:0,class:`settings__section`},O={class:`settings__section`},k={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},A=[`aria-checked`,`aria-label`,`onClick`],j={class:`theme-swatch__label`},M={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},N={class:`settings__section`},P={class:`settings__row`},F={class:`settings__row-value`},I={class:`install-modal__card`},L={id:`install-modal-title`,class:`install-modal__title`},R={class:`install-modal__steps`},z={key:0},B={key:1},V={key:0},H=f(e({__name:`SettingsView`,setup(e){let f=_(),{saveTheme:y}=g(),{isStandalone:b,isIOS:x,canPromptInstall:S,install:C}=T(),w=l(()=>f.user?.theme??`warm-craft`),H=a(!1);function U(e){y(e)}async function W(){!await C()&&!S.value&&(H.value=!0)}return(e,a)=>(i(),h(`main`,E,[a[18]||=c(`h1`,{class:`settings__title`},`Settings`,-1),r(b)?p(``,!0):(i(),h(`section`,D,[a[3]||=c(`h2`,{class:`settings__section-title`},`Install app`,-1),a[4]||=c(`p`,{class:`settings__hint`},` Pin pictureFrame to your home screen so it opens like a native app. `,-1),r(S)?(i(),h(`button`,{key:0,type:`button`,class:`settings__install`,onClick:W},` Install pictureFrame `)):(i(),h(`button`,{key:1,type:`button`,class:`settings__install`,onClick:a[0]||=e=>H.value=!0},` Add to Home Screen `))])),c(`section`,O,[a[6]||=c(`h2`,{class:`settings__section-title`},`Theme`,-1),c(`div`,k,[(i(!0),h(u,null,t(r(v),e=>(i(),h(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":w.value===e.id,"aria-label":e.label,class:d([`theme-swatch`,{"theme-swatch--active":w.value===e.id}]),style:n({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>U(e.id)},[a[5]||=c(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[c(`span`,{class:`theme-swatch__bar`}),c(`span`,{class:`theme-swatch__dot`})],-1),c(`span`,j,o(e.label),1),w.value===e.id?(i(),h(`span`,M,``)):p(``,!0)],14,A))),128))])]),c(`section`,N,[a[8]||=c(`h2`,{class:`settings__section-title`},`Account`,-1),c(`div`,P,[a[7]||=c(`span`,{class:`settings__row-label`},`Signed in as`,-1),c(`span`,F,o(r(f).user?.email),1)]),a[9]||=c(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)]),H.value?(i(),h(`div`,{key:1,class:`install-modal`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`install-modal-title`,onClick:a[2]||=s(e=>H.value=!1,[`self`])},[c(`div`,I,[c(`button`,{type:`button`,class:`install-modal__close`,"aria-label":`Close`,onClick:a[1]||=e=>H.value=!1},`×`),c(`h2`,L,o(r(x)?`Add to your iPhone home screen`:`Add to your home screen`),1),c(`ol`,R,[r(x)?(i(),h(`li`,z,[...a[10]||=[m(` Tap the `,-1),c(`strong`,null,`Share`,-1),m(` icon at the bottom of Safari (the square with an up-arrow). `,-1)]])):(i(),h(`li`,B,[...a[11]||=[m(` Open your browser's menu (usually the three dots `,-1),c(`strong`,null,``,-1),m(` in the top right). `,-1)]])),c(`li`,null,[a[13]||=m(` Scroll down and tap `,-1),a[14]||=c(`strong`,null,`Add to Home Screen`,-1),r(x)?p(``,!0):(i(),h(`span`,V,[...a[12]||=[m(`or `,-1),c(`strong`,null,`Install app`,-1)]])),a[15]||=m(`. `,-1)]),a[16]||=c(`li`,null,[m(` Tap `),c(`strong`,null,`Add`),m(` in the top right to confirm. `)],-1)]),a[17]||=c(`p`,{class:`install-modal__footer`},` The app will appear on your home screen. Open it from there and it runs like a regular app — no address bar, no tabs. `,-1)])])):p(``,!0)]))}}),[[`__scopeId`,`data-v-fb5d8496`]]);export{H as default};
@@ -1 +1 @@
.settings[data-v-6f8a8b72]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-6f8a8b72]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-6f8a8b72]{margin-bottom:var(--space-6)}.settings__section-title[data-v-6f8a8b72]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-6f8a8b72]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-6f8a8b72]{color:var(--color-text-muted)}.settings__row-value[data-v-6f8a8b72]{font-weight:600}.settings__logout[data-v-6f8a8b72]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.settings__hint[data-v-6f8a8b72]{color:var(--color-text-muted);font-size:var(--text-sm);margin-bottom:var(--space-3);line-height:1.4}.settings__install[data-v-6f8a8b72]{width:100%;min-height:var(--touch-min);background:var(--color-primary);color:var(--color-on-primary);border-radius:var(--radius-pill,9999px);font-size:var(--text-base);cursor:pointer;border:none;justify-content:center;align-items:center;font-weight:700;display:flex}.install-modal[data-v-6f8a8b72]{padding:var(--space-4);z-index:100;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.install-modal__card[data-v-6f8a8b72]{background:var(--color-surface);color:var(--color-text);border-radius:var(--radius-lg,16px);padding:var(--space-5);width:100%;max-width:380px;position:relative;box-shadow:0 20px 60px #00000040}.install-modal__close[data-v-6f8a8b72]{top:var(--space-2);right:var(--space-3);color:var(--color-text-muted);cursor:pointer;padding:var(--space-1) var(--space-2);background:0 0;border:none;font-size:1.75rem;line-height:1;position:absolute}.install-modal__title[data-v-6f8a8b72]{font-size:var(--text-lg);margin-bottom:var(--space-3);padding-right:var(--space-5);font-weight:700}.install-modal__steps[data-v-6f8a8b72]{margin:0 0 var(--space-3) var(--space-4);padding:0;line-height:1.5}.install-modal__steps li[data-v-6f8a8b72]{margin-bottom:var(--space-2)}.install-modal__footer[data-v-6f8a8b72]{color:var(--color-text-muted);font-size:var(--text-sm);margin-top:var(--space-3);border-top:1px solid var(--color-border);padding-top:var(--space-3);line-height:1.4}.theme-grid[data-v-6f8a8b72]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-6f8a8b72]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-6f8a8b72]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-6f8a8b72]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-6f8a8b72]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-6f8a8b72]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-6f8a8b72]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-6f8a8b72]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
.settings[data-v-fb5d8496]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-fb5d8496]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-fb5d8496]{margin-bottom:var(--space-6)}.settings__section-title[data-v-fb5d8496]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-fb5d8496]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-fb5d8496]{color:var(--color-text-muted)}.settings__row-value[data-v-fb5d8496]{font-weight:600}.settings__logout[data-v-fb5d8496]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.settings__hint[data-v-fb5d8496]{color:var(--color-text-muted);font-size:var(--text-sm);margin-bottom:var(--space-3);line-height:1.4}.settings__install[data-v-fb5d8496]{width:100%;min-height:var(--touch-min);background:var(--color-primary);color:var(--color-on-primary);border-radius:var(--radius-pill,9999px);font-size:var(--text-base);cursor:pointer;border:none;justify-content:center;align-items:center;font-weight:700;display:flex}.install-modal[data-v-fb5d8496]{padding:var(--space-4);z-index:100;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.install-modal__card[data-v-fb5d8496]{background:var(--color-surface);color:var(--color-text);border-radius:var(--radius-lg,16px);padding:var(--space-5);width:100%;max-width:380px;position:relative;box-shadow:0 20px 60px #00000040}.install-modal__close[data-v-fb5d8496]{top:var(--space-2);right:var(--space-3);color:var(--color-text-muted);cursor:pointer;padding:var(--space-1) var(--space-2);background:0 0;border:none;font-size:1.75rem;line-height:1;position:absolute}.install-modal__title[data-v-fb5d8496]{font-size:var(--text-lg);margin-bottom:var(--space-3);padding-right:var(--space-5);font-weight:700}.install-modal__steps[data-v-fb5d8496]{margin:0 0 var(--space-3) var(--space-4);padding:0;line-height:1.5}.install-modal__steps li[data-v-fb5d8496]{margin-bottom:var(--space-2)}.install-modal__footer[data-v-fb5d8496]{color:var(--color-text-muted);font-size:var(--text-sm);margin-top:var(--space-3);border-top:1px solid var(--color-border);padding-top:var(--space-3);line-height:1.4}.theme-grid[data-v-fb5d8496]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-fb5d8496]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-fb5d8496]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-fb5d8496]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-fb5d8496]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-fb5d8496]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-fb5d8496]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-fb5d8496]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -14,8 +14,8 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-BO5caB_f.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-eepT72yB.js">
<script type="module" crossorigin src="/build/assets/index-DdJ5jHP4.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>
<body>
+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">