Files
tilde/index.html
T
2026-07-13 09:13:15 -07:00

1012 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<meta charset="utf-8" />
<meta name="color-scheme" content="dark light" />
<meta name="robots" content="noindex" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>~</title>
<style>
@font-face {
font-family: 'SpaceGrotesk';
font-style: normal;
font-weight: normal;
src: url('./fonts/SpaceGrotesk-Regular.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'SpaceGrotesk';
font-style: normal;
font-weight: bold;
src: url('./fonts/SpaceGrotesk-Bold.woff2') format('woff2');
font-display: swap;
}
:root {
--color-background: #111;
--color-text-subtle: #888;
--color-text: #eee;
--font-family: SpaceGrotesk, -apple-system, Helvetica, sans-serif;
--font-size: clamp(16px, 1.5vw, 18px);
--color-accent: #e34;
--transition-speed: 200ms;
}
@media (prefers-color-scheme: light) {
:root {
--color-background: #e8e8e8;
--color-text-subtle: #606060;
--color-text: #111;
}
}
</style>
<script type="module">
/** @type {AppConfig} */
const CONFIG = {
commandPathDelimiter: '/',
commandSearchDelimiter: ' ',
defaultSearchTemplate: 'https://duckduckgo.com/?q={}',
openLinksInNewTab: true,
suggestionLimit: 4,
};
// named entries appear on the dial, in insertion order. suggestions are curated.
/** @type {Map<string, Command>} */
// prettier-ignore
const COMMANDS = new Map([
['a', { name: 'AI', suggestions: ['a/chatgpt', 'a/gemini', 'a/grok', 'a/lumo'], url: 'https://claude.ai/new' }],
['a/chatgpt', { url: 'https://chatgpt.com' }],
['a/gemini', { url: 'https://gemini.google.com' }],
['a/grok', { url: 'https://grok.com' }],
['a/lumo', { url: 'https://lumo.proton.me/u/0' }],
['c', { name: 'Cloudflare', url: 'https://dash.cloudflare.com' }],
['d', { name: 'Discord', url: 'https://discord.com/channels/@me' }],
['e', { name: 'PostHog', url: 'https://us.posthog.com' }],
['f', { name: 'Figma', url: 'https://www.figma.com' }],
['g', { name: 'GitHub', searchTemplate: '/search?q={}', suggestions: ['g/copilot', 'g/trending'], url: 'https://github.com' }],
['h', { name: 'Hetzner', url: 'https://console.hetzner.cloud/projects' }],
['i', { name: 'InstantDB', url: 'https://instantdb.com/dash' }],
['l', { name: 'LinkedIn', url: 'https://www.linkedin.com/feed/' }],
['m', { name: 'Modal', url: 'https://modal.com/apps' }],
['n', { name: 'Pinecone', url: 'https://app.pinecone.io' }],
['o', { name: 'OpenRouter', url: 'https://openrouter.ai/models' }],
['p', { name: 'Proton', suggestions: ['p/drive', 'p/pass'], url: 'https://mail.proton.me/u/0/inbox' }],
['p/drive', { url: 'https://drive.proton.me/u/0' }],
['p/pass', { url: 'https://pass.proton.me/u/0' }],
['q', { name: 'QBO', url: 'https://qbo.intuit.com' }],
['r', { name: 'Reddit', url: 'https://www.reddit.com' }],
['s', { name: 'Supabase', url: 'https://supabase.com/dashboard/organizations' }],
['t', { name: 'TickTick', url: 'https://ticktick.com/webapp/#q/today/tasks' }],
['u', { name: 'Duolingo', url: 'https://www.duolingo.com/learn' }],
['v', { name: 'Vercel', url: 'https://vercel.com/dashboard' }],
['x', { name: '𝕏', suggestions: ['x/dev', 'x/n8n', 'x/nginx', 'x/wg'], url: 'https://x.com/home' }],
['x/bazarr', { url: 'https://bazarr.xvyz.co' }],
['x/dev', { url: 'https://dev.xvyz.co' }],
['x/jellyfin', { url: 'https://jellyfin.xvyz.co' }],
['x/n8n', { url: 'https://n8n.xvyz.co/home/workflows' }],
['x/nginx', { url: 'https://nginx.xvyz.co' }],
['x/nzbget', { url: 'https://nzbget.xvyz.co' }],
['x/profilarr', { url: 'https://profilarr.xvyz.co' }],
['x/prowlarr', { url: 'https://prowlarr.xvyz.co' }],
['x/radarr', { url: 'https://radarr.xvyz.co' }],
['x/seerr', { url: 'https://seerr.xvyz.co' }],
['x/sonarr', { url: 'https://sonarr.xvyz.co' }],
['x/tracearr', { url: 'https://tracearr.xvyz.co' }],
['x/wg', { url: 'https://wg.xvyz.co' }],
['y', { name: 'YouTube', searchTemplate: '/results?search_query={}', url: 'https://www.youtube.com/feed/subscriptions' }],
['0', { name: 'localhost', url: 'http://localhost:3000' }],
]);
/**
* @typedef {object} AppConfig
* @property {string} commandPathDelimiter
* @property {string} commandSearchDelimiter
* @property {string} defaultSearchTemplate
* @property {boolean} openLinksInNewTab
* @property {number} suggestionLimit
*/
/**
* @typedef {object} Command
* @property {string} url
* @property {string=} name
* @property {string=} searchTemplate
* @property {string[]=} suggestions
*/
/**
* @typedef {object} ParsedQuery
* @property {string} query
* @property {string} url
* @property {string=} key
* @property {string=} path
* @property {string=} search
* @property {string=} splitBy
*/
/**
* @param {string} id
* @returns {HTMLTemplateElement}
*/
function getTemplate(id) {
const template = document.getElementById(id);
if (!(template instanceof HTMLTemplateElement)) {
throw new Error(`Template #${id} not found`);
}
return template;
}
const TEMPLATES = {
command: getTemplate('command-template'),
commands: getTemplate('commands-template'),
match: getTemplate('match-template'),
search: getTemplate('search-template'),
suggestion: getTemplate('suggestion-template'),
};
/**
* @param {string} value
* @returns {string}
*/
function escapeRegexCharacters(value) {
return value.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
/**
* @param {string} template
* @param {string} search
* @returns {string}
*/
function formatSearchUrl(template, search) {
return template.replace(/{}/g, encodeURIComponent(search));
}
/**
* @param {string} value
* @returns {boolean}
*/
function hasProtocol(value) {
return /^[a-zA-Z]+:\/\//i.test(value);
}
/**
* @param {string} value
* @returns {boolean}
*/
function isUrl(value) {
return /^((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*)?)$/i.test(value);
}
/**
* @param {string} value
* @param {string} delimiter
* @returns {[string, string?]}
*/
function splitOnce(value, delimiter) {
const delimiterIndex = value.indexOf(delimiter);
if (delimiterIndex === -1) return [value];
return [
value.slice(0, delimiterIndex),
value.slice(delimiterIndex + delimiter.length),
];
}
/**
* @param {string} raw
* @param {Map<string, Command>} commands
* @param {AppConfig} config
* @returns {ParsedQuery}
*/
function parseQuery(raw, commands, config) {
const query = raw.trim();
if (isUrl(query)) {
const url = hasProtocol(query) ? query : `https://${query}`;
return { query, url };
}
if (commands.has(query)) {
const { url } = commands.get(query);
return { key: query, query, url };
}
let splitBy = config.commandSearchDelimiter;
const [searchKey, rawSearch] = splitOnce(query, splitBy);
if (commands.has(searchKey)) {
const command = commands.get(searchKey);
const template = new URL(command.searchTemplate ?? '', command.url);
const search = rawSearch.trim();
const url = formatSearchUrl(decodeURI(template.href), search);
return { key: searchKey, query, search, splitBy, url };
}
splitBy = config.commandPathDelimiter;
const [pathKey, path] = splitOnce(query, splitBy);
if (commands.has(pathKey)) {
const command = commands.get(pathKey);
const url = `${new URL(command.url).origin}/${path}`;
return { key: pathKey, path, query, splitBy, url };
}
const url = formatSearchUrl(config.defaultSearchTemplate, query);
return { query, search: query, url };
}
/**
* @param {ParsedQuery} parsedQuery
* @param {Map<string, Command>} commands
* @param {AppConfig} config
* @returns {string[]}
*/
function collectLocalSuggestions(parsedQuery, commands, config) {
const parentKey = parsedQuery.query.split(config.commandPathDelimiter)[0];
const exactMatch = commands.has(parsedQuery.query);
let suggestions = [
...(commands.get(parsedQuery.query)?.suggestions ??
commands.get(parentKey)?.suggestions ??
[]),
];
if (exactMatch) {
suggestions = suggestions.filter((suggestion) =>
suggestion.startsWith(parsedQuery.query)
);
}
for (const [key, command] of commands.entries()) {
if (key !== parsedQuery.query && !suggestions.includes(key)) {
if (key.includes(parsedQuery.query)) {
suggestions.push(key);
}
}
for (const suggestion of command.suggestions ?? []) {
if (
suggestion !== parsedQuery.query &&
!suggestions.includes(suggestion)
) {
if (suggestion.includes(parsedQuery.query)) {
suggestions.push(suggestion);
}
}
}
}
return suggestions.filter((suggestion) =>
suggestion.includes(parsedQuery.query)
);
}
/**
* @param {string} search
* @returns {Promise<string[]>}
*/
function fetchDuckDuckGoSuggestions(search) {
return new Promise((resolve) => {
window.autocompleteCallback = (response) => {
const suggestions = [];
for (const item of response) {
if (item.phrase === search.toLowerCase()) continue;
suggestions.push(item.phrase);
}
resolve(suggestions);
};
const script = document.createElement('script');
document.querySelector('head').appendChild(script);
script.src = `https://duckduckgo.com/ac/?callback=autocompleteCallback&q=${search}`;
script.onload = script.remove;
script.onerror = () => {
script.remove();
resolve([]);
};
});
}
class CommandRing extends HTMLElement {
#clockInterval = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
const componentFragment = TEMPLATES.commands.content.cloneNode(true);
const commands = componentFragment.querySelector('.commands');
const entries = [...COMMANDS.entries()].filter(
([, { name, url }]) => name && url
);
const total = entries.length;
commands.style.setProperty(
'--item-size',
Math.sin(Math.PI / total) / 0.8
);
for (const [index, [key, { name, url }]] of entries.entries()) {
const commandFragment = TEMPLATES.command.content.cloneNode(true);
const item = commandFragment.querySelector('li');
const angle = (index / total) * 2 * Math.PI - Math.PI / 2;
item.style.setProperty('--x', 0.5 + 0.5 * Math.cos(angle));
item.style.setProperty('--y', 0.5 + 0.5 * Math.sin(angle));
const commandLink = commandFragment.querySelector('.command');
commandLink.href = url;
commandLink.setAttribute('aria-label', name);
if (CONFIG.openLinksInNewTab) commandLink.target = '_blank';
commandFragment.querySelector('.key').textContent = key;
commandFragment.querySelector('.name').textContent = name;
commands.append(commandFragment);
}
this.shadowRoot.append(componentFragment);
const items = commands.querySelectorAll('li');
for (const [index] of entries.entries()) {
const item = items[index];
item.addEventListener('mouseenter', () => {
for (const [otherIndex] of entries.entries()) {
const distance = Math.min(
Math.abs(otherIndex - index),
total - Math.abs(otherIndex - index)
);
if (distance === 0) {
items[otherIndex].style.transform =
'translate(-50%, -50%) scale(1)';
} else if (distance === 1) {
items[otherIndex].style.opacity = '0.25';
} else if (distance === 2) {
items[otherIndex].style.opacity = '0.5';
} else if (distance === 3) {
items[otherIndex].style.opacity = '0.75';
}
}
});
item.addEventListener('mouseleave', () => {
for (const otherItem of items) {
otherItem.style.opacity = '';
otherItem.style.transform = '';
}
});
}
}
connectedCallback() {
this.#startClock();
}
disconnectedCallback() {
if (this.#clockInterval === null) return;
clearInterval(this.#clockInterval);
this.#clockInterval = null;
}
#startClock() {
if (this.#clockInterval !== null) return;
const hour = this.shadowRoot.querySelector('.hand--hour');
const minute = this.shadowRoot.querySelector('.hand--minute');
const second = this.shadowRoot.querySelector('.hand--second');
const tick = () => {
const now = new Date();
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
const seconds = now.getSeconds();
second.style.transform = `rotate(${seconds * 6}deg)`;
minute.style.transform = `rotate(${(minutes + seconds / 60) * 6}deg)`;
hour.style.transform = `rotate(${(hours + minutes / 60) * 30}deg)`;
};
tick();
this.#clockInterval = setInterval(tick, 1000);
}
}
class SearchOverlay extends HTMLElement {
#dialog;
#form;
#input;
#suggestions;
#lastSuggestions = [];
#closeController = null;
#documentController = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
const componentFragment = TEMPLATES.search.content.cloneNode(true);
this.#dialog = componentFragment.querySelector('.dialog');
this.#form = componentFragment.querySelector('.form');
this.#input = componentFragment.querySelector('.input');
this.#suggestions = componentFragment.querySelector('.suggestions');
this.#form.addEventListener('submit', this.#onSubmit, false);
this.#input.addEventListener('input', this.#onInput);
this.#suggestions.addEventListener('click', this.#onSuggestionClick);
this.shadowRoot.append(componentFragment);
}
connectedCallback() {
if (this.#documentController) return;
this.#documentController = new AbortController();
const { signal } = this.#documentController;
document.addEventListener('keydown', this.#onKeydown, { signal });
document.addEventListener('paste', this.#onPaste, { signal });
}
disconnectedCallback() {
this.#documentController?.abort();
this.#documentController = null;
this.#cancelPendingClose();
}
#cancelPendingClose() {
this.#closeController?.abort();
this.#closeController = null;
}
#reset() {
this.#input.value = '';
this.#input.blur();
this.#suggestions.innerHTML = '';
}
#prepareOpen() {
this.#cancelPendingClose();
this.#dialog.show();
this.#input.focus();
}
#open() {
this.#prepareOpen();
requestAnimationFrame(() => this.#dialog.classList.add('visible'));
}
#close() {
this.#cancelPendingClose();
this.#closeController = new AbortController();
this.#dialog.classList.remove('visible');
this.#reset();
this.#dialog.addEventListener(
'transitionend',
() => {
if (!this.#dialog.classList.contains('visible')) {
this.#dialog.close();
}
this.#closeController = null;
},
{ once: true, signal: this.#closeController.signal }
);
}
#hideImmediately() {
this.#cancelPendingClose();
this.#reset();
this.#dialog.close();
}
#execute(query) {
const target = CONFIG.openLinksInNewTab ? '_blank' : '_self';
const parsedQuery = parseQuery(query, COMMANDS, CONFIG);
window.open(parsedQuery.url, target, 'noopener noreferrer');
this.#close();
}
#focusNextSuggestion(previous = false) {
const activeElement = this.shadowRoot.activeElement;
let nextIndex;
if (activeElement.dataset.index) {
const activeIndex = Number(activeElement.dataset.index);
nextIndex = previous ? activeIndex - 1 : activeIndex + 1;
} else {
nextIndex = previous ? this.#suggestions.childElementCount - 1 : 0;
}
const nextItem = this.#suggestions.children[nextIndex];
if (nextItem) nextItem.querySelector('.suggestion').focus();
else this.#input.focus();
}
#onInput = async () => {
const parsedQuery = parseQuery(this.#input.value, COMMANDS, CONFIG);
if (!parsedQuery.query) {
this.#close();
return;
}
let suggestions = collectLocalSuggestions(parsedQuery, COMMANDS, CONFIG);
const suggestionsWithStale = [
...suggestions,
...this.#lastSuggestions.filter((suggestion) =>
suggestion.includes(parsedQuery.query)
),
].slice(0, CONFIG.suggestionLimit);
if (suggestionsWithStale.length) {
this.#renderSuggestions(suggestionsWithStale, parsedQuery.query);
} else if (!(parsedQuery.search?.length > 1)) {
this.#suggestions.innerHTML = '';
}
if (parsedQuery.search?.length > 1) {
const results = await fetchDuckDuckGoSuggestions(parsedQuery.search);
const currentQuery = parseQuery(this.#input.value, COMMANDS, CONFIG);
if (currentQuery.query !== parsedQuery.query) return;
const duckDuckGoSuggestions = parsedQuery.splitBy
? results.map(
(search) => `${parsedQuery.key}${parsedQuery.splitBy}${search}`
)
: results;
this.#lastSuggestions = duckDuckGoSuggestions;
suggestions = [...suggestions, ...duckDuckGoSuggestions].slice(
0,
CONFIG.suggestionLimit
);
if (suggestions.length) {
this.#renderSuggestions(suggestions, parsedQuery.query);
} else {
this.#suggestions.innerHTML = '';
}
}
};
#onPaste = (event) => {
if (this.#dialog.classList.contains('visible')) return;
const text = event.clipboardData?.getData('text');
if (!text) return;
event.preventDefault();
this.#open();
this.#input.value = text;
this.#onInput();
};
#onKeydown = (event) => {
if (!this.#dialog.classList.contains('visible')) {
if (event.key === 'Escape') {
if (this.#dialog.open) {
this.#hideImmediately();
}
return;
}
this.#prepareOpen();
requestAnimationFrame(() => {
// close the search dialog before the next repaint if a character is
// not produced (e.g. if you type shift, control, alt etc.)
if (!this.#input.value) {
this.#hideImmediately();
} else {
this.#dialog.classList.add('visible');
}
});
return;
}
if (event.key === 'Escape') {
this.#close();
return;
}
const alt = event.altKey ? 'alt-' : '';
const ctrl = event.ctrlKey ? 'ctrl-' : '';
const meta = event.metaKey ? 'meta-' : '';
const shift = event.shiftKey ? 'shift-' : '';
const modifierPrefixedKey = `${alt}${ctrl}${meta}${shift}${event.key}`;
if (/^(ArrowDown|Tab|ctrl-n)$/.test(modifierPrefixedKey)) {
event.preventDefault();
this.#focusNextSuggestion();
return;
}
if (/^(ArrowUp|ctrl-p|shift-Tab)$/.test(modifierPrefixedKey)) {
event.preventDefault();
this.#focusNextSuggestion(true);
}
};
#onSubmit = () => {
this.#execute(this.#input.value);
};
#onSuggestionClick = (event) => {
const button = event.target.closest('.suggestion');
if (!button) return;
this.#execute(button.dataset.suggestion);
};
#renderSuggestions(suggestions, query) {
const focusedSuggestion =
this.shadowRoot.activeElement?.dataset?.suggestion;
this.#suggestions.innerHTML = '';
const pattern = new RegExp(escapeRegexCharacters(query), 'i');
for (const [index, suggestion] of suggestions.entries()) {
const suggestionFragment = TEMPLATES.suggestion.content.cloneNode(true);
const button = suggestionFragment.querySelector('.suggestion');
button.dataset.index = index;
button.dataset.suggestion = suggestion;
const matched = suggestion.match(pattern);
if (matched) {
const matchFragment = TEMPLATES.match.content.cloneNode(true);
const match = matchFragment.querySelector('.match');
const beforeMatch = suggestion.slice(0, matched.index);
const afterMatch = suggestion.slice(
matched.index + matched[0].length
);
match.textContent = matched[0];
match.insertAdjacentText('beforebegin', beforeMatch);
match.insertAdjacentText('afterend', afterMatch);
button.append(matchFragment);
} else {
button.textContent = suggestion;
}
this.#suggestions.append(suggestionFragment);
}
if (focusedSuggestion) {
const buttonToRefocus = this.#suggestions.querySelector(
`[data-suggestion="${CSS.escape(focusedSuggestion)}"]`
);
buttonToRefocus?.focus();
}
}
}
customElements.define('commands-component', CommandRing);
customElements.define('search-component', SearchOverlay);
</script>
<template id="commands-template">
<style>
nav {
--dial-size: min(55vh, 55vw);
align-items: center;
box-sizing: border-box;
display: flex;
justify-content: center;
min-height: 100dvh;
position: relative;
width: 100%;
}
.commands {
aspect-ratio: 1;
height: var(--dial-size);
list-style: none;
margin: 0;
padding: 0;
position: relative;
}
.commands li {
border-radius: 50%;
height: calc(var(--item-size) * 100%);
left: calc(var(--x) * 100%);
position: absolute;
top: calc(var(--y) * 100%);
transform: translate(-50%, -50%) scale(0.8);
transition:
opacity var(--transition-speed),
transform var(--transition-speed);
width: calc(var(--item-size) * 100%);
}
.command {
align-items: center;
border-radius: 50%;
color: var(--color-text);
display: flex;
height: 100%;
justify-content: center;
outline: 0;
position: relative;
text-align: center;
text-decoration: none;
width: 100%;
}
.key {
color: var(--color-text-subtle);
position: absolute;
transition:
opacity var(--transition-speed),
transform var(--transition-speed);
}
.command:where(:focus, :hover) .key {
opacity: 0;
pointer-events: none;
transform: translateY(1.5em) scale(0.9);
}
.name {
opacity: 0;
pointer-events: none;
transform: translateY(-1.5em) scale(0.9);
transition:
opacity var(--transition-speed),
transform var(--transition-speed);
}
.command:where(:focus, :hover) .name {
opacity: 1;
transform: translateY(0);
}
.clock {
aspect-ratio: 1;
height: calc(var(--dial-size) * 0.85);
left: 50%;
pointer-events: none;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
}
.hand {
background: var(--color-text);
border-radius: 2px;
bottom: 50%;
left: 50%;
position: absolute;
transform-origin: bottom center;
}
.hand--hour {
height: 30%;
width: 3px;
margin-left: -1.5px;
}
.hand--minute {
height: 40%;
width: 2px;
margin-left: -1px;
}
.hand--second {
background: var(--color-accent);
height: 45%;
width: 1px;
margin-left: -0.5px;
}
.dot {
background: var(--color-text);
border-radius: 50%;
height: 6px;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 6px;
}
</style>
<nav>
<menu class="commands"></menu>
<div class="clock">
<div class="hand hand--hour"></div>
<div class="hand hand--minute"></div>
<div class="hand hand--second"></div>
<div class="dot"></div>
</div>
</nav>
</template>
<template id="command-template">
<li>
<a class="command" rel="noopener noreferrer" aria-label="">
<span class="key" aria-hidden="true"></span>
<span class="name"></span>
</a>
</li>
</template>
<template id="search-template">
<style>
input,
button {
-moz-appearance: none;
font-family: var(--font-family);
-webkit-appearance: none;
background: transparent;
border: 0;
display: block;
outline: 0;
}
.dialog {
align-items: center;
background: var(--color-background);
border: none;
display: none;
flex-direction: column;
height: 100%;
justify-content: center;
left: 0;
opacity: 0;
padding: 0;
top: 0;
transform: translateY(10px);
transition:
opacity var(--transition-speed),
transform var(--transition-speed);
width: 100%;
}
.dialog[open] {
display: flex;
}
.dialog.visible {
opacity: 1;
transform: translateY(0);
}
.form {
width: 100%;
}
.sr-only {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.input {
color: var(--color-text);
font-size: 2rem;
font-weight: bold;
padding: 0;
text-align: center;
width: 100%;
}
.suggestions {
align-items: center;
display: flex;
flex-direction: column;
flex-wrap: wrap;
justify-content: center;
list-style: none;
margin: 1rem 0 0;
overflow: hidden;
padding: 0;
}
.suggestion {
color: var(--color-text);
cursor: pointer;
font-size: 1rem;
padding: 1rem;
position: relative;
transition: color var(--transition-speed);
white-space: nowrap;
z-index: 1;
}
.suggestion:where(:focus, :hover) {
color: var(--color-background);
}
.suggestions:hover .suggestion:focus:not(:hover) {
color: var(--color-text);
}
.suggestion::before {
background-color: var(--color-text);
border-radius: 1em;
content: ' ';
inset: 0.8em 0.4em;
opacity: 0;
position: absolute;
transform: translateY(0.3em) scale(0.9);
transition:
opacity var(--transition-speed),
transform var(--transition-speed);
z-index: -1;
}
.suggestion:where(:focus, :hover)::before {
opacity: 1;
transform: translateY(0);
}
.suggestions:hover .suggestion:focus:not(:hover)::before {
opacity: 0;
transform: translateY(0.3em) scale(0.9);
}
.match {
color: var(--color-text-subtle);
transition: color var(--transition-speed);
}
.suggestion:where(:focus, :hover) .match {
color: var(--color-background);
}
.suggestions:hover .suggestion:focus:not(:hover) .match {
color: var(--color-text-subtle);
}
@media (min-width: 700px) {
.suggestions {
flex-direction: row;
}
}
</style>
<dialog class="dialog" aria-label="Search">
<form autocomplete="off" class="form" method="dialog" spellcheck="false">
<label for="search-input" class="sr-only">Search</label>
<input class="input" id="search-input" type="text" />
<menu class="suggestions"></menu>
</form>
</dialog>
</template>
<template id="suggestion-template">
<li>
<button class="suggestion" type="button"></button>
</li>
</template>
<template id="match-template">
<span class="match"></span>
</template>
<style>
html {
background-color: var(--color-background);
font-family: var(--font-family);
font-size: var(--font-size);
line-height: 1.4;
user-select: none;
}
body {
margin: 0;
}
</style>
<commands-component></commands-component>
<search-component></search-component>