docs(vue): add interactive demo for every composable

A beautiful, SSR-safe demo.vue next to each composable, auto-discovered by the docs extractor and rendered client-only on each composable's page.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 59e995d0b5
commit e83f10fe32
214 changed files with 19584 additions and 74 deletions
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue';
import { broadcastedRef } from './index';
interface CartState {
item: string;
quantity: number;
}
const products = ['Mechanical Keyboard', 'USB-C Hub', 'Desk Mat', 'Wrist Rest'];
// Synced across every open tab via BroadcastChannel
const cart = broadcastedRef<CartState>('docs-demo-cart', { item: products[0], quantity: 1 }, { immediate: true });
const theme = broadcastedRef<'light' | 'dark'>('docs-demo-theme', 'light');
const supported = typeof BroadcastChannel !== 'undefined';
const subtotal = computed(() => cart.value.quantity * 49);
function pick(item: string): void {
cart.value = { ...cart.value, item };
}
function setQuantity(delta: number): void {
cart.value = { ...cart.value, quantity: Math.max(1, cart.value.quantity + delta) };
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Shared cart</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="supported
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'"
>
<span class="h-1.5 w-1.5 rounded-full" :class="supported ? 'bg-emerald-500' : 'bg-amber-500'" />
{{ supported ? 'Broadcasting' : 'Not supported' }}
</span>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex flex-wrap gap-2">
<button
v-for="product in products"
:key="product"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="cart.item === product
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="pick(product)"
>
{{ product }}
</button>
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-(--fg-muted)">Quantity</span>
<div class="flex items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
:disabled="cart.quantity <= 1"
@click="setQuantity(-1)"
>
&minus;
</button>
<span class="w-8 text-center font-mono text-lg font-bold tabular-nums text-(--fg)">{{ cart.quantity }}</span>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="setQuantity(1)"
>
+
</button>
</div>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="flex justify-between">
<span class="text-(--fg-muted)">{{ cart.item }} &times; {{ cart.quantity }}</span>
<span class="font-bold">${{ subtotal }}</span>
</div>
</div>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="theme = theme === 'light' ? 'dark' : 'light'"
>
Toggle shared theme: <span class="font-mono">{{ theme }}</span>
</button>
<p class="text-xs text-(--fg-subtle)">
Open this page in a second tab. Every change you make here is broadcast and mirrored instantly in the other tab.
</p>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useBreakpoints, breakpointsTailwind } from './index';
const bp = useBreakpoints(breakpointsTailwind);
const active = bp.active();
const current = bp.current();
const isMobile = bp.smaller('md');
const isDesktop = bp.greaterOrEqual('lg');
const rows: { key: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; width: string }[] = [
{ key: 'sm', width: '640px' },
{ key: 'md', width: '768px' },
{ key: 'lg', width: '1024px' },
{ key: 'xl', width: '1280px' },
{ key: '2xl', width: '1536px' },
];
const device = (): string => (isDesktop.value ? 'Desktop' : isMobile.value ? 'Mobile' : 'Tablet');
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Active breakpoint</span>
<div class="mt-1 flex items-baseline gap-2">
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ active || 'none' }}</span>
<span class="rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">{{ device() }}</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Tailwind breakpoints</span>
<div
v-for="row in rows"
:key="row.key"
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
:class="bp[row.key].value
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span class="font-mono font-medium">{{ row.key }}</span>
<span class="font-mono tabular-nums text-(--fg-subtle)">&ge; {{ row.width }}</span>
<span
class="h-2 w-2 rounded-full transition"
:class="bp[row.key].value ? 'bg-(--accent)' : 'bg-(--border-strong)'"
/>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="flex justify-between">
<span class="text-(--fg-muted)">current()</span>
<span>[{{ current.length ? current.join(', ') : '—' }}]</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
Resize your browser window the matched breakpoints update live.
</p>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useClipboard } from './index';
const { text, copied, copyPending, isSupported, copy } = useClipboard({ copiedDuring: 1500 });
const draft = ref('npm install @robonen/toolkit');
const snippets = [
'git switch -c feature/clipboard',
'sk-live-9f2a4c7e1b8d6033',
'https://robonen.dev/docs/useClipboard',
];
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Clipboard API</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'"
>
{{ isSupported ? 'Supported' : 'Not supported' }}
</span>
</div>
<template v-if="isSupported">
<div class="flex flex-col gap-2">
<input
v-model="draft"
type="text"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
placeholder="Type something to copy…"
>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="copyPending || !draft"
@click="copy(draft)"
>
{{ copyPending ? 'Copying…' : copied ? 'Copied!' : 'Copy to clipboard' }}
</button>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Quick copy</span>
<button
v-for="snippet in snippets"
:key="snippet"
class="inline-flex items-center justify-between gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2 text-left text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.99] cursor-pointer"
@click="copy(snippet)"
>
<span class="truncate font-mono text-xs text-(--fg-muted)">{{ snippet }}</span>
<span class="shrink-0 text-xs text-(--fg-subtle)">Copy</span>
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Last copied</span>
<p class="mt-1 break-all font-mono text-sm text-(--fg)">{{ text || '—' }}</p>
</div>
</template>
<div
v-else
class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-600 dark:text-amber-400"
>
The Clipboard API is not available in this browser.
</div>
</div>
</template>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useClipboardItems } from './index';
const errorMessage = ref('');
const { content, copied, copyPending, isSupported, copy, read } = useClipboardItems({
onError: (error) => {
errorMessage.value = error instanceof Error ? error.message : String(error);
},
});
const plain = 'Ada Lovelace — first computer programmer';
const html = '<strong>Ada Lovelace</strong> — <em>first computer programmer</em>';
function copyRich(): void {
errorMessage.value = '';
copy([
new ClipboardItem({
'text/plain': new Blob([plain], { type: 'text/plain' }),
'text/html': new Blob([html], { type: 'text/html' }),
}),
]);
}
async function readClipboard(): Promise<void> {
errorMessage.value = '';
await read();
}
function typesOf(item: ClipboardItem): string {
return item.types.join(', ');
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">ClipboardItem API</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'"
>
{{ isSupported ? 'Supported' : 'Not supported' }}
</span>
</div>
<template v-if="isSupported">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Rich payload</span>
<p class="mt-1 text-sm text-(--fg)" v-html="html" />
<p class="mt-1 font-mono text-xs text-(--fg-subtle)">text/plain &middot; text/html</p>
</div>
<div class="flex gap-2">
<button
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="copyPending"
@click="copyRich"
>
{{ copyPending ? 'Copying…' : copied ? 'Copied!' : 'Copy rich content' }}
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="readClipboard"
>
Read clipboard
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
content ({{ content.length }} {{ content.length === 1 ? 'item' : 'items' }})
</span>
<ul v-if="content.length" class="mt-2 flex flex-col gap-1">
<li
v-for="(item, i) in content"
:key="i"
class="font-mono text-xs text-(--fg)"
>
#{{ i + 1 }}: {{ typesOf(item) }}
</li>
</ul>
<p v-else class="mt-2 font-mono text-xs text-(--fg-subtle)">No items read yet</p>
</div>
<div
v-if="errorMessage"
class="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-600 dark:text-red-400"
>
{{ errorMessage }}
</div>
</template>
<div
v-else
class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-600 dark:text-amber-400"
>
The async ClipboardItem API is not available in this browser.
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useCloseWatcher } from './index';
const { isSupported, onClose, close } = useCloseWatcher();
const open = ref(false);
const lastClosedAt = ref<string | null>(null);
const closeCount = ref(0);
onMounted(() => {
onClose(() => {
if (!open.value)
return;
open.value = false;
closeCount.value++;
lastClosedAt.value = new Date().toLocaleTimeString();
});
});
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">CloseWatcher API</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'"
>
{{ isSupported ? 'Native' : 'Esc fallback' }}
</span>
</div>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
:disabled="open"
@click="open = true"
>
Open dialog
</button>
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 translate-y-1"
leave-active-class="transition duration-100 ease-in"
leave-to-class="opacity-0 translate-y-1"
>
<div v-if="open" class="rounded-xl border border-(--border-strong) bg-(--bg-elevated) p-4 shadow-lg">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-(--fg)">Unsaved changes</p>
<p class="mt-1 text-sm text-(--fg-muted)">
Press <kbd class="rounded border border-(--border) bg-(--bg-inset) px-1.5 py-0.5 font-mono text-xs text-(--fg)">Esc</kbd>
(or the Android back gesture) to dismiss.
</p>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="close()"
>
Dismiss via close()
</button>
</div>
</div>
</Transition>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="flex justify-between">
<span class="text-(--fg-muted)">closes</span>
<span class="font-bold">{{ closeCount }}</span>
</div>
<div class="mt-1 flex justify-between">
<span class="text-(--fg-muted)">last</span>
<span>{{ lastClosedAt ?? '—' }}</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
Open the dialog, then dismiss it with Esc, the system back gesture, or the programmatic <code class="font-mono">close()</code> call.
</p>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useColorMode } from './index';
// Scope the applied class/attribute to the demo card so it does not
// fight the docs site's own theme. `emitAuto` keeps the tri-state value.
const target = ref<HTMLElement>();
const mode = useColorMode({
selector: target,
attribute: 'data-demo-theme',
storageKey: null,
emitAuto: true,
modes: {
auto: '',
light: 'light',
dark: 'dark',
sepia: 'sepia',
},
});
const options = [
{ value: 'auto', label: 'Auto', icon: '◐' },
{ value: 'light', label: 'Light', icon: '☀' },
{ value: 'dark', label: 'Dark', icon: '☾' },
{ value: 'sepia', label: 'Sepia', icon: '✦' },
] as const;
</script>
<template>
<div
ref="target"
class="flex w-full max-w-sm flex-col gap-4"
>
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Color mode</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
system: {{ mode.system.value }}
</span>
</div>
<div class="grid grid-cols-4 gap-2">
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="inline-flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 text-xs font-medium transition active:scale-[0.98] cursor-pointer"
:class="mode === opt.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="mode = opt.value"
>
<span class="text-base leading-none">{{ opt.icon }}</span>
{{ opt.label }}
</button>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Reactive state</p>
<dl class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">selected (emitAuto)</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode }}</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">resolved state</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode.state.value }}</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">store</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode.store.value }}</dd>
</div>
</dl>
</div>
<p class="text-xs text-(--fg-subtle)">
The chosen mode is applied as <code class="font-mono text-(--fg-muted)">data-demo-theme</code> on this card.
Pick "Auto" to follow your OS preference.
</p>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useCssVar } from './index';
const target = ref<HTMLElement>();
// Read/write live CSS custom properties on the preview box.
const hue = useCssVar('--demo-hue', target, { initialValue: '210' });
const radius = useCssVar('--demo-radius', target, { initialValue: '16' });
const size = useCssVar('--demo-size', target, { initialValue: '96' });
const swatches = ['12', '90', '150', '210', '270', '330'];
const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
ref="target"
class="flex items-center justify-center rounded-xl border border-(--border) bg-(--bg-inset) p-6"
>
<div
class="shadow-lg transition-all duration-300 ease-out"
:style="{
width: 'calc(var(--demo-size) * 1px)',
height: 'calc(var(--demo-size) * 1px)',
borderRadius: 'calc(var(--demo-radius) * 1px)',
background: 'hsl(var(--demo-hue) 80% 55%)',
}"
/>
</div>
<div class="flex flex-col gap-4 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="hue">Hue</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-hue: {{ hue }}</span>
</div>
<input
id="hue"
v-model="hue"
type="range"
min="0"
max="360"
class="w-full accent-(--accent) cursor-pointer"
>
<div class="flex gap-1.5">
<button
v-for="s in swatches"
:key="s"
type="button"
class="h-6 w-6 rounded-md border border-(--border) transition hover:scale-110 active:scale-95 cursor-pointer"
:style="{ background: `hsl(${s} 80% 55%)` }"
:aria-label="`Set hue ${s}`"
@click="hue = s"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="radius">Radius</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-radius: {{ radius }}</span>
</div>
<input
id="radius"
v-model="radius"
type="range"
min="0"
max="48"
class="w-full accent-(--accent) cursor-pointer"
>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="size">Size</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-size: {{ size }}</span>
</div>
<input
id="size"
v-model="size"
type="range"
min="48"
max="140"
class="w-full accent-(--accent) cursor-pointer"
>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
background: {{ accent }};
</div>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useDark } from './index';
const target = ref<HTMLElement>();
// Scope the toggle to the demo card via a data attribute so it does not
// override the docs site's own theme. `storageKey: null` keeps it in memory.
const isDark = useDark({
selector: target,
attribute: 'data-demo-mode',
valueDark: 'dark',
valueLight: 'light',
storageKey: null,
});
function toggle() {
isDark.value = !isDark.value;
}
</script>
<template>
<div
ref="target"
data-demo-mode
class="flex w-full max-w-sm flex-col gap-4"
>
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-center gap-3">
<span
class="flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors"
:class="isDark
? 'bg-indigo-500/15 text-indigo-400'
: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'"
>
{{ isDark ? '☾' : '☀' }}
</span>
<div>
<p class="text-sm font-medium text-(--fg)">{{ isDark ? 'Dark mode' : 'Light mode' }}</p>
<p class="text-xs text-(--fg-subtle)">isDark = {{ isDark }}</p>
</div>
</div>
<button
type="button"
role="switch"
:aria-checked="isDark"
class="relative inline-flex h-7 w-12 items-center rounded-full border border-(--border) transition focus:outline-none focus:ring-2 focus:ring-(--ring) cursor-pointer"
:class="isDark ? 'bg-(--accent)' : 'bg-(--bg-inset)'"
@click="toggle"
>
<span
class="inline-block h-5 w-5 transform rounded-full bg-(--bg) shadow transition-transform"
:class="isDark ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Preview surface</p>
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
data-demo-mode = "{{ isDark ? 'dark' : 'light' }}"
</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
Writing the boolean toggles the attribute on this card. When the requested state
matches your OS preference, <code class="font-mono text-(--fg-muted)">useDark</code>
falls back to <code class="font-mono text-(--fg-muted)">auto</code> to keep tracking it.
</p>
</div>
</template>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useDocumentPiP } from './index';
const { isSupported, isOpen, error, open, close } = useDocumentPiP();
// A live element we move into (and back out of) the PiP window.
const player = ref<HTMLElement>();
const host = ref<HTMLElement>();
const elapsed = ref(0);
let timer: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
timer = setInterval(() => {
elapsed.value += 1;
}, 1000);
});
onUnmounted(() => {
if (timer)
clearInterval(timer);
});
async function popOut() {
const win = await open({ width: 320, height: 180 });
if (win && player.value) {
// Carry over the document styles so the moved DOM keeps its look.
for (const sheet of Array.from(document.styleSheets)) {
try {
const css = Array.from(sheet.cssRules).map(r => r.cssText).join('');
const style = win.document.createElement('style');
style.textContent = css;
win.document.head.append(style);
}
catch {
// Cross-origin stylesheet — skip.
}
}
win.document.body.style.margin = '0';
win.document.body.append(player.value);
}
}
// When the PiP window closes, pull the element back into the page.
watch(isOpen, (openNow) => {
if (!openNow && player.value && host.value)
host.value.append(player.value);
});
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400"
>
Document Picture-in-Picture is not supported in this browser.
</div>
<template v-else>
<div
ref="host"
class="min-h-[7rem] rounded-xl border border-(--border) bg-(--bg-inset) p-1"
>
<div
ref="player"
class="flex h-full flex-col items-center justify-center gap-1 rounded-lg bg-(--bg-elevated) p-6"
>
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Live timer</span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ String(Math.floor(elapsed / 60)).padStart(2, '0') }}:{{ String(elapsed % 60).padStart(2, '0') }}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="isOpen"
@click="popOut"
>
Pop out
</button>
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isOpen"
@click="close"
>
Close window
</button>
</div>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-sm">
<span class="text-(--fg-muted)">isOpen</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isOpen
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg) text-(--fg-muted)'"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="isOpen ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
/>
{{ isOpen ? 'floating' : 'docked' }}
</span>
</div>
<p
v-if="error"
class="text-xs text-red-600 dark:text-red-400"
>
{{ String(error) }}
</p>
<p
v-else
class="text-xs text-(--fg-subtle)"
>
"Pop out" moves the live timer into an always-on-top window. Closing it returns the element to the page.
</p>
</template>
</div>
</template>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import { useEventListener } from './index';
// 1. Element target via template ref — track pointer position over the pad.
const pad = ref<HTMLElement>();
const pos = ref({ x: 0, y: 0 });
const inside = ref(false);
useEventListener(pad, 'pointermove', (e: PointerEvent) => {
const rect = pad.value?.getBoundingClientRect();
if (!rect)
return;
pos.value = {
x: Math.round(e.clientX - rect.left),
y: Math.round(e.clientY - rect.top),
};
});
useEventListener(pad, 'pointerenter', () => (inside.value = true));
useEventListener(pad, 'pointerleave', () => (inside.value = false));
// 2. Global window target with a stoppable listener — capture last key.
const lastKey = shallowRef<string>('');
const keyCount = ref(0);
const listening = ref(true);
const stop = useEventListener('keydown', (e: KeyboardEvent) => {
lastKey.value = e.key === ' ' ? 'Space' : e.key;
keyCount.value += 1;
});
function toggleListening() {
if (listening.value) {
stop();
listening.value = false;
}
else {
// Re-arm by registering a fresh listener.
useEventListener('keydown', (e: KeyboardEvent) => {
lastKey.value = e.key === ' ' ? 'Space' : e.key;
keyCount.value += 1;
});
listening.value = true;
}
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">pointermove on element</span>
<div
ref="pad"
class="relative h-32 overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset) touch-none"
>
<div
class="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-(--accent) transition-opacity"
:class="inside ? 'opacity-100' : 'opacity-0'"
:style="{ left: `${pos.x}px`, top: `${pos.y}px` }"
/>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="text-xs text-(--fg-subtle)">{{ inside ? '' : 'Hover here' }}</span>
</div>
<div class="pointer-events-none absolute bottom-2 left-2 rounded-md border border-(--border) bg-(--bg) px-2 py-0.5 font-mono text-xs tabular-nums text-(--fg-muted)">
x: {{ pos.x }} · y: {{ pos.y }}
</div>
</div>
</div>
<div class="flex flex-col gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">keydown on window</span>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="toggleListening"
>
{{ listening ? 'Stop listening' : 'Start listening' }}
</button>
</div>
<div class="flex items-center gap-3">
<kbd
class="flex min-w-[3.5rem] items-center justify-center rounded-lg border border-(--border-strong) bg-(--bg-inset) px-3 py-2 font-mono text-sm font-medium text-(--fg)"
>
{{ lastKey || '—' }}
</kbd>
<div class="flex flex-col">
<span class="text-xs text-(--fg-subtle)">presses captured</span>
<span class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ keyCount }}</span>
</div>
<span
class="ml-auto inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="listening
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="listening ? 'bg-emerald-500 animate-pulse' : 'bg-(--fg-subtle)'"
/>
{{ listening ? 'active' : 'stopped' }}
</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
Listeners auto-detach on unmount. The returned stop function lets you detach early press any key, then toggle listening.
</p>
</div>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useEyeDropper } from './index';
const { isSupported, sRGBHex, open } = useEyeDropper({ initialValue: '#6366f1' });
const history = ref<string[]>([]);
const error = ref('');
const hex = computed(() => sRGBHex.value || '#000000');
function relativeLuminance(color: string): number {
const value = color.replace('#', '');
const r = Number.parseInt(value.slice(0, 2), 16) / 255;
const g = Number.parseInt(value.slice(2, 4), 16) / 255;
const b = Number.parseInt(value.slice(4, 6), 16) / 255;
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
const readableText = computed(() => (relativeLuminance(hex.value) > 0.5 ? '#000000' : '#ffffff'));
async function pick() {
error.value = '';
try {
const result = await open();
if (result && !history.value.includes(result.sRGBHex))
history.value = [result.sRGBHex, ...history.value].slice(0, 6);
}
catch {
error.value = 'Selection cancelled';
}
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center text-sm text-amber-600 dark:text-amber-400"
>
The EyeDropper API is not supported in this browser.
</div>
<template v-else>
<div
class="flex h-32 items-center justify-center rounded-xl border border-(--border) transition-colors duration-300"
:style="{ backgroundColor: hex, color: readableText }"
>
<span class="font-mono text-2xl font-bold tabular-nums">{{ hex }}</span>
</div>
<button class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer" @click="pick">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m2 22 1-1h3l9-9" /><path d="M3 21v-3l9-9" /><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L21 6l3 3-3 3-3-3-9 9" />
</svg>
Pick a color from screen
</button>
<p v-if="error" class="text-center text-xs text-(--fg-subtle)">
{{ error }}
</p>
<div v-if="history.length" class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Recent</span>
<div class="flex flex-wrap gap-2">
<button
v-for="color in history"
:key="color"
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted) transition hover:border-(--border-strong) cursor-pointer"
@click="sRGBHex = color"
>
<span class="size-3 rounded-full border border-(--border)" :style="{ backgroundColor: color }" />
{{ color }}
</button>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useFavicon } from './index';
interface Preset {
label: string;
href: string;
emoji: string;
}
// Tiny inline SVG data-URIs so the demo needs no network/assets.
function emojiFavicon(emoji: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">${emoji}</text></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
const presets: Preset[] = [
{ label: 'Rocket', emoji: '🚀', href: emojiFavicon('🚀') },
{ label: 'Fire', emoji: '🔥', href: emojiFavicon('🔥') },
{ label: 'Heart', emoji: '💜', href: emojiFavicon('💜') },
{ label: 'Star', emoji: '⭐', href: emojiFavicon('⭐') },
];
const favicon = useFavicon(presets[0].href);
const active = ref(presets[0].label);
function select(preset: Preset) {
active.value = preset.label;
favicon.value = preset.href;
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-center gap-2 rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2">
<div class="flex gap-1.5">
<span class="size-2.5 rounded-full bg-red-500/70" />
<span class="size-2.5 rounded-full bg-amber-500/70" />
<span class="size-2.5 rounded-full bg-emerald-500/70" />
</div>
<div class="ml-2 flex flex-1 items-center gap-2 rounded-md bg-(--bg) px-2 py-1">
<span class="text-base leading-none">{{ presets.find(p => p.label === active)?.emoji }}</span>
<span class="truncate text-xs text-(--fg-muted)">My Awesome App</span>
</div>
</div>
<p class="mt-2 text-center text-xs text-(--fg-subtle)">
Look at the real browser tab its favicon updates live.
</p>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Choose a favicon</span>
<div class="grid grid-cols-2 gap-2">
<button
v-for="preset in presets"
:key="preset.label"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="active === preset.label
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="select(preset)"
>
<span class="text-base leading-none">{{ preset.emoji }}</span>
{{ preset.label }}
</button>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg) break-all">
favicon.value = "{{ presets.find(p => p.label === active)?.emoji }} svg"
</div>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useFileDialog } from './index';
const { files, open, reset, onChange, onCancel } = useFileDialog({
accept: 'image/*',
multiple: true,
});
const status = ref('Idle');
const multiple = ref(true);
const selected = computed(() => (files.value ? Array.from(files.value) : []));
const totalBytes = computed(() => selected.value.reduce((sum, file) => sum + file.size, 0));
function formatBytes(bytes: number): string {
if (bytes === 0)
return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / 1024 ** index).toFixed(1)} ${units[index]}`;
}
onChange((list) => {
status.value = list && list.length ? `Selected ${list.length} file(s)` : 'Cleared';
});
onCancel(() => {
status.value = 'Dialog dismissed';
});
function pick() {
open({ multiple: multiple.value });
}
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="flex items-center justify-between gap-3">
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
<input v-model="multiple" type="checkbox" class="size-4 rounded border-(--border) accent-(--accent)">
Allow multiple
</label>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
{{ status }}
</span>
</div>
<div class="flex gap-2">
<button class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer" @click="pick">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
Choose images
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!selected.length"
@click="reset"
>
Clear
</button>
</div>
<div
v-if="!selected.length"
class="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center"
>
<span class="text-sm text-(--fg-muted)">No files selected</span>
<span class="text-xs text-(--fg-subtle)">Click Choose images to open the native dialog</span>
</div>
<div v-else class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">{{ selected.length }} file(s)</span>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ formatBytes(totalBytes) }} total</span>
</div>
<ul class="flex max-h-44 flex-col gap-1.5 overflow-auto">
<li
v-for="file in selected"
:key="file.name + file.lastModified"
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
>
<span class="truncate text-sm text-(--fg)">{{ file.name }}</span>
<span class="shrink-0 font-mono text-xs tabular-nums text-(--fg-subtle)">{{ formatBytes(file.size) }}</span>
</li>
</ul>
</div>
</div>
</template>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useFileSystemAccess } from './index';
const lastError = ref('');
const {
isSupported,
data,
fileName,
fileMIME,
fileSize,
open,
create,
save,
saveAs,
} = useFileSystemAccess({
dataType: 'Text',
types: [{ description: 'Text files', accept: { 'text/plain': ['.txt', '.md'] } }],
onError: (error) => {
lastError.value = error instanceof Error && error.name === 'AbortError'
? 'Cancelled'
: 'Something went wrong';
},
});
// Writable string proxy so the textarea can v-model the (union-typed) data ref.
const text = computed({
get: () => (typeof data.value === 'string' ? data.value : ''),
set: (value: string) => { data.value = value; },
});
function formatBytes(bytes: number): string {
if (bytes === 0)
return '0 B';
const units = ['B', 'KB', 'MB'];
const index = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / 1024 ** index).toFixed(1)} ${units[index]}`;
}
async function newFile() {
lastError.value = '';
await create({ suggestedName: 'untitled.txt' });
if (typeof data.value !== 'string')
data.value = 'Hello from the File System Access API!\n';
}
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center text-sm text-amber-600 dark:text-amber-400"
>
The File System Access API is not supported in this browser. Try Chrome or Edge.
</div>
<template v-else>
<div class="flex flex-wrap gap-2">
<button class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer" @click="open()">
Open
</button>
<button class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer" @click="newFile">
New
</button>
<button class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100" :disabled="data === undefined" @click="save()">
Save
</button>
<button class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100" :disabled="data === undefined" @click="saveAs()">
Save As
</button>
</div>
<div v-if="fileName" class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
{{ fileName }}
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
{{ fileMIME || 'text/plain' }}
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-mono tabular-nums text-(--fg-muted)">
{{ formatBytes(fileSize) }}
</span>
</div>
<textarea
v-if="data !== undefined"
v-model="text"
rows="6"
spellcheck="false"
class="w-full resize-none rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
placeholder="File contents…"
/>
<div
v-else
class="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center"
>
<span class="text-sm text-(--fg-muted)">No file open</span>
<span class="text-xs text-(--fg-subtle)">Open an existing file or create a new one, edit it, then save back to disk.</span>
</div>
<p v-if="lastError" class="text-center text-xs text-(--fg-subtle)">
{{ lastError }}
</p>
</template>
</div>
</template>
@@ -1,46 +0,0 @@
<script setup lang="ts">import { useFps } from './index';
const { fps, min, max, isActive, reset, toggle } = useFps({ every: 10 });
</script>
<template>
<div class="space-y-4">
<div class="flex items-end gap-8">
<div>
<div class="text-4xl font-mono font-bold tabular-nums text-(--color-text)">{{ fps }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">FPS</div>
</div>
<div>
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ min === Infinity ? '—' : min }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">Min</div>
</div>
<div>
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ max || '—' }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">Max</div>
</div>
</div>
<div class="h-2 rounded-full border border-(--color-border) overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="fps >= 50 ? 'bg-emerald-500' : fps >= 30 ? 'bg-amber-500' : 'bg-red-500'"
:style="{ width: `${Math.min(fps / 60 * 100, 100)}%` }"
/>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
@click="reset"
>
Reset
</button>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { useFullscreen } from './index';
const target = useTemplateRef<HTMLElement>('target');
const { isSupported, isFullscreen, enter, exit, toggle } = useFullscreen(target);
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center text-sm text-amber-600 dark:text-amber-400"
>
The Fullscreen API is not supported in this browser.
</div>
<div
ref="target"
class="relative flex h-44 flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-(--border) bg-(--bg-elevated) transition-colors"
:class="isFullscreen && 'bg-(--bg-inset)'"
>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isFullscreen
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span class="size-1.5 rounded-full" :class="isFullscreen ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
{{ isFullscreen ? 'Fullscreen' : 'Windowed' }}
</span>
<p class="px-6 text-center text-sm text-(--fg-muted)">
This panel becomes the fullscreen target. Press
<kbd class="rounded border border-(--border) bg-(--bg) px-1.5 py-0.5 font-mono text-xs text-(--fg)">Esc</kbd>
to leave.
</p>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isSupported"
@click="toggle"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<template v-if="isFullscreen">
<path d="M8 3v3a2 2 0 0 1-2 2H3" /><path d="M21 8h-3a2 2 0 0 1-2-2V3" /><path d="M3 16h3a2 2 0 0 1 2 2v3" /><path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</template>
<template v-else>
<path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M21 8V5a2 2 0 0 0-2-2h-3" /><path d="M3 16v3a2 2 0 0 0 2 2h3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</template>
</svg>
{{ isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen' }}
</button>
</div>
<div class="flex gap-2">
<button
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isSupported || isFullscreen"
@click="enter"
>
Enter
</button>
<button
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isSupported || !isFullscreen"
@click="exit"
>
Exit
</button>
</div>
</div>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useImage } from './index';
const samples = [
{ label: 'Mountains', src: 'https://picsum.photos/id/1018/640/400' },
{ label: 'Forest', src: 'https://picsum.photos/id/1015/640/400' },
{ label: 'Broken URL', src: 'https://picsum.photos/this-image-does-not-exist.jpg' },
];
const current = ref(0);
const src = computed(() => samples[current.value]!.src);
// Reactive getter source: useImage reloads whenever `src` changes.
const { isLoading, isReady, error, state, execute } = useImage(
() => ({ src: src.value, alt: samples[current.value]!.label }),
);
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex flex-wrap gap-2">
<button
v-for="(sample, index) in samples"
:key="sample.src"
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="current === index
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="current = index"
>
{{ sample.label }}
</button>
</div>
<div class="relative aspect-[8/5] w-full overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset)">
<Transition
enter-active-class="transition duration-300"
enter-from-class="opacity-0 scale-[1.02]"
leave-active-class="transition duration-200"
leave-to-class="opacity-0"
>
<img
v-if="isReady && state"
:key="src"
:src="state.src"
:alt="state.alt"
class="h-full w-full object-cover"
>
<div
v-else-if="error"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center"
>
<span class="text-2xl"></span>
<p class="text-sm font-medium text-red-600 dark:text-red-400">
Failed to load image
</p>
</div>
<div
v-else
class="absolute inset-0 flex flex-col items-center justify-center gap-3"
>
<span class="h-7 w-7 animate-spin rounded-full border-2 border-(--border-strong) border-t-(--accent)" />
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Loading
</p>
</div>
</Transition>
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isLoading
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: isReady
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'"
>
{{ isLoading ? 'loading' : isReady ? 'ready' : 'error' }}
</span>
<span
v-if="isReady && state"
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted) tabular-nums"
>
{{ state.naturalWidth }}×{{ state.naturalHeight }}
</span>
</div>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="isLoading"
@click="execute()"
>
Reload
</button>
</div>
</div>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useLocalFonts } from './index';
const { isSupported, fonts, error, query } = useLocalFonts();
const loading = ref(false);
const filter = ref('');
async function pickFonts() {
loading.value = true;
try {
await query();
}
finally {
loading.value = false;
}
}
const filtered = computed(() => {
const term = filter.value.trim().toLowerCase();
if (!term)
return fonts.value;
return fonts.value.filter(font => font.fullName.toLowerCase().includes(term));
});
// Unique families for the summary readout.
const familyCount = computed(() => new Set(fonts.value.map(font => font.family)).size);
// Render each face in its own font (quotes kept out of the template attribute).
function familyStyle(name: string) {
return { fontFamily: `'${name}', sans-serif` };
}
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Local Font Access
</p>
<span
v-if="fonts.length"
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted) tabular-nums"
>
{{ fonts.length }} faces · {{ familyCount }} families
</span>
</div>
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-300"
>
The Local Font Access API is not supported in this browser. Try a recent Chromium-based desktop browser.
</div>
<template v-else>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="loading"
@click="pickFonts"
>
{{ loading ? 'Requesting permission…' : fonts.length ? 'Re-query fonts' : 'Enumerate installed fonts' }}
</button>
<p
v-if="error"
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400"
>
Query failed permission was likely denied.
</p>
<template v-if="fonts.length">
<input
v-model="filter"
type="search"
placeholder="Filter by name…"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
<ul class="max-h-56 divide-y divide-(--border) overflow-y-auto rounded-xl border border-(--border) bg-(--bg-elevated)">
<li
v-for="font in filtered"
:key="font.postscriptName"
class="flex items-baseline justify-between gap-3 px-3 py-2"
>
<span
class="truncate text-base text-(--fg)"
:style="familyStyle(font.fullName)"
>
{{ font.fullName }}
</span>
<span class="shrink-0 font-mono text-xs text-(--fg-subtle)">
{{ font.style }}
</span>
</li>
<li
v-if="!filtered.length"
class="px-3 py-6 text-center text-sm text-(--fg-subtle)"
>
No fonts match "{{ filter }}"
</li>
</ul>
</template>
<p
v-else-if="!error && !loading"
class="rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-6 text-center text-sm text-(--fg-subtle)"
>
Click above to grant the <code class="font-mono">local-fonts</code> permission and list your fonts.
</p>
</template>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { useMediaQuery } from './index';
// useMediaQuery returns a single ComputedRef<boolean> — bind it directly.
const isWide = useMediaQuery('(min-width: 1024px)');
const isMedium = useMediaQuery('(min-width: 640px)');
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const isPortrait = useMediaQuery('(orientation: portrait)');
const isFinePointer = useMediaQuery('(pointer: fine)');
const breakpoint = isWide;
const queries = [
{ label: 'min-width: 1024px', match: isWide },
{ label: 'min-width: 640px', match: isMedium },
{ label: 'prefers-color-scheme: dark', match: prefersDark },
{ label: 'prefers-reduced-motion', match: prefersReducedMotion },
{ label: 'orientation: portrait', match: isPortrait },
{ label: 'pointer: fine', match: isFinePointer },
];
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-center">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Current layout
</p>
<p class="mt-1 font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ breakpoint ? 'desktop' : isMedium ? 'tablet' : 'mobile' }}
</p>
<p class="mt-1 text-sm text-(--fg-muted)">
Resize the window to watch these queries flip live.
</p>
</div>
<ul class="divide-y divide-(--border) rounded-xl border border-(--border) bg-(--bg-elevated)">
<li
v-for="query in queries"
:key="query.label"
class="flex items-center justify-between gap-3 px-3 py-2.5"
>
<code class="font-mono text-sm text-(--fg)">{{ query.label }}</code>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
:class="query.match.value
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-subtle)'"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="query.match.value ? 'bg-emerald-500' : 'bg-(--border-strong)'"
/>
{{ query.match.value ? 'matches' : 'no match' }}
</span>
</li>
</ul>
</div>
</template>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { useObjectUrl } from './index';
const file = shallowRef<File>();
// useObjectUrl returns a single read-only ShallowRef<string | undefined>.
const url = useObjectUrl(file);
const isImage = computed(() => file.value?.type.startsWith('image/') ?? false);
const sizeLabel = computed(() => {
const bytes = file.value?.size ?? 0;
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
});
const dragging = ref(false);
function onFiles(list: FileList | null | undefined) {
if (list && list.length)
file.value = list[0];
}
function onDrop(event: DragEvent) {
dragging.value = false;
onFiles(event.dataTransfer?.files);
}
function clear() {
file.value = undefined;
}
// A synthetic blob so the demo works even without a file at hand.
function generateSample() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160">`
+ `<rect width="240" height="160" fill="#6366f1"/>`
+ `<text x="120" y="90" font-size="22" font-family="sans-serif" fill="white" text-anchor="middle">useObjectUrl</text>`
+ `</svg>`;
file.value = new File([svg], 'sample.svg', { type: 'image/svg+xml' });
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<label
class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 text-center transition"
:class="dragging
? 'border-(--accent) bg-(--accent-subtle)'
: 'border-(--border) bg-(--bg-inset) hover:border-(--border-strong)'"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<span class="text-2xl">📎</span>
<span class="text-sm font-medium text-(--fg)">Drop a file or click to choose</span>
<span class="text-xs text-(--fg-subtle)">An object URL is created instantly</span>
<input
type="file"
class="hidden"
@change="onFiles(($event.target as HTMLInputElement).files)"
>
</label>
<div class="flex gap-2">
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="generateSample"
>
Use sample image
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!file"
@click="clear"
>
Clear
</button>
</div>
<div
v-if="file"
class="flex flex-col gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4"
>
<img
v-if="isImage && url"
:src="url"
alt="Selected file preview"
class="mx-auto max-h-40 rounded-lg border border-(--border) object-contain"
>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="truncate font-medium text-(--fg)">{{ file.name }}</span>
<span class="shrink-0 font-mono text-xs text-(--fg-muted) tabular-nums">{{ sizeLabel }}</span>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg) break-all">
{{ url }}
</div>
</div>
<p
v-else
class="rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-6 text-center text-sm text-(--fg-subtle)"
>
No source the URL ref is <code class="font-mono">undefined</code>
</p>
</div>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { usePermission } from './index';
import type { GeneralPermissionDescriptor } from './index';
const names: GeneralPermissionDescriptor['name'][] = [
'geolocation',
'camera',
'microphone',
'notifications',
'clipboard-read',
];
// usePermission takes a static descriptor, so instantiate one per permission
// up-front and switch which reactive state we read via the dropdown.
const permissions = names.map(name => ({
name,
...usePermission(name, { controls: true }),
}));
const selected = ref<GeneralPermissionDescriptor['name']>('geolocation');
const active = computed(() => permissions.find(perm => perm.name === selected.value)!);
const isSupported = active.value.isSupported;
const meta = computed(() => {
switch (active.value.state.value) {
case 'granted':
return { label: 'granted', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400', ring: 'border-emerald-500/30 bg-emerald-500/10' };
case 'denied':
return { label: 'denied', dot: 'bg-red-500', text: 'text-red-600 dark:text-red-400', ring: 'border-red-500/30 bg-red-500/10' };
case 'prompt':
return { label: 'prompt', dot: 'bg-amber-500', text: 'text-amber-600 dark:text-amber-400', ring: 'border-amber-500/30 bg-amber-500/10' };
default:
return { label: 'unknown', dot: 'bg-(--border-strong)', text: 'text-(--fg-subtle)', ring: 'border-(--border) bg-(--bg-inset)' };
}
});
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-300"
>
The Permissions API is not supported in this browser.
</div>
<template v-else>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Permission
</label>
<select
v-model="selected"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
<option v-for="name in names" :key="name" :value="name">
{{ name }}
</option>
</select>
</div>
<ul class="divide-y divide-(--border) rounded-xl border border-(--border) bg-(--bg-elevated)">
<li
v-for="perm in permissions"
:key="perm.name"
class="flex items-center justify-between gap-3 px-3 py-2.5"
>
<code class="font-mono text-sm text-(--fg)">{{ perm.name }}</code>
<span class="font-mono text-xs text-(--fg-muted)">{{ perm.state.value ?? 'unknown' }}</span>
</li>
</ul>
<div class="flex flex-col items-center gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
{{ selected }}
</span>
<span
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm font-semibold transition"
:class="[meta.ring, meta.text]"
>
<span class="h-2 w-2 rounded-full" :class="meta.dot" />
{{ meta.label }}
</span>
</div>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="active.query()"
>
Re-check "{{ selected }}"
</button>
<p class="text-center text-xs text-(--fg-subtle)">
Status updates live if you change a permission in your browser settings.
</p>
</template>
</div>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue';
import { usePreferredColorScheme } from './index';
const scheme = usePreferredColorScheme();
const options = [
{ value: 'light', label: 'Light', icon: '☀️', hint: 'prefers-color-scheme: light' },
{ value: 'dark', label: 'Dark', icon: '🌙', hint: 'prefers-color-scheme: dark' },
{ value: 'no-preference', label: 'No preference', icon: '🌓', hint: 'no explicit preference' },
] as const;
const active = computed(() => options.find(o => o.value === scheme.value));
// A tiny live preview that flips its own palette based on the OS preference,
// independent of the docs site theme.
const previewClass = computed(() =>
scheme.value === 'dark'
? 'bg-zinc-900 text-zinc-100 border-zinc-700'
: 'bg-white text-zinc-900 border-zinc-200',
);
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Preferred color scheme
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
<span class="size-1.5 rounded-full bg-emerald-500" />
live
</span>
</div>
<ul class="flex flex-col gap-2">
<li
v-for="option in options"
:key="option.value"
class="flex items-center gap-3 rounded-lg border px-3 py-2.5 transition"
:class="scheme === option.value
? 'border-(--accent) bg-(--accent-subtle)'
: 'border-(--border) bg-(--bg-elevated)'"
>
<span class="text-lg leading-none">{{ option.icon }}</span>
<span class="flex flex-1 flex-col">
<span
class="text-sm font-medium"
:class="scheme === option.value ? 'text-(--accent-text)' : 'text-(--fg)'"
>
{{ option.label }}
</span>
<span class="font-mono text-xs text-(--fg-subtle)">{{ option.hint }}</span>
</span>
<span
v-if="scheme === option.value"
class="text-(--accent-text)"
aria-hidden="true"
></span>
</li>
</ul>
<div
class="flex items-center gap-3 rounded-xl border p-4 transition-colors"
:class="previewClass"
>
<span class="text-2xl">{{ active?.icon }}</span>
<div class="flex flex-col">
<span class="text-sm font-semibold">Self-themed preview</span>
<span class="text-xs opacity-70">
Resolved to <span class="font-mono">{{ scheme }}</span>
</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
Read-only: change your OS appearance setting to see this update instantly.
</p>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue';
import { usePreferredContrast } from './index';
const contrast = usePreferredContrast({ ssrContrast: 'no-preference' });
const levels = [
{ value: 'more', label: 'More', desc: 'Heavier borders, stronger separation', query: 'prefers-contrast: more' },
{ value: 'less', label: 'Less', desc: 'Softer, lower-contrast surfaces', query: 'prefers-contrast: less' },
{ value: 'custom', label: 'Custom', desc: 'A user-defined contrast scheme', query: 'prefers-contrast: custom' },
{ value: 'no-preference', label: 'No preference', desc: 'Default system contrast', query: 'prefers-contrast: no-preference' },
] as const;
const active = computed(() => levels.find(l => l.value === contrast.value));
// Demonstrate adapting UI intensity to the reported contrast level.
const cardClass = computed(() => {
switch (contrast.value) {
case 'more':
return 'border-(--border-strong) bg-(--bg-inset)';
case 'less':
return 'border-(--border) bg-(--bg-subtle) opacity-90';
default:
return 'border-(--border) bg-(--bg-elevated)';
}
});
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Preferred contrast
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
{{ contrast }}
</span>
</div>
<div class="grid grid-cols-2 gap-2">
<div
v-for="level in levels"
:key="level.value"
class="flex flex-col gap-1 rounded-lg border px-3 py-2.5 transition"
:class="contrast === level.value
? 'border-(--accent) bg-(--accent-subtle)'
: 'border-(--border) bg-(--bg-elevated)'"
>
<span
class="text-sm font-medium"
:class="contrast === level.value ? 'text-(--accent-text)' : 'text-(--fg)'"
>
{{ level.label }}
</span>
<span class="text-xs leading-snug text-(--fg-muted)">{{ level.desc }}</span>
</div>
</div>
<div class="rounded-xl border p-4 transition-colors" :class="cardClass">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Adaptive surface
</span>
<p class="mt-1 text-sm text-(--fg)">
This card adjusts its borders and fill to match the
<span class="font-mono text-(--fg-muted)">{{ active?.query }}</span> level.
</p>
</div>
<p class="text-xs text-(--fg-subtle)">
Read-only: toggle your OS accessibility "increase / reduce contrast" setting to update.
</p>
</div>
</template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue';
import { usePreferredDark } from './index';
const isDark = usePreferredDark();
const label = computed(() => (isDark.value ? 'Dark' : 'Light'));
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
prefers-color-scheme: dark
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors"
:class="isDark
? 'border-indigo-500/30 bg-indigo-500/10 text-indigo-600 dark:text-indigo-300'
: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'"
>
{{ isDark ? 'true' : 'false' }}
</span>
</div>
<!-- A miniature sky scene that flips between day and night. -->
<div
class="relative flex h-40 items-end overflow-hidden rounded-xl border border-(--border) p-4 transition-colors duration-500"
:class="isDark
? 'bg-gradient-to-b from-slate-900 to-slate-700'
: 'bg-gradient-to-b from-sky-300 to-sky-100'"
>
<!-- Sun / Moon -->
<div
class="absolute right-5 top-5 size-12 rounded-full transition-all duration-500"
:class="isDark
? 'bg-zinc-200 shadow-[0_0_28px_6px_rgba(228,228,231,0.4)]'
: 'bg-amber-300 shadow-[0_0_36px_10px_rgba(252,211,77,0.6)]'"
/>
<!-- Stars, only at night -->
<Transition
enter-active-class="transition-opacity duration-700"
enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-300"
leave-to-class="opacity-0"
>
<div v-if="isDark" class="absolute inset-0">
<span class="absolute left-6 top-7 size-1 rounded-full bg-white" />
<span class="absolute left-20 top-12 size-0.5 rounded-full bg-white" />
<span class="absolute left-32 top-6 size-1 rounded-full bg-white" />
<span class="absolute left-12 top-16 size-0.5 rounded-full bg-white" />
<span class="absolute left-40 top-16 size-1 rounded-full bg-white" />
</div>
</Transition>
<span
class="relative z-10 text-2xl font-bold tabular-nums transition-colors duration-500"
:class="isDark ? 'text-zinc-100' : 'text-slate-800'"
>
{{ label }}
</span>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
const isDark = usePreferredDark()
<span class="text-(--fg-subtle)"> // </span>{{ isDark }}
</div>
<p class="text-xs text-(--fg-subtle)">
Read-only: switch your OS to dark/light mode to watch the scene change.
</p>
</div>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue';
import { usePreferredLanguages } from './index';
const languages = usePreferredLanguages();
// Resolve a human-friendly name + flag for each BCP-47 tag where possible.
const displayNames = computed(() => {
try {
return new Intl.DisplayNames(['en'], { type: 'language' });
}
catch {
return undefined;
}
});
function nameOf(tag: string): string {
return displayNames.value?.of(tag) ?? tag;
}
function flagOf(tag: string): string {
const region = tag.split('-')[1]?.toUpperCase();
if (!region || region.length !== 2)
return '🌐';
// Convert a 2-letter region code to a flag emoji.
return String.fromCodePoint(...[...region].map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
}
const primary = computed(() => languages.value[0] ?? 'en');
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
navigator.languages
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
{{ languages.length }} {{ languages.length === 1 ? 'locale' : 'locales' }}
</span>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Primary language
</span>
<div class="mt-1 flex items-center gap-2.5">
<span class="text-2xl leading-none">{{ flagOf(primary) }}</span>
<span class="flex flex-col">
<span class="text-base font-semibold text-(--fg)">{{ nameOf(primary) }}</span>
<span class="font-mono text-xs text-(--fg-subtle)">{{ primary }}</span>
</span>
</div>
</div>
<ol class="flex flex-col gap-2">
<li
v-for="(lang, index) in languages"
:key="`${lang}-${index}`"
class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
>
<span class="w-5 text-center font-mono text-xs text-(--fg-subtle) tabular-nums">
{{ index + 1 }}
</span>
<span class="text-lg leading-none">{{ flagOf(lang) }}</span>
<span class="flex flex-1 flex-col">
<span class="text-sm font-medium text-(--fg)">{{ nameOf(lang) }}</span>
<span class="font-mono text-xs text-(--fg-subtle)">{{ lang }}</span>
</span>
<span
v-if="index === 0"
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
>
preferred
</span>
</li>
</ol>
<p class="text-xs text-(--fg-subtle)">
Read-only: updates automatically on the browser's <span class="font-mono">languagechange</span> event.
</p>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue';
import { usePreferredReducedMotion } from './index';
const motion = usePreferredReducedMotion();
const reduced = computed(() => motion.value === 'reduce');
// Mirror the recommended pattern: derive a transition duration from the
// preference so animations are disabled when the user asks for reduced motion.
const duration = computed(() => (reduced.value ? 0 : 1.2));
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
prefers-reduced-motion
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors"
:class="reduced
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'"
>
{{ motion }}
</span>
</div>
<!-- Animated demo box: the orbiting dot pauses when motion is reduced. -->
<div class="flex h-40 items-center justify-center rounded-xl border border-(--border) bg-(--bg-inset)">
<div class="relative size-24">
<div class="absolute inset-0 rounded-full border-2 border-dashed border-(--border-strong)" />
<div
class="orbit absolute left-1/2 top-1/2 size-24"
:style="{ animationDuration: `${duration}s`, animationPlayState: reduced ? 'paused' : 'running' }"
>
<span class="absolute -left-2 -top-2 size-4 rounded-full bg-(--accent) shadow-lg" />
</div>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-2xl">{{ reduced ? '⏸' : '🎞️' }}</span>
</div>
</div>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Derived setting
</span>
<div class="mt-1 flex items-baseline gap-2">
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ reduced ? 0 : 1200 }}
</span>
<span class="text-sm text-(--fg-muted)">ms transition</span>
</div>
<p class="mt-2 text-sm text-(--fg-muted)">
{{ reduced
? 'Reduced motion requested — animations are disabled.'
: 'Full motion — animations run at normal speed.' }}
</p>
</div>
<p class="text-xs text-(--fg-subtle)">
Read-only: enable "Reduce motion" in your OS accessibility settings to pause the orbit.
</p>
</div>
</template>
<style scoped>
.orbit {
animation-name: spin;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue';
import { usePreferredReducedTransparency } from './index';
const transparency = usePreferredReducedTransparency();
const isReduced = computed(() => transparency.value === 'reduce');
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
prefers-reduced-transparency
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isReduced
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'"
>
<span
class="size-1.5 rounded-full"
:class="isReduced ? 'bg-amber-500' : 'bg-emerald-500'"
/>
{{ transparency }}
</span>
</div>
<!-- Live preview: a frosted glass panel that flattens when reduce is preferred -->
<div class="relative overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset) p-4">
<div
class="pointer-events-none absolute -left-6 -top-8 size-28 rounded-full bg-(--accent) opacity-60 blur-xl"
/>
<div
class="pointer-events-none absolute -bottom-10 right-2 size-24 rounded-full bg-sky-500 opacity-50 blur-xl"
/>
<div
class="relative rounded-lg border border-(--border) p-4 transition"
:class="isReduced
? 'bg-(--bg-elevated)'
: 'bg-(--bg-elevated)/60 backdrop-blur-md'"
>
<p class="text-sm font-medium text-(--fg)">
Glass card
</p>
<p class="mt-1 text-sm text-(--fg-muted)">
{{ isReduced
? 'Translucency removed for clarity.'
: 'Background blurs through the panel.' }}
</p>
</div>
</div>
<p class="text-xs leading-relaxed text-(--fg-subtle)">
Toggle <span class="font-mono text-(--fg-muted)">Reduce transparency</span> in your OS
accessibility settings to see this update live.
</p>
</div>
</template>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import { useScriptTag } from './index';
// A tiny, well-known public UMD script (no side effects beyond defining a global).
const src = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js';
const status = ref<'idle' | 'loading' | 'loaded' | 'error'>('idle');
const loadedAt = shallowRef<string | null>(null);
const { scriptTag, load, unload } = useScriptTag(
src,
() => {
status.value = 'loaded';
loadedAt.value = new Date().toLocaleTimeString();
},
{ manual: true },
);
async function onLoad() {
if (status.value === 'loading') return;
status.value = 'loading';
try {
await load();
}
catch {
status.value = 'error';
}
}
function onUnload() {
unload();
status.value = 'idle';
loadedAt.value = null;
}
const statusStyles = {
idle: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)',
loading: 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400',
loaded: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
error: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400',
} as const;
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-center justify-between gap-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Script status
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium capitalize"
:class="statusStyles[status]"
>
<span
class="size-1.5 rounded-full"
:class="{
'bg-(--fg-subtle)': status === 'idle',
'bg-sky-500 animate-pulse': status === 'loading',
'bg-emerald-500': status === 'loaded',
'bg-red-500': status === 'error',
}"
/>
{{ status }}
</span>
</div>
<div class="mt-3 truncate rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg-muted)">
{{ src }}
</div>
<dl class="mt-3 grid grid-cols-2 gap-2 text-sm">
<div>
<dt class="text-xs text-(--fg-subtle)">
&lt;script&gt; element
</dt>
<dd class="font-mono text-(--fg)">
{{ scriptTag ? 'present' : 'null' }}
</dd>
</div>
<div>
<dt class="text-xs text-(--fg-subtle)">
Loaded at
</dt>
<dd class="font-mono text-(--fg)">
{{ loadedAt ?? '—' }}
</dd>
</div>
</dl>
</div>
<div class="flex gap-2">
<button
type="button"
:disabled="status === 'loading' || status === 'loaded'"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="onLoad"
>
{{ status === 'loading' ? 'Loading…' : 'Load script' }}
</button>
<button
type="button"
:disabled="status !== 'loaded'"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="onUnload"
>
Unload
</button>
</div>
<p class="text-xs leading-relaxed text-(--fg-subtle)">
Manual mode the tag is injected into <span class="font-mono text-(--fg-muted)">&lt;head&gt;</span>
only when you click, and removed on unload.
</p>
</div>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useShare } from './index';
const payload = reactive({
title: 'Vue Toolkit',
text: 'A collection of essential Vue composables.',
url: 'https://vuejs.org',
});
const { share, isSupported } = useShare(payload);
const lastResult = ref<'shared' | 'dismissed' | null>(null);
async function onShare() {
lastResult.value = null;
try {
await share();
lastResult.value = 'shared';
}
catch {
// User dismissed the share sheet (AbortError) — treat as a non-error.
lastResult.value = 'dismissed';
}
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Web Share API
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span
class="size-1.5 rounded-full"
:class="isSupported ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
/>
{{ isSupported ? 'Supported' : 'Unsupported' }}
</span>
</div>
<div class="flex flex-col gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<label class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Title</span>
<input
v-model="payload.title"
type="text"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Text</span>
<input
v-model="payload.text"
type="text"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">URL</span>
<input
v-model="payload.url"
type="url"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</label>
</div>
<button
type="button"
:disabled="!isSupported"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="onShare"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
Share
</button>
<p v-if="!isSupported" class="text-xs leading-relaxed text-(--fg-subtle)">
The Web Share API is not available in this browser. It works on most mobile
browsers and Safari.
</p>
<p
v-else-if="lastResult"
class="text-xs font-medium"
:class="lastResult === 'shared'
? 'text-emerald-600 dark:text-emerald-400'
: 'text-(--fg-muted)'"
>
{{ lastResult === 'shared' ? 'Content shared.' : 'Share sheet dismissed.' }}
</p>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useStyleTag } from './index';
const initialCss = `.styletag-demo-box {
background: linear-gradient(135deg, #6366f1, #ec4899);
color: white;
letter-spacing: 0.05em;
}`;
const { id, css, isLoaded, load, unload } = useStyleTag(initialCss, { immediate: false });
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Injected style tag
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isLoaded
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span
class="size-1.5 rounded-full"
:class="isLoaded ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
/>
{{ isLoaded ? 'Loaded' : 'Unloaded' }}
</span>
</div>
<!-- Live target affected by the injected stylesheet -->
<div class="rounded-xl border border-(--border) bg-(--bg-inset) p-4">
<div
class="styletag-demo-box rounded-lg border border-(--border) bg-(--bg-elevated) px-4 py-6 text-center text-sm font-semibold text-(--fg) transition-all"
>
Styled by &lt;style id="{{ id }}"&gt;
</div>
</div>
<label class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">CSS source</span>
<textarea
v-model="css"
rows="5"
spellcheck="false"
class="w-full resize-none rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-xs leading-relaxed text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
/>
</label>
<div class="flex gap-2">
<button
type="button"
:disabled="isLoaded"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="load"
>
Load
</button>
<button
type="button"
:disabled="!isLoaded"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="unload"
>
Unload
</button>
</div>
<p class="text-xs leading-relaxed text-(--fg-subtle)">
Editing the CSS updates the live stylesheet instantly while loaded.
</p>
</div>
</template>
@@ -1,54 +1,84 @@
<script setup lang="ts">import { useTabLeader } from './index';
<script setup lang="ts">
import { useTabLeader } from './index';
const { isLeader, isSupported, acquire, release } = useTabLeader('docs-demo-leader');
const { isLeader, isSupported, acquire, release } = useTabLeader('vue-toolkit-demo-leader');
</script>
<template>
<div class="space-y-4">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-(--color-text-soft)">Web Locks API:</span>
<span
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
:class="isSupported ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700' : 'border-red-500/30 bg-red-500/10 text-red-700'"
>
{{ isSupported ? 'Supported' : 'Not supported' }}
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Web Locks election
</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-(--color-text-soft)">Leader status:</span>
<span
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
:class="isLeader ? 'border-brand-500/30 bg-brand-500/10 text-brand-600' : 'border-(--color-border) bg-(--color-bg-mute) text-(--color-text-soft)'"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span
class="w-2 h-2 rounded-full"
:class="isLeader ? 'bg-brand-500 animate-pulse' : 'bg-(--color-text-mute)'"
class="size-1.5 rounded-full"
:class="isSupported ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
/>
{{ isLeader ? 'Leader' : 'Follower' }}
{{ isSupported ? 'Supported' : 'Unsupported' }}
</span>
</div>
<p class="text-xs text-(--color-text-mute)">
Open this page in multiple tabs only one will be the leader.
Close the leader tab and another will take over automatically.
</p>
<!-- Primary leader state -->
<div
class="flex flex-col items-center gap-2 rounded-xl border p-6 transition-colors"
:class="isLeader
? 'border-(--accent) bg-(--accent-subtle)'
: 'border-(--border) bg-(--bg-elevated)'"
>
<div
class="flex size-12 items-center justify-center rounded-full transition-colors"
:class="isLeader
? 'bg-(--accent) text-(--accent-fg)'
: 'bg-(--bg-inset) text-(--fg-subtle)'"
>
<svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m12 2 3 7h7l-5.5 4.5L18.5 21 12 16.5 5.5 21 7.5 13.5 2 9h7z" />
</svg>
</div>
<p
class="text-lg font-bold"
:class="isLeader ? 'text-(--accent-text)' : 'text-(--fg)'"
>
{{ isLeader ? 'Leader tab' : 'Follower tab' }}
</p>
<p class="text-center text-xs text-(--fg-muted)">
{{ isLeader
? 'This tab holds the lock and would run exclusive work.'
: 'Another tab is the leader, or leadership was released.' }}
</p>
</div>
<div class="flex items-center gap-2 pt-2">
<div class="flex gap-2">
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
:disabled="!isSupported || isLeader"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="acquire"
>
Acquire
</button>
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
:disabled="!isSupported || !isLeader"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="release"
>
Release
</button>
</div>
<p v-if="!isSupported" class="text-xs leading-relaxed text-(--fg-subtle)">
The Web Locks API is not available in this browser.
</p>
<p v-else class="text-xs leading-relaxed text-(--fg-subtle)">
Open this page in a second tab only one tab is the leader at a time. Release
here and watch another tab take over.
</p>
</div>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useTextareaAutosize } from './index';
const maxHeight = ref(220);
const resizes = ref(0);
const { textarea, input, triggerResize } = useTextareaAutosize({
maxHeight,
onResize: () => resizes.value++,
});
input.value = 'Type here and watch the textarea grow with your content.\n\nIt re-fits on every keystroke, on programmatic changes, and when the available width changes (try resizing the panel).';
function loadSample(): void {
input.value = [
'Release notes — v0.0.15',
'',
'- useTextareaAutosize now reacts to width reflow',
'- Title sync is SSR-safe',
'- URL params decode repeated keys to arrays',
].join('\n');
}
function clear(): void {
input.value = '';
}
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Auto-growing textarea
</label>
<textarea
ref="textarea"
v-model="input"
placeholder="Start typing…"
rows="1"
class="w-full resize-none overflow-y-auto rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm leading-relaxed text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
/>
</div>
<div class="flex flex-col gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Max height
</span>
<span class="font-mono text-sm tabular-nums text-(--fg)">{{ maxHeight }}px</span>
</div>
<input
v-model.number="maxHeight"
type="range"
min="80"
max="400"
step="20"
class="w-full accent-(--accent)"
>
<div class="flex items-center justify-between border-t border-(--border) pt-2 text-xs">
<span class="text-(--fg-muted)">{{ input.length }} chars</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 font-medium text-(--fg-muted)">
{{ resizes }} resizes
</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="loadSample"
>
Load sample
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="triggerResize"
>
Trigger resize
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!input"
@click="clear"
>
Clear
</button>
</div>
</div>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useTitle } from './index';
const appName = ref('Toolkit');
// Two-way bound to document.title, formatted through the template.
const title = useTitle('Dashboard', {
titleTemplate: (t) => `${t} · ${appName.value}`,
});
const presets = ['Dashboard', 'Inbox', 'Settings', 'Billing'];
// Re-apply the template when the app name changes by re-writing the title.
watch(appName, () => {
// eslint-disable-next-line no-self-assign
title.value = title.value;
});
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Live document title
</span>
<div class="mt-2 flex items-center gap-2 rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2.5">
<svg viewBox="0 0 24 24" fill="none" class="size-4 shrink-0 text-(--fg-subtle)">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.5" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" stroke="currentColor" stroke-width="1.5" />
</svg>
<span class="truncate font-mono text-sm text-(--fg)">
{{ title || 'Untitled' }} · {{ appName }}
</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Page title
</label>
<input
v-model="title"
type="text"
placeholder="Enter a page title"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
App name (template suffix)
</label>
<input
v-model="appName"
type="text"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset"
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
:class="title === preset ? 'border-(--accent) text-(--accent-text)' : ''"
@click="title = preset"
>
{{ preset }}
</button>
</div>
<p class="text-xs text-(--fg-subtle)">
Check your browser tab it updates in real time.
</p>
</div>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useUrlSearchParams } from './index';
interface Filters {
q: string;
sort: string;
tags: string[];
}
// Reactive object mirrored to the URL query string. Mutate it to update the URL.
const params = useUrlSearchParams<Filters>('history', {
initialValue: { q: 'vue', sort: 'recent', tags: ['ui'] },
removeFalsyValues: true,
});
const sorts = ['recent', 'popular', 'name'];
const allTags = ['ui', 'browser', 'animation', 'sensors'];
const activeTags = computed<string[]>(() =>
Array.isArray(params.tags) ? params.tags : params.tags ? [params.tags] : [],
);
function toggleTag(tag: string): void {
const next = new Set(activeTags.value);
if (next.has(tag))
next.delete(tag);
else
next.add(tag);
params.tags = [...next];
}
const queryString = computed(() => {
const usp = new URLSearchParams();
if (params.q)
usp.set('q', params.q);
if (params.sort)
usp.set('sort', params.sort);
for (const t of activeTags.value)
usp.append('tags', t);
const s = usp.toString();
return s ? `?${s}` : '(empty)';
});
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Search query
</label>
<input
v-model="params.q"
type="text"
placeholder="Search…"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Sort by</span>
<div class="flex flex-wrap gap-2">
<button
v-for="s in sorts"
:key="s"
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="params.sort === s
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="params.sort = s"
>
{{ s }}
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Tags (repeated keys array)
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in allTags"
:key="tag"
type="button"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition cursor-pointer"
:class="activeTags.includes(tag)
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted) hover:border-(--border-strong)'"
@click="toggleTag(tag)"
>
#{{ tag }}
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Live URL query
</span>
<div class="overflow-x-auto rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<span class="whitespace-nowrap">{{ queryString }}</span>
</div>
<p class="text-xs text-(--fg-subtle)">
The browser address bar updates as you edit. Falsy values are dropped.
</p>
</div>
</div>
</template>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useVibrate } from './index';
const presets: Record<string, number[]> = {
Pulse: [200],
Double: [100, 60, 100],
SOS: [100, 60, 100, 60, 100, 200, 250, 60, 250, 60, 250, 200, 100, 60, 100, 60, 100],
Heartbeat: [120, 100, 120, 500],
};
const { isSupported, pattern, vibrate, stop, intervalControls } = useVibrate({
pattern: presets.Double,
interval: 1500,
});
const activePreset = ref('Double');
const looping = intervalControls?.isActive;
function applyPreset(name: string): void {
activePreset.value = name;
pattern.value = presets[name];
vibrate();
}
function toggleLoop(): void {
if (intervalControls?.isActive.value)
stop();
else
intervalControls?.resume();
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-300"
>
The Vibration API is not supported in this browser. Try a mobile device.
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Current pattern (ms)
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
{{ isSupported ? 'supported' : 'unsupported' }}
</span>
</div>
<div class="mt-2 overflow-x-auto rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
[{{ Array.isArray(pattern) ? pattern.join(', ') : pattern }}]
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Presets</span>
<div class="grid grid-cols-2 gap-2">
<button
v-for="(_, name) in presets"
:key="name"
type="button"
:disabled="!isSupported"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:class="activePreset === name
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="applyPreset(name)"
>
{{ name }}
</button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
:disabled="!isSupported"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="vibrate()"
>
Vibrate now
</button>
<button
type="button"
:disabled="!isSupported"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="toggleLoop"
>
{{ looping ? 'Stop loop' : 'Loop every 1.5s' }}
</button>
<button
type="button"
:disabled="!isSupported"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="stop"
>
Stop
</button>
</div>
</div>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useWakeLock } from './index';
const { isSupported, isActive, sentinel, request, release } = useWakeLock();
const error = ref<string | null>(null);
async function toggle(): Promise<void> {
error.value = null;
try {
if (sentinel.value)
await release();
else
await request('screen');
}
catch (e) {
error.value = e instanceof Error ? e.message : String(e);
}
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-300"
>
The Screen Wake Lock API is not supported in this browser.
</div>
<div class="flex flex-col items-center gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-6">
<div
class="flex size-16 items-center justify-center rounded-full border transition"
:class="isActive
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-subtle)'"
>
<svg viewBox="0 0 24 24" fill="none" class="size-7">
<rect x="4" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5" />
<path d="M8 21h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path v-if="isActive" d="m9 9 2 2 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="text-center">
<p class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ isActive ? 'AWAKE' : 'IDLE' }}
</p>
<p class="mt-1 text-xs text-(--fg-muted)">
{{ isActive ? 'Screen will stay on' : 'Screen may sleep normally' }}
</p>
</div>
</div>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2.5 text-sm">
<span class="text-(--fg-muted)">Lock held</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-elevated) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
{{ sentinel ? 'yes' : 'none' }}
</span>
</div>
<button
type="button"
:disabled="!isSupported"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="toggle"
>
{{ sentinel ? 'Release wake lock' : 'Request wake lock' }}
</button>
<p v-if="error" class="text-xs text-red-600 dark:text-red-400">
{{ error }}
</p>
<p v-else class="text-xs text-(--fg-subtle)">
The lock auto-releases when the tab is hidden and re-acquires when visible again.
</p>
</div>
</template>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useWebNotification } from './index';
const title = ref('New message from Ada');
const body = ref('Hey — the deploy is green. Ship it whenever you are ready.');
const lastEvent = ref<string>('');
const {
isSupported,
notification,
permissionGranted,
show,
close,
ensurePermissionGranted,
onClick,
onShow,
onClose,
onError,
} = useWebNotification({
// Don't prompt on mount — wait for an explicit user gesture below.
requestPermissions: false,
icon: 'https://vuejs.org/images/logo.png',
requireInteraction: false,
});
onShow(() => (lastEvent.value = 'shown'));
onClick(() => (lastEvent.value = 'clicked'));
onClose(() => (lastEvent.value = 'closed'));
onError(() => (lastEvent.value = 'error'));
async function requestPermission() {
await ensurePermissionGranted();
}
function notify() {
show({ title: title.value, body: body.value });
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400"
>
Notifications are not supported in this browser.
</div>
<template v-else>
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div>
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Permission
</div>
<div class="mt-1 flex items-center gap-2 text-sm text-(--fg-muted)">
<span
class="inline-block size-2 rounded-full transition"
:class="permissionGranted ? 'bg-emerald-500' : 'bg-(--border-strong)'"
/>
{{ permissionGranted ? 'Granted' : 'Not granted' }}
</div>
</div>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="permissionGranted"
@click="requestPermission"
>
{{ permissionGranted ? 'Allowed' : 'Request access' }}
</button>
</div>
<div class="flex flex-col gap-3">
<label class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Title</span>
<input
v-model="title"
type="text"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Body</span>
<textarea
v-model="body"
rows="2"
class="w-full resize-none rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
/>
</label>
</div>
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!permissionGranted"
@click="notify"
>
Show notification
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!notification"
@click="close"
>
Close
</button>
</div>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-sm">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Last event</span>
<span class="font-mono text-(--fg)">{{ lastEvent || '—' }}</span>
</div>
<p v-if="!permissionGranted" class="text-xs text-(--fg-subtle)">
Grant access first, then trigger a notification. Switch back to this tab and it auto-closes.
</p>
</template>
</div>
</template>