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

View File

@@ -0,0 +1,38 @@
# Vue Router guard/history experiment
This small app compares the browser's current URL and history position with
Vue Router's `currentRoute` while a real `beforeResolve` guard is pending.
## Run it
From the repository root:
```sh
npm --prefix experiments/router-guard-history run dev
```
Open the URL printed by Vite.
## Suggested tests
### Forward navigation
1. Enable **Pause the next `beforeResolve`**.
2. Click **Push Alpha**.
3. While paused, compare **Browser URL** with **router.currentRoute**.
4. Allow or reject the navigation.
For `router.push()`, both values remain on the outgoing route until the guard
allows confirmation.
### Back navigation
1. Push **Alpha**, then push **Beta**.
2. Enable **Pause the next `beforeResolve`**.
3. Click **Router Back**.
4. While paused, compare **Browser URL** with **router.currentRoute**.
5. Reject the navigation and watch the browser restore its previous history
entry, or repeat the test and allow it.
The event timeline separately records the raw browser `popstate`, Vue Router
guards, the URL, `currentRoute`, and the navigation result.

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue Router Guard History Experiment</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
{
"name": "router-guard-history-experiment",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vue": "^3.5.39",
"vue-router": "^5.0.6",
"vue-tsc": "^3.3.5"
}
}

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { RouterView } from "vue-router";
import { experiment, record } from "./experiment";
import { router } from "./router";
const renderTick = ref(0);
const browserUrl = computed(() => {
renderTick.value;
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
});
const historyPosition = computed(() => {
renderTick.value;
return window.history.state?.position ?? "not available";
});
function refreshBrowserState() {
renderTick.value += 1;
}
window.addEventListener("popstate", refreshBrowserState);
router.afterEach(refreshBrowserState);
function navigate(kind: "push" | "replace", target: string) {
record(`action: ${kind}(${target})`, router.currentRoute.value.fullPath, {
target,
});
void router[kind](target).then(() => refreshBrowserState());
}
function back() {
record("action: router.back()", router.currentRoute.value.fullPath);
router.back();
}
function settle(allow: boolean) {
experiment.pendingGuard?.settle(allow);
}
function clearLog() {
experiment.logs.splice(0);
}
</script>
<template>
<main>
<header>
<p class="eyebrow">Vue Router experiment</p>
<h1>What changes while <code>beforeResolve</code> is pending?</h1>
<p class="intro">
Pause the next resolve guard, navigate, and compare the browser URL with
Vue Router's authoritative route.
</p>
</header>
<section class="status-grid">
<div>
<span>Browser URL</span>
<strong>{{ browserUrl }}</strong>
</div>
<div>
<span>router.currentRoute</span>
<strong>{{ router.currentRoute.value.fullPath }}</strong>
</div>
<div>
<span>history.state.position</span>
<strong>{{ historyPosition }}</strong>
</div>
</section>
<section class="controls">
<label class="hold-toggle">
<input v-model="experiment.holdNextResolve" type="checkbox" />
Pause the next <code>beforeResolve</code>
</label>
<div class="button-row">
<button @click="navigate('push', '/home')">Push Home</button>
<button @click="navigate('push', '/alpha')">Push Alpha</button>
<button @click="navigate('push', '/beta')">Push Beta</button>
<button @click="navigate('replace', '/alpha')">Replace Alpha</button>
<button class="back" @click="back">Router Back</button>
</div>
<div v-if="experiment.pendingGuard" class="gate">
<div>
<span>Navigation paused</span>
<strong>
{{ experiment.pendingGuard.from }} →
{{ experiment.pendingGuard.to }}
</strong>
</div>
<button class="allow" @click="settle(true)">Allow navigation</button>
<button class="reject" @click="settle(false)">Reject navigation</button>
</div>
</section>
<RouterView />
<section class="log-panel">
<div class="log-heading">
<div>
<p class="eyebrow">Event timeline</p>
<h2>Newest event first</h2>
</div>
<button class="quiet" @click="clearLog">Clear log</button>
</div>
<div class="log-table">
<div class="log-row log-labels">
<span>Time / event</span>
<span>Browser URL</span>
<span>Current route</span>
<span>Target / result</span>
</div>
<div v-for="entry in experiment.logs" :key="entry.id" class="log-row">
<span
><small>{{ entry.elapsed }}</small
>{{ entry.event }}</span
>
<code>{{ entry.browserUrl }}</code>
<code>{{ entry.currentRoute }}</code>
<span>
<code v-if="entry.target">{{ entry.target }}</code>
<small v-if="entry.detail">{{ entry.detail }}</small>
</span>
</div>
</div>
</section>
<aside>
<strong>Suggested test</strong>
Push Alpha, then Beta. Enable the pause checkbox and click Router Back.
While the guard is paused, inspect the two route values and then try both
rejection and approval.
</aside>
</main>
</template>

View File

@@ -0,0 +1,78 @@
import { reactive } from "vue";
import type {
NavigationFailure,
RouteLocationNormalizedLoaded,
} from "vue-router";
export interface ExperimentLog {
id: number;
elapsed: string;
event: string;
browserUrl: string;
currentRoute: string;
target?: string;
historyPosition?: unknown;
detail?: string;
}
interface PendingGuard {
to: string;
from: string;
settle(allow: boolean): void;
}
const startedAt = performance.now();
let sequence = 0;
export const experiment = reactive<{
holdNextResolve: boolean;
pendingGuard: PendingGuard | null;
logs: ExperimentLog[];
}>({
holdNextResolve: false,
pendingGuard: null,
logs: [],
});
function browserUrl() {
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
export function record(
event: string,
currentRoute: string,
options: { target?: string; detail?: string } = {},
) {
experiment.logs.unshift({
id: ++sequence,
elapsed: `${(performance.now() - startedAt).toFixed(1)} ms`,
event,
browserUrl: browserUrl(),
currentRoute,
target: options.target,
historyPosition: window.history.state?.position,
detail: options.detail,
});
}
export function waitForDecision(
to: RouteLocationNormalizedLoaded,
from: RouteLocationNormalizedLoaded,
) {
return new Promise<boolean>((resolve) => {
experiment.pendingGuard = {
to: to.fullPath,
from: from.fullPath,
settle(allow) {
experiment.pendingGuard = null;
resolve(allow);
},
};
});
}
export function describeFailure(failure?: NavigationFailure | void) {
return failure
? `navigation failure type ${String(failure.type)}`
: "navigation confirmed";
}

View File

@@ -0,0 +1,13 @@
import { createApp } from "vue";
import App from "./App.vue";
import { record } from "./experiment";
import { router } from "./router";
import "./style.css";
const app = createApp(App);
app.use(router);
app.mount("#app");
void router.isReady().then(() => {
record("router: ready", router.currentRoute.value.fullPath);
});

View File

@@ -0,0 +1,71 @@
import {
createRouter,
createWebHistory,
type RouteRecordRaw,
} from "vue-router";
import ExperimentPage from "./views/ExperimentPage.vue";
import {
describeFailure,
experiment,
record,
waitForDecision,
} from "./experiment";
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/home" },
{
path: "/home",
component: ExperimentPage,
props: { title: "Home", color: "#6d5efc" },
},
{
path: "/alpha",
component: ExperimentPage,
props: { title: "Alpha", color: "#ef5da8" },
},
{
path: "/beta",
component: ExperimentPage,
props: { title: "Beta", color: "#2bbf8a" },
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
// This listener sees the raw browser traversal. For a back/forward traversal,
// compare its URL with router.currentRoute in the event log.
window.addEventListener("popstate", () => {
record("window: popstate", router.currentRoute.value.fullPath);
});
router.beforeEach((to) => {
record("router: beforeEach", router.currentRoute.value.fullPath, {
target: to.fullPath,
});
});
router.beforeResolve(async (to, from) => {
record("router: beforeResolve entered", router.currentRoute.value.fullPath, {
target: to.fullPath,
});
if (!experiment.holdNextResolve) return;
experiment.holdNextResolve = false;
const allow = await waitForDecision(to, from);
record(
allow ? "beforeResolve: allowed" : "beforeResolve: rejected",
router.currentRoute.value.fullPath,
{ target: to.fullPath },
);
return allow || false;
});
router.afterEach((to, _from, failure) => {
record("router: afterEach", router.currentRoute.value.fullPath, {
target: to.fullPath,
detail: describeFailure(failure),
});
});

View File

@@ -0,0 +1,256 @@
:root {
color: #e8e9f3;
background: #11131a;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
button {
border: 1px solid #3b3e4c;
border-radius: 0.65rem;
padding: 0.7rem 0.95rem;
color: #f5f5fa;
background: #252834;
cursor: pointer;
}
button:hover {
background: #303442;
}
main {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
header {
max-width: 760px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0.75rem;
font-size: clamp(2rem, 5vw, 4.25rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
h2 {
margin-bottom: 0;
font-size: 1.1rem;
}
.eyebrow,
.status-grid span,
.gate span,
.route-page span,
small {
color: #979baa;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.intro {
color: #aeb1be;
font-size: 1.05rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 2rem 0 1rem;
}
.status-grid div,
.controls,
.log-panel,
aside {
border: 1px solid #292c38;
border-radius: 1rem;
background: #181a22;
}
.status-grid div {
display: grid;
gap: 0.35rem;
padding: 1rem;
}
.status-grid strong,
.gate strong {
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.controls {
padding: 1rem;
}
.hold-toggle {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
}
.button-row,
.gate {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.back {
margin-left: auto;
}
.gate {
align-items: center;
margin-top: 1rem;
padding: 0.85rem;
border: 1px solid #d6a53a;
border-radius: 0.75rem;
background: #2a2418;
}
.gate div {
display: grid;
gap: 0.25rem;
margin-right: auto;
}
.allow {
background: #176b4f;
}
.reject {
background: #7d2d46;
}
.route-page {
min-height: 170px;
display: grid;
align-content: center;
gap: 0.4rem;
margin: 1rem 0;
padding: 1.5rem;
overflow: hidden;
border-radius: 1rem;
background:
radial-gradient(circle at 90% 20%, var(--route-color), transparent 42%),
#1b1d27;
}
.route-page strong {
font-size: 2.5rem;
}
.log-panel {
overflow: hidden;
}
.log-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #292c38;
}
.log-heading .eyebrow {
margin-bottom: 0.2rem;
}
.quiet {
padding: 0.45rem 0.7rem;
background: transparent;
}
.log-table {
overflow-x: auto;
}
.log-row {
min-width: 850px;
display: grid;
grid-template-columns: 1.35fr 1fr 1fr 1.4fr;
gap: 1rem;
padding: 0.7rem 1rem;
border-top: 1px solid #232630;
font-size: 0.85rem;
}
.log-row > span {
display: grid;
align-content: start;
gap: 0.25rem;
}
.log-labels {
color: #7f8390;
border-top: 0;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
code {
color: #d6cffd;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
aside {
margin-top: 1rem;
padding: 1rem;
color: #aeb1be;
line-height: 1.55;
}
aside strong {
color: #f5f5fa;
}
@media (max-width: 720px) {
main {
padding-top: 1.5rem;
}
.status-grid {
grid-template-columns: 1fr;
}
.back {
margin-left: 0;
}
}

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{
title: string;
color: string;
}>();
</script>
<template>
<article class="route-page" :style="{ '--route-color': color }">
<span>Rendered route component</span>
<strong>{{ title }}</strong>
</article>
</template>

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "../../node_modules/.tmp/router-guard-history.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

View File

@@ -0,0 +1,6 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
});