Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.

This commit is contained in:
2026-07-22 07:04:39 +00:00
parent bfe364c57d
commit 6aed7606ad
48 changed files with 5454 additions and 84 deletions
+57 -28
View File
@@ -1,37 +1,66 @@
<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'
import { computed, ref } from "vue";
import { NativeSheet, 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('')
const native = useNativeRouter();
const store = useDemoStore();
const query = ref("");
const sheetBreakpoint = ref(0.62);
const snapEnabled = ref(true);
const sheetBreakpoints = computed(() =>
snapEnabled.value ? [0.38, 0.62, 1] : [],
);
async function choose(id: string) {
await native.cancelInteractive()
await native.replace(`/chat/${id}`, { presentation: 'push' })
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>
<NativeSheet
v-model="sheetBreakpoint"
class="compose-sheet"
aria-label="New message"
:breakpoints="sheetBreakpoints"
:initial-breakpoint="0.62"
>
<main class="compose-sheet-content">
<header class="sheet-header">
<button type="button" @click="native.dismiss()">Cancel</button>
<div>
<h1>New message</h1>
<small data-testid="sheet-size">
{{ snapEnabled ? `${Math.round(sheetBreakpoint * 100)}%` : "Auto" }}
</small>
</div>
<button type="button" @click="snapEnabled = !snapEnabled">
{{ snapEnabled ? "Fit content" : "Use snap points" }}
</button>
</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>
</main>
</NativeSheet>
</template>
+33 -11
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { NativeLink, useNativeRouter } from '@native-vue-router/core'
import AppHeader from '../components/AppHeader.vue'
import { setStoryEntryBlocked, storyEntryGuard } from '../guard-state'
import { NativeLink, useNativeRouter } from "@native-vue-router/core";
import AppHeader from "../components/AppHeader.vue";
import { setStoryEntryBlocked, storyEntryGuard } from "../guard-state";
const native = useNativeRouter()
const native = useNativeRouter();
function toggleStoryGuard() {
setStoryEntryBlocked(!storyEntryGuard.blockEntry)
setStoryEntryBlocked(!storyEntryGuard.blockEntry);
}
</script>
@@ -24,13 +24,35 @@ function toggleStoryGuard() {
</div>
</section>
<section class="settings-list">
<a href="/profile/runtime-lab" @click.prevent="native.sibling('/profile/runtime-lab')"><span>⌁</span><strong>Runtime stress lab</strong><i>›</i></a>
<button type="button" aria-label="Block Stories re-entry" :aria-pressed="storyEntryGuard.blockEntry" @click="toggleStoryGuard">
<span>⌽</span><strong>Block cached Stories re-entry</strong><i data-testid="story-guard-status">{{ storyEntryGuard.blockEntry ? storyEntryGuard.status : 'Off' }}</i>
<a
href="/profile/runtime-lab"
@click.prevent="native.sibling('/profile/runtime-lab')"
><span>⌁</span><strong>Runtime stress lab</strong><i>›</i></a
>
<button
type="button"
aria-label="Block Stories re-entry"
:aria-pressed="storyEntryGuard.blockEntry"
@click="toggleStoryGuard"
>
<span>⌽</span><strong>Block cached Stories re-entry</strong
><i data-testid="story-guard-status">{{
storyEntryGuard.blockEntry ? storyEntryGuard.status : "Off"
}}</i>
</button>
<NativeLink to="/settings"
><span>⚙︎</span><strong>Navigation lab</strong><i>›</i></NativeLink
>
<NativeLink to="/profile/vue-lab/alpha?mode=manual#route-state"
><span>Vue</span><strong>Vue compatibility 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>
<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>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useNativeRouter } from "@native-vue-router/core";
import { useRoute } from "vue-router";
import AppHeader from "../components/AppHeader.vue";
import {
compatibilityLab,
recordCompatibilityEvent,
} from "../compatibility-lab";
const route = useRoute();
const native = useNativeRouter();
const unloadStatus = ref("Lab remains cached");
const labLocation = computed(() => ({
name: "vue-compatibility",
params: { sample: route.params.sample },
query: route.query,
hash: route.hash,
}));
function unloadLab() {
const count = native.unload(labLocation.value);
unloadStatus.value = count
? "Lab view evicted; Back will create a new instance"
: "No matching inactive lab view was mounted";
recordCompatibilityEvent("Away screen", "native.unload", `${count} view(s)`);
}
async function unloadAndReturn() {
unloadLab();
await native.pop();
}
</script>
<template>
<main
class="screen compatibility-screen"
data-testid="vue-compatibility-away"
>
<AppHeader
title="Cached-route checkpoint"
subtitle="The compatibility lab is behind this view"
back
/>
<section class="lab-intro compat-intro">
<span>↩</span>
<div>
<strong>Inspect native cache behavior</strong>
<p>
The previous lab instance is inactive but still mounted until you
explicitly unload it.
</p>
</div>
</section>
<section class="settings-group">
<h2>Inactive route controls</h2>
<div>
<span
><strong>Compatibility lab</strong
><small>{{ unloadStatus }}</small></span
><b>behind</b>
</div>
<p>
Use normal Back to observe native activate/show without Vue remount
hooks. Use “Unload and return” to observe beforeUnmount/unmounted
followed by a fresh component instance.
</p>
</section>
<div class="compat-controls">
<button type="button" data-testid="compat-unload-lab" @click="unloadLab">
Unload cached lab
</button>
<button
type="button"
data-testid="compat-unload-return"
@click="unloadAndReturn"
>
Unload and return
</button>
</div>
<section class="settings-group compat-event-section">
<div class="compat-event-heading">
<span
><strong>Shared lifecycle journal</strong
><small>Events survive route eviction</small></span
>
</div>
<ol class="compat-event-log" data-testid="compat-away-event-log">
<li v-for="event in compatibilityLab.events" :key="event.id">
<time>{{ event.timestamp }}</time>
<span
><strong>{{ event.source }}</strong
><code>{{ event.hook }}</code></span
>
<small>{{ event.detail }}</small>
</li>
</ol>
</section>
</main>
</template>
@@ -0,0 +1,427 @@
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
provide,
ref,
} from "vue";
import {
onNativeViewActivate,
onNativeViewDeactivate,
onNativeViewEvict,
onNativeViewHide,
onNativeViewShow,
useNativeRouter,
useNativeViewLifecycle,
} from "@native-vue-router/core";
import { useRoute } from "vue-router";
import AppHeader from "../components/AppHeader.vue";
import CompatibilityAsyncProbe from "../components/CompatibilityAsyncProbe.vue";
import CompatibilityCompositionProbe from "../components/CompatibilityCompositionProbe.vue";
import CompatibilityInjectProbe from "../components/CompatibilityInjectProbe.vue";
import CompatibilityKeepAliveProbe from "../components/CompatibilityKeepAliveProbe.vue";
import CompatibilityOptionsProbe from "../components/CompatibilityOptionsProbe.vue";
import {
clearCompatibilityEvents,
compatibilityLab,
compatibilityLabContextKey,
recordCompatibilityEvent,
} from "../compatibility-lab";
const route = useRoute();
const native = useNativeRouter();
const nativeLifecycle = useNativeViewLifecycle();
const instanceId = Math.random().toString(36).slice(2, 7);
const revision = ref(0);
const probesMounted = ref(true);
const keepAliveVariant = ref<"A" | "B">("A");
const transitionVisible = ref(true);
const teleportOpen = ref(false);
const suspenseRequest = ref(1);
const routeLabel = computed(() => route.fullPath);
const nativeVisible = nativeLifecycle.isVisible;
const nativeActive = nativeLifecycle.isActive;
const eventCount = computed(() => compatibilityLab.events.length);
provide(compatibilityLabContextKey, {
source: `Vue compatibility lab ${instanceId}`,
routeLabel,
});
const recordPageEvent = (hook: string, detail?: string) =>
recordCompatibilityEvent(`Lab page ${instanceId}`, hook, detail);
onMounted(() => recordPageEvent("onMounted", route.fullPath));
onBeforeUnmount(() => recordPageEvent("onBeforeUnmount"));
onUnmounted(() => recordPageEvent("onUnmounted"));
onNativeViewActivate(() => recordPageEvent("native activate"));
onNativeViewDeactivate(() => recordPageEvent("native deactivate"));
onNativeViewShow(() => recordPageEvent("native show"));
onNativeViewHide(() => recordPageEvent("native hide"));
onNativeViewEvict((reason) => recordPageEvent("native evict", String(reason)));
function updateProbes() {
revision.value += 1;
recordPageEvent("revision changed", String(revision.value));
}
function toggleProbeMount() {
probesMounted.value = !probesMounted.value;
recordPageEvent(probesMounted.value ? "probes inserted" : "probes removed");
}
function switchKeptInstance() {
keepAliveVariant.value = keepAliveVariant.value === "A" ? "B" : "A";
}
function recordTransition(hook: string) {
recordCompatibilityEvent("Transition", hook);
}
function reloadSuspense() {
suspenseRequest.value += 1;
}
function pushAlternateRoute() {
const sample = route.params.sample === "alpha" ? "beta" : "alpha";
void native.push({
name: "vue-compatibility",
params: { sample },
query: { mode: "parameter", revision: revision.value },
hash: "#route-state",
});
}
function replaceQueryAndHash() {
void native.replace(
{
name: "vue-compatibility",
params: { sample: route.params.sample },
query: { mode: "replaced", tick: Date.now().toString().slice(-5) },
hash: "#event-log",
},
{ presentation: "fade" },
);
}
function openAwayRoute() {
void native.push({
name: "vue-compatibility-away",
params: { sample: route.params.sample },
query: route.query,
hash: route.hash,
});
}
</script>
<template>
<main
class="screen compatibility-screen"
data-testid="vue-compatibility-view"
:data-instance-id="instanceId"
>
<AppHeader
title="Vue compatibility"
subtitle="Native component laboratory"
back
>
<span class="compat-header-state" :class="{ active: nativeActive }">{{
nativeActive ? "active" : nativeVisible ? "transitioning" : "cached"
}}</span>
</AppHeader>
<section class="lab-intro compat-intro">
<span>Vue</span>
<div>
<strong>Exercise real framework behavior</strong>
<p>
Every control below runs inside a routed, cached NativeRouterView
entry.
</p>
</div>
</section>
<section
id="route-state"
class="settings-group compat-route-state"
data-testid="compat-route-state"
>
<h2>Scoped route state</h2>
<div>
<span
><strong>Full path</strong
><small>useRoute() and Options API $route</small></span
><code data-testid="compat-full-path">{{ route.fullPath }}</code>
</div>
<div>
<span
><strong>Named route</strong
><small>Matched record identity</small></span
><code>{{ String(route.name) }}</code>
</div>
<div>
<span><strong>Param</strong><small>route.params.sample</small></span
><code data-testid="compat-param">{{ route.params.sample }}</code>
</div>
<div>
<span><strong>Query</strong><small>route.query</small></span
><code data-testid="compat-query">{{
JSON.stringify(route.query)
}}</code>
</div>
<div>
<span><strong>Hash</strong><small>route.hash</small></span
><code data-testid="compat-hash">{{ route.hash || "(empty)" }}</code>
</div>
<div>
<span><strong>Matched</strong><small>route.matched</small></span
><code>{{
route.matched.map((record) => String(record.name)).join(" → ")
}}</code>
</div>
</section>
<div class="compat-controls compat-controls--three">
<button
type="button"
data-testid="compat-change-param"
@click="pushAlternateRoute"
>
Push alternate param
</button>
<button
type="button"
data-testid="compat-change-query"
@click="replaceQueryAndHash"
>
Replace query + hash
</button>
<button
type="button"
data-testid="compat-open-away"
@click="openAwayRoute"
>
Cache this view
</button>
</div>
<section class="settings-group">
<h2>provide() / inject()</h2>
<CompatibilityInjectProbe location="route tree" />
<p>
The same probe is rendered inside the Teleport below to verify
logical-tree injection and scoped routing.
</p>
</section>
<section class="settings-group">
<h2>Options and Composition lifecycle</h2>
<div>
<span
><strong>Shared revision</strong
><small>Changing it triggers beforeUpdate / updated</small></span
><b data-testid="compat-revision">{{ revision }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-update-probes"
@click="updateProbes"
>
Update props
</button>
<button
type="button"
data-testid="compat-toggle-probes"
@click="toggleProbeMount"
>
{{ probesMounted ? "Unmount probes" : "Mount probes" }}
</button>
</div>
<div v-if="probesMounted" class="compat-probe-grid">
<CompatibilityOptionsProbe
:instance-name="`Options ${instanceId}`"
:revision="revision"
/>
<CompatibilityCompositionProbe
:instance-name="`Composition ${instanceId}`"
:revision="revision"
/>
</div>
</section>
<section class="settings-group">
<h2>KeepAlive</h2>
<div>
<span
><strong>Current cached child</strong
><small>Switch away and back after incrementing</small></span
><b>{{ keepAliveVariant }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-switch-keepalive"
@click="switchKeptInstance"
>
Switch to {{ keepAliveVariant === "A" ? "B" : "A" }}
</button>
</div>
<KeepAlive>
<CompatibilityKeepAliveProbe
:key="keepAliveVariant"
:name="keepAliveVariant"
/>
</KeepAlive>
</section>
<section class="settings-group">
<h2>Transition</h2>
<div>
<span
><strong>CSS transition target</strong
><small>Hooks are recorded in the event journal</small></span
><b>{{ transitionVisible ? "shown" : "removed" }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-toggle-transition"
@click="transitionVisible = !transitionVisible"
>
Toggle transition
</button>
</div>
<Transition
name="compat-fade"
@before-enter="recordTransition('before-enter')"
@after-enter="recordTransition('after-enter')"
@before-leave="recordTransition('before-leave')"
@after-leave="recordTransition('after-leave')"
>
<article
v-if="transitionVisible"
class="compat-transition-card"
data-testid="compat-transition-card"
>
Transition child is mounted
</article>
</Transition>
</section>
<section class="settings-group">
<h2>Teleport</h2>
<div>
<span
><strong>Body-level overlay</strong
><small
>Automatically hidden when this native view is inactive</small
></span
><b>{{ teleportOpen ? "armed" : "closed" }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-open-teleport"
@click="teleportOpen = true"
>
Open teleported overlay
</button>
</div>
</section>
<Teleport to="body">
<Transition name="compat-fade">
<div
v-if="teleportOpen && nativeVisible"
class="compat-teleport"
data-testid="compat-teleport-overlay"
@click.self="teleportOpen = false"
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="compat-teleport-title"
>
<button
type="button"
aria-label="Close teleported overlay"
@click="teleportOpen = false"
>
×
</button>
<h2 id="compat-teleport-title">Teleported route content</h2>
<CompatibilityInjectProbe location="teleport" />
</section>
</div>
</Transition>
</Teleport>
<section class="settings-group">
<h2>Suspense</h2>
<div>
<span
><strong>Async setup request</strong
><small>Fallback remains for 750 ms</small></span
><b>#{{ suspenseRequest }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-reload-suspense"
@click="reloadSuspense"
>
Reload async child
</button>
</div>
<Suspense
:key="suspenseRequest"
:timeout="0"
@pending="recordCompatibilityEvent('Suspense', 'pending')"
@fallback="recordCompatibilityEvent('Suspense', 'fallback')"
@resolve="recordCompatibilityEvent('Suspense', 'resolve')"
>
<CompatibilityAsyncProbe :request-id="suspenseRequest" />
<template #fallback>
<article
class="compat-probe compat-probe--loading"
data-testid="compat-suspense-fallback"
>
<span class="lab-spinner" />
<div>
<strong>Suspense fallback</strong>
<p>Waiting for async setup…</p>
</div>
</article>
</template>
</Suspense>
</section>
<section id="event-log" class="settings-group compat-event-section">
<div class="compat-event-heading">
<span
><strong>Lifecycle event journal</strong
><small>{{ eventCount }} retained events · newest first</small></span
>
<button
type="button"
data-testid="compat-clear-events"
@click="clearCompatibilityEvents"
>
Clear
</button>
</div>
<ol class="compat-event-log" data-testid="compat-event-log">
<li v-for="event in compatibilityLab.events" :key="event.id">
<time>{{ event.timestamp }}</time>
<span
><strong>{{ event.source }}</strong
><code>{{ event.hook }}</code></span
>
<small>{{ event.detail }}</small>
</li>
</ol>
</section>
</main>
</template>