Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.
This commit is contained in:
138
experiments/router-guard-history/src/App.vue
Normal file
138
experiments/router-guard-history/src/App.vue
Normal 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>
|
||||
78
experiments/router-guard-history/src/experiment.ts
Normal file
78
experiments/router-guard-history/src/experiment.ts
Normal 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";
|
||||
}
|
||||
13
experiments/router-guard-history/src/main.ts
Normal file
13
experiments/router-guard-history/src/main.ts
Normal 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);
|
||||
});
|
||||
71
experiments/router-guard-history/src/router.ts
Normal file
71
experiments/router-guard-history/src/router.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
256
experiments/router-guard-history/src/style.css
Normal file
256
experiments/router-guard-history/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user