first commit

This commit is contained in:
2026-07-21 14:54:36 +10:00
commit e79c793b9c
134 changed files with 14427 additions and 0 deletions

26
apps/demo/src/App.vue Normal file
View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NativeNavigator, NativeRouterView } from '@native-vue-router/core'
import { NativeTabBar, type NativeTabItem } from '@native-vue-router/preset-native'
import { useRoute } from 'vue-router'
import PwaUpdate from './components/PwaUpdate.vue'
const route = useRoute()
const siblingRoutes = ['/inbox', '/stories', '/profile']
const showTabs = computed(() => Boolean(route.meta.tab))
const tabs: NativeTabItem[] = [
{ label: 'Inbox', to: '/inbox', icon: '◉' },
{ label: 'Stories', to: '/stories', icon: '◎' },
{ label: 'You', to: '/profile', icon: '◇' },
]
</script>
<template>
<div class="app-frame">
<NativeNavigator :siblings="siblingRoutes">
<NativeRouterView />
</NativeNavigator>
<NativeTabBar v-if="showTabs" :items="tabs" class="app-tabs" />
<PwaUpdate />
</div>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { Person } from '../data'
defineProps<{ person: Person; size?: 'sm' | 'md' | 'lg' | 'xl' }>()
</script>
<template>
<span class="avatar" :class="`avatar--${size ?? 'md'}`" :style="{ '--avatar-color': person.color }" :aria-label="person.name">
<span>{{ person.name.split(' ').map((part) => part[0]).join('') }}</span>
<i v-if="person.online" aria-label="Online" />
</span>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { NativeBackButton } from '@native-vue-router/preset-native'
withDefaults(defineProps<{ title: string; subtitle?: string; back?: boolean; large?: boolean }>(), {
back: false,
large: false,
})
</script>
<template>
<header class="app-header" :class="{ 'app-header--large': large }">
<NativeBackButton v-if="back" />
<div class="app-header__title">
<p v-if="subtitle">{{ subtitle }}</p>
<h1>{{ title }}</h1>
</div>
<div class="app-header__actions"><slot /></div>
</header>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/vue'
import { useNativeRouter } from '@native-vue-router/core'
const native = useNativeRouter()
const { needRefresh, updateServiceWorker } = useRegisterSW()
function update() {
if (!native.transaction.value) void updateServiceWorker(true)
}
</script>
<template>
<aside v-if="needRefresh" class="update-toast" role="status">
<span>A fresh build is ready.</span>
<button type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
</aside>
</template>

171
apps/demo/src/data.ts Normal file
View File

@@ -0,0 +1,171 @@
import { computed, reactive, readonly, ref } from 'vue'
export interface Person {
id: string
name: string
handle: string
color: string
online: boolean
bio: string
}
export interface Message {
id: string
personId: string
body: string
sentAt: number
mine: boolean
status: 'sent' | 'delivered' | 'failed'
}
export interface Conversation {
id: string
personId: string
unread: number
pinned?: boolean
messages: Message[]
}
const people: Person[] = [
{ id: 'maya', name: 'Maya Chen', handle: '@mayac', color: '#ff7a8a', online: true, bio: 'Product designer · Sydney to everywhere.' },
{ id: 'noah', name: 'Noah Williams', handle: '@noahw', color: '#4f9cff', online: true, bio: 'Film, tiny cameras, and very long walks.' },
{ id: 'sofia', name: 'Sofia Rossi', handle: '@sofiar', color: '#9d67ff', online: false, bio: 'Making typefaces and better pasta.' },
{ id: 'liam', name: 'Liam Park', handle: '@liamp', color: '#35c7a0', online: true, bio: 'Engineer. Climber. Questionable DJ.' },
{ id: 'amara', name: 'Amara Okafor', handle: '@amarao', color: '#f4ad42', online: false, bio: 'Architecture and cities after dark.' },
]
const timestamp = Date.now()
const seedConversations: Conversation[] = [
{
id: 'maya', personId: 'maya', unread: 2, pinned: true,
messages: [
{ id: 'm1', personId: 'maya', body: 'That transition feels ridiculously smooth ✨', sentAt: timestamp - 840_000, mine: false, status: 'delivered' },
{ id: 'm2', personId: 'maya', body: 'Try holding it halfway, then let go slowly.', sentAt: timestamp - 780_000, mine: false, status: 'delivered' },
],
},
{
id: 'noah', personId: 'noah', unread: 0,
messages: [
{ id: 'n1', personId: 'noah', body: 'Uploaded the photos from yesterday.', sentAt: timestamp - 4_200_000, mine: false, status: 'delivered' },
{ id: 'n2', personId: 'noah', body: 'The grain is perfect.', sentAt: timestamp - 4_000_000, mine: true, status: 'delivered' },
],
},
{
id: 'sofia', personId: 'sofia', unread: 1,
messages: [{ id: 's1', personId: 'sofia', body: 'Coffee at the new place tomorrow?', sentAt: timestamp - 18_000_000, mine: false, status: 'delivered' }],
},
{
id: 'liam', personId: 'liam', unread: 0,
messages: [{ id: 'l1', personId: 'liam', body: 'The build is green. Ship it.', sentAt: timestamp - 86_000_000, mine: false, status: 'delivered' }],
},
{
id: 'amara', personId: 'amara', unread: 0,
messages: [{ id: 'a1', personId: 'amara', body: 'This city never really goes quiet.', sentAt: timestamp - 172_000_000, mine: false, status: 'delivered' }],
},
]
const conversations = ref<Conversation[]>(structuredClone(seedConversations))
const ready = ref(false)
const offline = ref(!navigator.onLine)
const settings = reactive({ simulatedLatency: 180, simulateFailures: false })
function openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('native-vue-messenger', 1)
request.onupgradeneeded = () => request.result.createObjectStore('state')
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
async function readStored() {
const database = await openDatabase()
return await new Promise<Conversation[] | undefined>((resolve, reject) => {
const transaction = database.transaction('state', 'readonly')
const request = transaction.objectStore('state').get('conversations')
request.onsuccess = () => resolve(request.result as Conversation[] | undefined)
request.onerror = () => reject(request.error)
}).finally(() => database.close())
}
async function persist() {
try {
const database = await openDatabase()
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction('state', 'readwrite')
transaction.objectStore('state').put(structuredClone(conversations.value), 'conversations')
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
database.close()
} catch {
// Private browsing and locked-down webviews can reject IndexedDB.
}
}
async function initialize() {
try {
const stored = await readStored()
if (stored?.length) conversations.value = stored
else await persist()
} finally {
ready.value = true
}
}
window.addEventListener('online', () => { offline.value = false })
window.addEventListener('offline', () => { offline.value = true })
void initialize()
export function useDemoStore() {
const conversationFor = (id: string) => computed(() => conversations.value.find((conversation) => conversation.id === id))
const personFor = (id: string) => people.find((person) => person.id === id)
const markRead = (id: string) => {
const conversation = conversations.value.find((item) => item.id === id)
if (conversation) conversation.unread = 0
void persist()
}
const sendMessage = async (id: string, body: string) => {
const conversation = conversations.value.find((item) => item.id === id)
if (!conversation || !body.trim()) return false
const message: Message = {
id: crypto.randomUUID(),
personId: id,
body: body.trim(),
sentAt: Date.now(),
mine: true,
status: 'sent',
}
conversation.messages.push(message)
await persist()
await new Promise((resolve) => window.setTimeout(resolve, settings.simulatedLatency))
message.status = settings.simulateFailures || offline.value ? 'failed' : 'delivered'
await persist()
return message.status === 'delivered'
}
const reset = async () => {
conversations.value = structuredClone(seedConversations)
await persist()
}
return {
people: readonly(ref(people)),
conversations: readonly(conversations),
ready: readonly(ready),
offline: readonly(offline),
settings,
conversationFor,
personFor,
markRead,
sendMessage,
reset,
}
}
export function relativeTime(value: number) {
const minutes = Math.floor((Date.now() - value) / 60_000)
if (minutes < 1) return 'now'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
return `${Math.floor(hours / 24)}d`
}

25
apps/demo/src/main.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createApp } from 'vue'
import { createNativeRouter } from '@native-vue-router/core'
import { createCapacitorAdapter } from '@native-vue-router/capacitor'
import { createElectronRendererAdapter } from '@native-vue-router/electron'
import App from './App.vue'
import { router } from './router'
import './style.css'
const isElectron = navigator.userAgent.toLowerCase().includes('electron')
const platform = isElectron
? createElectronRendererAdapter()
: createCapacitorAdapter({ haptics: true, exitAtRoot: true })
const nativeRouter = createNativeRouter({
router,
cache: { maxInactive: 8 },
platform,
})
const app = createApp(App)
app.use(router)
app.use(nativeRouter)
await router.isReady()
app.mount('#app')

39
apps/demo/src/router.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/inbox' },
{
path: '/inbox', name: 'inbox', component: () => import('./views/InboxView.vue'),
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 0, siblingHistory: 'replace', gesture: 'full' } },
},
{
path: '/stories', name: 'stories', component: () => import('./views/StoriesView.vue'),
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 1, siblingHistory: 'replace', gesture: 'full' } },
},
{
path: '/profile', name: 'profile', component: () => import('./views/ProfileView.vue'),
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 2, siblingHistory: 'replace', gesture: 'full' } },
},
{
path: '/chat/:id', name: 'chat', component: () => import('./views/ChatView.vue'),
meta: { native: { presentation: 'push', parent: '/inbox', gesture: 'edge' } },
},
{
path: '/chat/:id/details', name: 'chat-details', component: () => import('./views/ContactView.vue'),
meta: { native: { presentation: 'push', parent: (route) => `/chat/${String(route.params.id)}`, gesture: 'edge' } },
},
{
path: '/compose', name: 'compose', component: () => import('./views/ComposeView.vue'),
meta: { native: { presentation: 'sheet', parent: '/inbox', gesture: 'full' } },
},
{
path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue'),
meta: { native: { presentation: 'push', parent: '/profile', gesture: 'edge' } },
},
]
export const router = createRouter({
history: window.location.protocol === 'file:' ? createWebHashHistory() : createWebHistory(),
routes,
scrollBehavior: () => ({ top: 0 }),
})

260
apps/demo/src/style.css Normal file
View File

@@ -0,0 +1,260 @@
@import "@native-vue-router/core/style.css";
@import "@native-vue-router/preset-native/style.css";
:root {
font-family: Inter, ui-rounded, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f5f6fa;
background: #050608;
font-synthesis: none;
text-rendering: optimizeLegibility;
--nvr-view-background: #0b0d12;
--nvr-accent: #8b73ff;
--line: rgba(255, 255, 255, .08);
--muted: #898e9c;
--surface: #13161d;
}
* { box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; }
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
body {
min-width: 320px;
background:
radial-gradient(circle at 50% -15%, rgba(124, 92, 255, .18), transparent 36%),
#050608;
}
.app-frame {
position: relative;
width: 100%;
height: 100dvh;
max-width: 740px;
margin: 0 auto;
overflow: hidden;
background: #0b0d12;
box-shadow: 0 0 80px rgba(0, 0, 0, .5);
}
.app-tabs {
position: absolute;
z-index: 20;
right: 0;
bottom: 0;
left: 0;
}
.screen {
width: 100%;
height: 100%;
overflow: auto;
overscroll-behavior: contain;
background:
radial-gradient(circle at 88% 4%, rgba(124, 92, 255, .09), transparent 24%),
#0b0d12;
scrollbar-width: none;
}
.screen::-webkit-scrollbar { display: none; }
.screen--tabs { padding-bottom: calc(76px + env(safe-area-inset-bottom)); }
.app-header {
position: sticky;
z-index: 10;
top: 0;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
min-height: calc(58px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 14px 0;
border-bottom: 1px solid var(--line);
background: rgba(11, 13, 18, .82);
backdrop-filter: blur(24px) saturate(1.45);
}
.app-header--large {
position: relative;
align-items: end;
min-height: calc(126px + env(safe-area-inset-top));
padding: calc(24px + env(safe-area-inset-top)) 20px 16px;
border-bottom: 0;
background: transparent;
backdrop-filter: none;
}
.app-header__title { min-width: 0; }
.app-header__title p { margin: 0 0 3px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; }
.app-header__title h1 { overflow: hidden; margin: 0; text-overflow: ellipsis; font-size: 17px; line-height: 1.2; white-space: nowrap; }
.app-header--large .app-header__title h1 { font-size: clamp(32px, 8vw, 42px); letter-spacing: -.045em; }
.app-header__actions { display: flex; align-items: center; justify-content: flex-end; }
.round-button,
.avatar-button {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border: 1px solid rgba(255,255,255,.1);
border-radius: 50%;
color: #fff;
background: rgba(255,255,255,.07);
text-decoration: none;
cursor: pointer;
}
.round-button { font-size: 25px; font-weight: 300; }
.avatar-button { border: 0; background: transparent; }
.content-stack { padding: 0 16px 24px; }
.search-field {
display: flex;
align-items: center;
gap: 10px;
height: 46px;
padding: 0 14px;
border: 1px solid rgba(255,255,255,.06);
border-radius: 15px;
color: var(--muted);
background: rgba(255,255,255,.055);
}
.search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: #fff; background: transparent; }
.search-field input::placeholder { color: #707582; }
.story-strip { display: flex; gap: 18px; padding: 22px 3px 20px; overflow-x: auto; scrollbar-width: none; }
.story-strip::-webkit-scrollbar { display: none; }
.story-person { display: flex; flex: 0 0 auto; flex-direction: column; align-items: center; gap: 7px; color: #c9ccd5; font-size: 11px; }
.avatar {
position: relative;
display: inline-grid;
flex: 0 0 auto;
width: 52px;
height: 52px;
place-items: center;
border: 2px solid color-mix(in srgb, var(--avatar-color) 65%, white 5%);
border-radius: 50%;
color: #fff;
background: linear-gradient(145deg, color-mix(in srgb, var(--avatar-color) 84%, white), color-mix(in srgb, var(--avatar-color) 54%, #111));
box-shadow: inset 0 0 22px rgba(255,255,255,.12), 0 8px 24px color-mix(in srgb, var(--avatar-color) 22%, transparent);
font-size: 15px;
font-weight: 750;
}
.avatar i { position: absolute; right: -1px; bottom: 1px; width: 12px; height: 12px; border: 2px solid #0b0d12; border-radius: 50%; background: #34d399; }
.avatar--sm { width: 38px; height: 38px; font-size: 11px; }
.avatar--lg { width: 60px; height: 60px; }
.avatar--xl { width: 102px; height: 102px; font-size: 27px; }
.section-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 2px 4px 10px; }
.section-heading h2 { margin: 0; font-size: 16px; }
.section-heading span { color: #646a77; font-size: 11px; }
.conversation-list { overflow: hidden; border: 1px solid rgba(255,255,255,.055); border-radius: 21px; background: rgba(255,255,255,.03); }
.conversation-row {
position: relative;
display: flex;
width: 100%;
min-height: 76px;
align-items: center;
gap: 13px;
padding: 11px 14px;
border: 0;
border-bottom: 1px solid var(--line);
color: inherit;
text-align: left;
background: transparent;
cursor: pointer;
}
.conversation-row:last-child { border-bottom: 0; }
.conversation-row:active { background: rgba(255,255,255,.045); }
.conversation-copy { min-width: 0; flex: 1; }
.conversation-copy > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.conversation-copy strong { display: block; overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.conversation-copy time { color: #6f7480; font-size: 11px; }
.conversation-copy p { overflow: hidden; margin: 5px 0 0; color: #858a97; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.unread-badge { display: grid; min-width: 20px; height: 20px; padding: 0 6px; place-items: center; border-radius: 10px; background: var(--nvr-accent); font-size: 11px; font-weight: 800; }
.chevron { color: #515663; font-size: 24px; }
.story-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 0 16px; }
.story-card { position: relative; display: flex; min-height: 240px; flex-direction: column; justify-content: flex-end; gap: 9px; overflow: hidden; padding: 16px; border-radius: 25px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.09); }
.story-card__glow { position: absolute; inset: 0; background: linear-gradient(transparent 30%, rgba(0,0,0,.62)); }
.story-card > *:not(.story-card__glow) { position: relative; z-index: 1; }
.story-card strong { font-size: 13px; }
.story-card p { margin: 2px 0 0; color: rgba(255,255,255,.7); font-size: 11px; }
.story-card__mark { position: absolute !important; top: 18px; right: 18px; font-size: 28px; }
.gesture-tip { margin: 22px auto; padding: 0 34px; color: #646a77; font-size: 12px; line-height: 1.5; text-align: center; }
.profile-card { margin: 0 16px 18px; padding: 28px 20px 22px; border: 1px solid var(--line); border-radius: 26px; background: linear-gradient(145deg, rgba(124,92,255,.16), rgba(255,255,255,.025)); text-align: center; }
.profile-avatar { display: grid; width: 88px; height: 88px; margin: 0 auto 14px; place-items: center; border: 2px solid #9b88ff; border-radius: 30px; background: linear-gradient(145deg, #9b88ff, #4735a5); box-shadow: 0 18px 44px rgba(124,92,255,.26); font-size: 24px; font-weight: 800; transform: rotate(-3deg); }
.profile-card h2 { margin: 0; font-size: 24px; }
.profile-card > p { margin: 5px 0 22px; color: var(--muted); font-size: 13px; }
.profile-stats { display: grid; grid-template-columns: repeat(3, 1fr); }
.profile-stats div { display: flex; flex-direction: column; gap: 3px; border-right: 1px solid var(--line); }
.profile-stats div:last-child { border: 0; }
.profile-stats strong { font-size: 17px; }
.profile-stats span { color: var(--muted); font-size: 11px; }
.settings-list, .settings-group { overflow: hidden; margin: 0 16px 18px; border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,255,.03); }
.settings-list > a, .settings-list > button { display: grid; width: 100%; min-height: 58px; grid-template-columns: 30px 1fr auto; align-items: center; padding: 0 15px; border: 0; border-bottom: 1px solid var(--line); color: inherit; background: transparent; text-align: left; text-decoration: none; }
.settings-list > :last-child { border-bottom: 0; }
.settings-list strong { font-size: 14px; }
.settings-list i { color: var(--muted); font-size: 12px; font-style: normal; }
.settings-list .danger { color: #ff6c76; }
.chat-screen { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
.message-list { display: flex; min-height: 0; flex-direction: column; gap: 8px; overflow-y: auto; padding: 18px 14px; overscroll-behavior: contain; }
.message-day { align-self: center; margin-bottom: 9px; padding: 5px 10px; border-radius: 12px; color: #737986; background: rgba(255,255,255,.04); font-size: 10px; font-weight: 700; }
.message { max-width: min(78%, 480px); align-self: flex-start; }
.message p { margin: 0; padding: 10px 13px; border-radius: 18px 18px 18px 5px; background: #1a1e27; font-size: 14px; line-height: 1.42; }
.message footer { display: flex; gap: 6px; margin: 4px 5px 0; color: #656b78; font-size: 9px; }
.message--mine { align-self: flex-end; }
.message--mine p { border-radius: 18px 18px 5px 18px; background: linear-gradient(145deg, #8268ff, #6247d7); }
.message--mine footer { justify-content: flex-end; }
.typing-pill { display: flex; width: 50px; gap: 4px; padding: 12px 13px; border-radius: 18px 18px 18px 5px; background: #1a1e27; }
.typing-pill i { width: 5px; height: 5px; border-radius: 50%; background: #777d89; animation: typing 1s infinite alternate; }
.typing-pill i:nth-child(2) { animation-delay: .2s; }.typing-pill i:nth-child(3) { animation-delay: .4s; }
@keyframes typing { to { opacity: .25; transform: translateY(-3px); } }
.composer { display: grid; grid-template-columns: 38px 1fr 38px; gap: 8px; padding: 10px max(12px, env(safe-area-inset-right)) calc(10px + env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: rgba(11,13,18,.92); backdrop-filter: blur(24px); }
.composer input { min-width: 0; border: 1px solid var(--line); border-radius: 20px; outline: 0; padding: 0 15px; color: #fff; background: rgba(255,255,255,.055); }
.composer button { border: 0; border-radius: 50%; background: rgba(255,255,255,.07); font-size: 21px; }
.composer .send-button { background: var(--nvr-accent); font-weight: 700; }
.composer .send-button:disabled { opacity: .35; }
.contact-hero { padding: 36px 24px 20px; text-align: center; }
.contact-hero h1 { margin: 15px 0 2px; font-size: 26px; }
.contact-hero p { max-width: 340px; margin: 5px auto; color: var(--muted); font-size: 13px; line-height: 1.5; }
.contact-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 0 16px 22px; }
.contact-actions button { display: flex; min-height: 72px; flex-direction: column; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,255,.035); font-size: 11px; }
.contact-actions span { color: #9b88ff; font-size: 21px; }
.sheet-screen { border-radius: 22px 22px 0 0; }
.sheet-handle { position: sticky; z-index: 12; top: 8px; width: 38px; height: 5px; margin: 8px auto 0; border-radius: 4px; background: #4e5360; }
.sheet-header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 16px; }
.sheet-header h1 { margin: 0; font-size: 16px; }
.sheet-header button { justify-self: start; border: 0; color: #9b88ff; background: transparent; }
.compose-search { margin: 0 16px 14px; }
.lab-intro { display: flex; align-items: center; gap: 16px; margin: 20px 16px; padding: 18px; border: 1px solid rgba(124,92,255,.2); border-radius: 20px; background: rgba(124,92,255,.1); }
.lab-intro > span { display: grid; width: 54px; height: 54px; place-items: center; border-radius: 17px; background: #7c5cff; font-size: 20px; font-weight: 800; }
.lab-intro strong { font-size: 14px; }.lab-intro p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
.settings-group { padding: 8px 0; }
.settings-group h2 { margin: 8px 15px; color: #737986; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
.settings-group > label, .settings-group > div { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 15px; padding: 10px 15px; border-top: 1px solid var(--line); }
.settings-group > p { margin: 0; padding: 12px 15px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; line-height: 1.55; }
.settings-group label span, .settings-group div span { display: flex; flex-direction: column; gap: 3px; }
.settings-group strong { font-size: 13px; }.settings-group small { color: var(--muted); }
.settings-group input[type="range"] { width: 120px; accent-color: var(--nvr-accent); }
.settings-group input[type="checkbox"] { width: 42px; height: 24px; accent-color: var(--nvr-accent); }
.settings-group b { color: #3dd9aa; font-size: 12px; }.settings-group b.offline { color: #ff7a84; }
.reset-button { display: block; width: calc(100% - 32px); min-height: 48px; margin: 0 16px 30px; border: 1px solid rgba(255,108,118,.2); border-radius: 15px; color: #ff7a84; background: rgba(255,108,118,.07); }
.empty-state { display: grid; place-items: center; }
.update-toast { position: absolute; z-index: 50; right: 14px; bottom: calc(82px + env(safe-area-inset-bottom)); left: 14px; display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border: 1px solid var(--line); border-radius: 15px; background: rgba(28,31,40,.96); box-shadow: 0 18px 50px rgba(0,0,0,.4); font-size: 12px; }
.update-toast button { border: 0; color: #a998ff; background: transparent; font-weight: 700; }
@media (min-width: 741px) {
.app-frame { height: min(920px, calc(100dvh - 28px)); margin-top: 14px; border: 1px solid rgba(255,255,255,.08); border-radius: 30px; }
.story-grid { grid-template-columns: repeat(4, 1fr); }
.story-card { min-height: 300px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
}

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { NativeLink } from '@native-vue-router/core'
import { useRoute } from 'vue-router'
import AppAvatar from '../components/AppAvatar.vue'
import AppHeader from '../components/AppHeader.vue'
import { relativeTime, useDemoStore } from '../data'
const route = useRoute()
const store = useDemoStore()
const id = computed(() => String(route.params.id))
const conversation = computed(() => store.conversations.value.find((item) => item.id === id.value))
const person = computed(() => store.personFor(id.value))
const draft = ref('')
const list = ref<HTMLElement | null>(null)
async function send() {
const body = draft.value
if (!body.trim()) return
draft.value = ''
void store.sendMessage(id.value, body)
await nextTick()
list.value?.scrollTo({ top: list.value.scrollHeight, behavior: 'smooth' })
}
onMounted(() => {
store.markRead(id.value)
list.value?.scrollTo({ top: list.value.scrollHeight })
})
</script>
<template>
<main v-if="conversation && person" class="screen chat-screen">
<AppHeader :title="person.name" :subtitle="person.online ? 'Online now' : person.handle" back>
<NativeLink :to="`/chat/${id}/details`" class="avatar-button" aria-label="Conversation details">
<AppAvatar :person="person" size="sm" />
</NativeLink>
</AppHeader>
<div ref="list" class="message-list">
<div class="message-day">Today</div>
<article v-for="message in conversation.messages" :key="message.id" class="message" :class="{ 'message--mine': message.mine }">
<p>{{ message.body }}</p>
<footer>
<time>{{ relativeTime(message.sentAt) }}</time>
<span v-if="message.mine">{{ message.status === 'failed' ? 'Tap to retry' : message.status }}</span>
</footer>
</article>
<div v-if="person.online" class="typing-pill"><i /><i /><i /></div>
</div>
<form class="composer" @submit.prevent="send">
<button type="button" aria-label="Add attachment"></button>
<input v-model="draft" aria-label="Message" :placeholder="`Message ${person.name.split(' ')[0]}`" />
<button class="send-button" type="submit" :disabled="!draft.trim()" aria-label="Send"></button>
</form>
</main>
<main v-else class="screen empty-state"><h1>Conversation not found</h1></main>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NativeDismissGesture, useNativeRouter } from '@native-vue-router/core'
import AppAvatar from '../components/AppAvatar.vue'
import { useDemoStore } from '../data'
const native = useNativeRouter()
const store = useDemoStore()
const query = ref('')
async function choose(id: string) {
await native.cancelInteractive()
await native.replace(`/chat/${id}`, { presentation: 'push' })
}
</script>
<template>
<NativeDismissGesture as="main" class="screen sheet-screen">
<div class="sheet-handle" aria-hidden="true" />
<header class="sheet-header">
<button type="button" @click="native.dismiss()">Cancel</button>
<h1>New message</h1>
<span />
</header>
<label class="search-field compose-search">
<span>To:</span>
<input v-model="query" autofocus placeholder="Search people" />
</label>
<div class="conversation-list">
<button v-for="person in store.people.value.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))" :key="person.id" class="conversation-row" @click="choose(person.id)">
<AppAvatar :person="person" />
<div class="conversation-copy"><strong>{{ person.name }}</strong><p>{{ person.handle }}</p></div>
<span class="chevron"></span>
</button>
</div>
</NativeDismissGesture>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppAvatar from '../components/AppAvatar.vue'
import AppHeader from '../components/AppHeader.vue'
import { useDemoStore } from '../data'
const route = useRoute()
const store = useDemoStore()
const person = computed(() => store.personFor(String(route.params.id)))
</script>
<template>
<main v-if="person" class="screen">
<AppHeader title="Details" back />
<section class="contact-hero">
<AppAvatar :person="person" size="xl" />
<h1>{{ person.name }}</h1>
<p>{{ person.handle }}</p>
<p>{{ person.bio }}</p>
</section>
<section class="contact-actions">
<button><span></span>Audio</button>
<button><span></span>Video</button>
<button><span></span>Search</button>
</section>
<section class="settings-list">
<button><span></span><strong>Shared media</strong><i>24</i></button>
<button><span></span><strong>Mute notifications</strong><i>Off</i></button>
<button class="danger"><span></span><strong>Block contact</strong><i /></button>
</section>
</main>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { NativeGestureLink, useNativeRouter } from '@native-vue-router/core'
import AppAvatar from '../components/AppAvatar.vue'
import AppHeader from '../components/AppHeader.vue'
import { relativeTime, useDemoStore } from '../data'
const store = useDemoStore()
const native = useNativeRouter()
const query = ref('')
const filtered = computed(() => store.conversations.value.filter((conversation) => {
const person = store.personFor(conversation.personId)
return person?.name.toLowerCase().includes(query.value.toLowerCase())
}))
</script>
<template>
<main class="screen screen--tabs">
<AppHeader title="Messages" subtitle="Thursday, 16 July" large>
<button class="round-button" type="button" aria-label="Compose" @click="native.present('/compose', 'sheet')"></button>
</AppHeader>
<section class="content-stack">
<label class="search-field">
<span aria-hidden="true"></span>
<input v-model="query" type="search" placeholder="Search conversations" />
</label>
<div class="story-strip" aria-label="Online friends">
<div v-for="person in store.people.value.filter((item) => item.online)" :key="person.id" class="story-person">
<AppAvatar :person="person" size="lg" />
<span>{{ person.name.split(' ')[0] }}</span>
</div>
</div>
<div class="section-heading">
<h2>Recent</h2>
<span>Drag a conversation left</span>
</div>
<div class="conversation-list">
<NativeGestureLink
v-for="conversation in filtered"
:key="conversation.id"
:to="`/chat/${conversation.id}`"
presentation="reveal"
direction="left"
class="conversation-row"
>
<template v-if="store.personFor(conversation.personId)" >
<AppAvatar :person="store.personFor(conversation.personId)!" size="md" />
<div class="conversation-copy">
<div>
<strong>{{ store.personFor(conversation.personId)?.name }}</strong>
<time>{{ relativeTime(conversation.messages.at(-1)?.sentAt ?? 0) }}</time>
</div>
<p>{{ conversation.messages.at(-1)?.body }}</p>
</div>
<span v-if="conversation.unread" class="unread-badge">{{ conversation.unread }}</span>
<span v-else class="chevron" aria-hidden="true"></span>
</template>
</NativeGestureLink>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { NativeLink } from '@native-vue-router/core'
import AppHeader from '../components/AppHeader.vue'
</script>
<template>
<main class="screen screen--tabs">
<AppHeader title="You" subtitle="Your space" large />
<section class="profile-card">
<div class="profile-avatar">HV</div>
<h2>Harvey</h2>
<p>@harvmaster · Sydney</p>
<div class="profile-stats">
<div><strong>28</strong><span>Friends</span></div>
<div><strong>164</strong><span>Moments</span></div>
<div><strong>12</strong><span>Groups</span></div>
</div>
</section>
<section class="settings-list">
<NativeLink to="/settings"><span></span><strong>Navigation lab</strong><i></i></NativeLink>
<a href="https://github.com" target="_blank" rel="noreferrer"><span></span><strong>Project source</strong><i></i></a>
<button type="button"><span></span><strong>Appearance</strong><i>System</i></button>
</section>
</main>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import AppHeader from '../components/AppHeader.vue'
import { useDemoStore } from '../data'
const store = useDemoStore()
</script>
<template>
<main class="screen">
<AppHeader title="Navigation lab" subtitle="Runtime controls" back />
<section class="lab-intro">
<span>60</span>
<div><strong>FPS baseline</strong><p>Interactive layers use transform and opacity only.</p></div>
</section>
<section class="settings-group">
<h2>Simulation</h2>
<label><span><strong>Network latency</strong><small>{{ store.settings.simulatedLatency }} ms</small></span><input v-model.number="store.settings.simulatedLatency" type="range" min="0" max="1200" step="20" /></label>
<label><span><strong>Force message failures</strong><small>Test optimistic UI</small></span><input v-model="store.settings.simulateFailures" type="checkbox" /></label>
<div><span><strong>Connection</strong><small>Browser network state</small></span><b :class="{ offline: store.offline.value }">{{ store.offline.value ? 'Offline' : 'Online' }}</b></div>
</section>
<section class="settings-group">
<h2>Gesture recipes</h2>
<p>Hold an edge-back gesture at any progress, swipe between primary routes, drag a conversation, or open the compose sheet.</p>
</section>
<button class="reset-button" type="button" @click="store.reset">Reset offline demo data</button>
</main>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import AppAvatar from '../components/AppAvatar.vue'
import AppHeader from '../components/AppHeader.vue'
import { useDemoStore } from '../data'
const store = useDemoStore()
const gradients = [
'linear-gradient(155deg, #f4a261, #d64e7d 48%, #5427a8)',
'linear-gradient(155deg, #44c6e9, #4378db 48%, #1b245d)',
'linear-gradient(155deg, #68d391, #2a9d8f 48%, #173e48)',
'linear-gradient(155deg, #b794f4, #805ad5 48%, #322659)',
]
</script>
<template>
<main class="screen screen--tabs">
<AppHeader title="Stories" subtitle="Moments from your circle" large />
<section class="story-grid">
<article v-for="(person, index) in store.people.value.slice(0, 4)" :key="person.id" class="story-card" :style="{ background: gradients[index] }">
<div class="story-card__glow" />
<AppAvatar :person="person" size="sm" />
<div>
<strong>{{ person.name }}</strong>
<p>{{ index % 2 ? '2 hours ago' : 'Just now' }}</p>
</div>
<span class="story-card__mark">{{ ['✦', '◌', '△', '◇'][index] }}</span>
</article>
</section>
<p class="gesture-tip">Swipe horizontally anywhere to move between primary routes.</p>
</main>
</template>