{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "recipes", "type": "registry:item", "title": "aesthetic recipes", "description": "The canonical behavior glue for aesthetic v0.25.0: mode toggle, theme switching, nav indicator, view swap, send moment, settings rows, anticipation, dialogs, toasts, popovers. Dependency-free.", "meta": { "version": "0.25.0", "tier": "whole-recipes", "classes": [], "tokens": [], "recipes": ["recipes.js"] }, "files": [ { "path": "recipes/recipes.js", "type": "registry:file", "target": "~/aesthetic.recipes.js", "content": "/* aesthetic recipes — the canonical behavior glue, combined.\n Generated from recipes/*.js by scripts/build-recipes.mjs; edit the\n singles, then `npm run build:recipes`. Each section is\n self-contained — copy what you need, or take this whole file. */\n\n/* the mode: light and dark, defaulting to the system, with a toggle.\n\n The boot — read the persisted choice before first paint so the page\n never flashes the wrong mode — must be inlined in :\n\n \n\n The toggle below pins the opposite of the effective scheme. Pinning\n color-scheme keeps UA widgets (scrollbars, form controls) on the\n pinned side even when the OS disagrees. The change itself is quick\n and interruptible: a new click cuts any in-flight transition and\n applies the newest mode immediately. Unsupported view transitions\n get the same quick color ease; reduced motion is instant.\n next-themes consumers: keep attribute=\"class\" and skip this file —\n the classes match. */\n(() => {\n const root = document.documentElement;\n let activeTransition = null;\n let easingTimer = 0;\n let runId = 0;\n let targetDark = null;\n\n const isDark = () =>\n root.classList.contains('dark')\n ? true\n : root.classList.contains('light')\n ? false\n : matchMedia('(prefers-color-scheme: dark)').matches;\n\n const reducedMode = matchMedia('(prefers-reduced-motion: reduce)');\n\n const clearAnimation = () => {\n if (activeTransition && activeTransition.skipTransition) {\n activeTransition.skipTransition();\n }\n activeTransition = null;\n if (easingTimer) {\n clearTimeout(easingTimer);\n easingTimer = 0;\n }\n root.classList.remove('ae-vt-mode', 'ae-mode-easing');\n };\n\n const applyMode = (dark) => {\n root.classList.toggle('dark', dark);\n root.classList.toggle('light', !dark);\n root.style.colorScheme = dark ? 'dark' : 'light';\n try {\n localStorage.setItem('ae-mode', dark ? 'dark' : 'light');\n } catch (e) {}\n };\n\n document.querySelectorAll('.ae-mode').forEach((btn) => {\n btn.addEventListener('click', () => {\n const nextDark = !(targetDark ?? isDark());\n const id = ++runId;\n targetDark = nextDark;\n const flip = () => {\n if (id !== runId) return;\n applyMode(nextDark);\n };\n clearAnimation();\n if (reducedMode.matches) {\n flip();\n } else if (document.startViewTransition) {\n root.classList.add('ae-vt-mode');\n activeTransition = document.startViewTransition(flip);\n easingTimer = setTimeout(() => {\n if (id !== runId) return;\n root.classList.remove('ae-vt-mode');\n easingTimer = 0;\n }, 180);\n activeTransition.finished.finally(() => {\n if (id !== runId) return;\n root.classList.remove('ae-vt-mode');\n activeTransition = null;\n if (easingTimer) {\n clearTimeout(easingTimer);\n easingTimer = 0;\n }\n });\n } else {\n root.classList.add('ae-mode-easing');\n flip();\n easingTimer = setTimeout(() => {\n if (id !== runId) return;\n root.classList.remove('ae-mode-easing');\n easingTimer = 0;\n }, 180);\n }\n });\n });\n})();\n\n/* named themes: project flavor over the same --ae-* token contract.\n\n Pin a project theme on the root:\n\n \n\n To let users switch, inline this boot in before first paint:\n\n \n\n Then load this recipe and mark controls:\n\n \n\n The recipe persists the user's selected theme, keeps aria-pressed in\n sync, and exposes window.aeTheme.set('violet') for app shells. */\n(() => {\n if (window.aeTheme) return;\n\n const root = document.documentElement;\n const themes = ['ultramarine', 'moss', 'ember', 'violet'];\n const themeSet = new Set(themes);\n const storageKey = root.dataset.aeThemeKey || 'ae-theme';\n\n const clean = (value) =>\n typeof value === 'string' && themeSet.has(value) ? value : null;\n\n const current = () => clean(root.dataset.aeTheme) || 'ultramarine';\n\n const syncButtons = () => {\n const active = current();\n document.querySelectorAll('[data-ae-theme-choice]').forEach((button) => {\n const chosen = clean(button.getAttribute('data-ae-theme-choice'));\n if (!chosen) return;\n button.setAttribute('aria-pressed', String(chosen === active));\n });\n };\n\n const setTheme = (theme, options = {}) => {\n const chosen = clean(theme);\n if (!chosen) return current();\n root.dataset.aeTheme = chosen;\n if (options.persist !== false) {\n try {\n localStorage.setItem(storageKey, chosen);\n } catch (e) {}\n }\n syncButtons();\n return chosen;\n };\n\n const saved = (() => {\n try {\n return clean(localStorage.getItem(storageKey));\n } catch (e) {\n return null;\n }\n })();\n if (saved) setTheme(saved, { persist: false });\n else if (!clean(root.dataset.aeTheme)) root.dataset.aeTheme = 'ultramarine';\n\n document.querySelectorAll('[data-ae-theme-choice]').forEach((button) => {\n const theme = clean(button.getAttribute('data-ae-theme-choice'));\n if (!theme) return;\n button.setAttribute('type', button.getAttribute('type') || 'button');\n button.addEventListener('click', () => setTheme(theme));\n });\n syncButtons();\n\n window.aeTheme = {\n themes: [...themes],\n get: current,\n set: setTheme,\n clear() {\n try {\n localStorage.removeItem(storageKey);\n } catch (e) {}\n root.dataset.aeTheme = 'ultramarine';\n syncButtons();\n return current();\n },\n };\n})();\n\n/* the nav indicator: position the sliding ink underline under the\n active item on activation, resize, and font load (metrics shift\n when Geist arrives). Exposes window.aePlaceInds for page scripts\n that swap views and need the indicator re-placed after a display\n change. Works for .ae-nav and .ae-tabs alike. */\n(() => {\n if (window.aePlaceInds) return;\n const placeInd = (nav) => {\n const active = nav.querySelector(\n '[aria-current], [aria-selected=\"true\"], .is-active',\n );\n const ind = nav.querySelector('.ae-nav-ind');\n if (!active || !ind || !nav.offsetWidth) return;\n ind.style.left = active.offsetLeft + 'px';\n ind.style.width = active.offsetWidth + 'px';\n };\n const placeAllInds = () =>\n document.querySelectorAll('.ae-nav, .ae-tabs').forEach(placeInd);\n\n document.querySelectorAll('.ae-nav, .ae-tabs').forEach((nav) => {\n nav.querySelectorAll('a, button').forEach((item) => {\n item.addEventListener('click', () => {\n nav\n .querySelectorAll('a, button')\n .forEach((b) => b.classList.remove('is-active'));\n item.classList.add('is-active');\n placeInd(nav);\n });\n });\n });\n addEventListener('resize', placeAllInds);\n if (document.fonts) document.fonts.ready.then(placeAllInds);\n placeAllInds();\n\n window.aePlaceInds = placeAllInds;\n})();\n\n/* view swap by hash: give each view data-route=\"name\" and link to it\n with href=\"#name\". The matching view mounts (.is-on) and replays\n its entrance; everything else unmounts. Falls back to\n data-route=\"index\" when the hash names nothing. */\n(() => {\n const views = document.querySelectorAll('[data-route]');\n if (!views.length) return;\n const show = () => {\n const route = location.hash.slice(1) || 'index';\n let hit = false;\n views.forEach((v) => {\n const on = v.dataset.route === route;\n v.classList.toggle('is-on', on);\n hit = hit || on;\n });\n if (!hit)\n views.forEach((v) =>\n v.classList.toggle('is-on', v.dataset.route === 'index'),\n );\n if (window.aePlaceInds) requestAnimationFrame(window.aePlaceInds);\n };\n addEventListener('hashchange', show);\n show();\n})();\n\n/* the send moment: resolve once, persist, and say so. Call\n window.aeSend(button, 'Sent') on success — it flips the costume,\n disables the control (success is never rewound), and announces the\n resolved state to assistive tech: the aria-hidden done layer is\n silent on its own, so a live region speaks for it.\n\n Forms marked data-ae-demo resolve on submit without a network —\n the site's demo hook; real consumers call aeSend from their own\n submit handling. */\n(() => {\n if (window.aeSend) return;\n const live = document.createElement('span');\n live.className = 'ae-sr';\n live.setAttribute('role', 'status');\n (document.body || document.documentElement).appendChild(live);\n\n window.aeSend = (btn, done) => {\n if (!btn || btn.classList.contains('is-sent')) return;\n btn.classList.add('is-sent');\n btn.disabled = true;\n live.textContent = '';\n requestAnimationFrame(() => {\n live.textContent =\n done ||\n btn.dataset.aeDone ||\n (btn.querySelector('.ae-send-done') || {}).textContent ||\n 'Done';\n });\n };\n\n document.querySelectorAll('form[data-ae-demo]').forEach((form) => {\n form.addEventListener('submit', (e) => {\n e.preventDefault();\n window.aeSend(form.querySelector('.ae-send'));\n });\n });\n})();\n\n/* settings rows that open: each .ae-setting button toggles the\n .ae-setting-panel that follows it; choosing an option writes the\n row's value and folds the chooser. One row open at a time; Escape\n folds and returns focus. */\n(() => {\n const closeSetting = (btn) => {\n const panel = btn.nextElementSibling;\n if (panel) panel.classList.remove('is-open');\n btn.setAttribute('aria-expanded', 'false');\n };\n document.querySelectorAll('.ae-setting').forEach((btn) => {\n const panel = btn.nextElementSibling;\n if (!panel || !panel.classList.contains('ae-setting-panel')) return;\n btn.setAttribute('aria-expanded', 'false');\n btn.addEventListener('click', () => {\n const open = panel.classList.contains('is-open');\n document.querySelectorAll('.ae-setting').forEach(closeSetting);\n if (!open) {\n panel.classList.add('is-open');\n btn.setAttribute('aria-expanded', 'true');\n }\n });\n panel.querySelectorAll('.ae-menu button').forEach((opt) => {\n opt.addEventListener('click', () => {\n panel\n .querySelectorAll('.ae-menu button')\n .forEach((b) => b.classList.toggle('is-sel', b === opt));\n const val = btn.querySelector('.ae-setting-val');\n if (val) val.textContent = opt.dataset.value || opt.textContent.trim();\n closeSetting(btn);\n btn.focus();\n });\n });\n panel.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') {\n closeSetting(btn);\n btn.focus();\n }\n });\n });\n})();\n\n/* input anticipation (the combined warming): the nearest field's\n label and line warm toward ink as the pointer approaches.\n Mouse-only, color-only, capped below the committed state, off under\n reduced motion; focus always snaps to the committed state. Opt in\n with data-ae-anticipate on the form. */\n(() => {\n const fine = matchMedia('(hover: hover) and (pointer: fine)');\n const reducedAnt = matchMedia('(prefers-reduced-motion: reduce)');\n const forms = document.querySelectorAll('[data-ae-anticipate]');\n if (!forms.length || !fine.matches || reducedAnt.matches) return;\n\n const fields = [];\n forms.forEach((form) => {\n form.querySelectorAll('.ae-input').forEach((input) => {\n const label = form.querySelector(`label[for=\"${input.id}\"]`);\n fields.push({ input, label, rect: null });\n });\n });\n const measure = () => {\n fields.forEach((f) => {\n f.rect = f.input.getBoundingClientRect();\n });\n };\n let raf = 0;\n let px = 0;\n let py = 0;\n const apply = () => {\n raf = 0;\n let best = null;\n let bestD = Infinity;\n fields.forEach((f) => {\n if (!f.rect) return;\n const dx = Math.max(f.rect.left - px, 0, px - f.rect.right);\n const dy = Math.max(f.rect.top - py, 0, py - f.rect.bottom);\n const d = Math.hypot(dx, dy);\n if (d < bestD) {\n bestD = d;\n best = f;\n }\n });\n fields.forEach((f) => {\n const intent =\n f === best ? Math.min(0.6, Math.max(0, 1 - bestD / 180) ** 2) : 0;\n const v = intent ? intent.toFixed(3) : '';\n f.input.style.setProperty('--ae-intent', v);\n if (f.label) f.label.style.setProperty('--ae-intent', v);\n });\n };\n measure();\n addEventListener(\n 'pointermove',\n (e) => {\n px = e.clientX;\n py = e.clientY;\n if (!raf) raf = requestAnimationFrame(apply);\n },\n { passive: true },\n );\n addEventListener('resize', measure);\n addEventListener('scroll', measure, { passive: true });\n})();\n\n/* the toast: news arrives at the edge and waits to be read.\n window.aeToast('Saved.', { status: 'ok' }) — ok|warn|err puts the\n hue on the glyph, the words stay ink. Toasts persist until\n dismissed or pushed out (the tray keeps four); pass { timeout: ms }\n to opt into self-dismissal — it is never the default. */\n(() => {\n if (window.aeToast) return;\n const ICONS = {\n ok: '',\n warn: '',\n err: '',\n };\n\n let tray = null;\n const ensureTray = () => {\n if (!tray) {\n tray = document.createElement('div');\n tray.className = 'ae-toasts';\n document.body.appendChild(tray);\n }\n return tray;\n };\n\n const dismiss = (toast) => {\n if (!toast.isConnected) return;\n toast.classList.add('is-leaving');\n setTimeout(() => toast.remove(), 200);\n };\n\n window.aeToast = (message, opts) => {\n const { status, timeout } = opts || {};\n const toast = document.createElement('div');\n toast.className = 'ae-toast';\n toast.setAttribute('role', 'status');\n if (status && ICONS[status]) {\n const i = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n i.setAttribute('class', `ae-icon ae-${status}`);\n i.setAttribute('viewBox', '0 0 24 24');\n i.innerHTML = ICONS[status];\n toast.appendChild(i);\n }\n toast.appendChild(document.createTextNode(message));\n const x = document.createElement('button');\n x.className = 'ae-toast-x';\n x.setAttribute('aria-label', 'dismiss');\n x.textContent = '✕';\n x.addEventListener('click', () => dismiss(toast));\n toast.appendChild(x);\n\n const t = ensureTray();\n t.appendChild(toast);\n while (t.children.length > 4) t.firstElementChild.remove();\n if (timeout) setTimeout(() => dismiss(toast), timeout);\n return toast;\n };\n})();\n\n/* the popover: geometry for the slip + roving tabindex for its menu.\n Native [popover] owns the top layer, light dismiss, and Escape; this\n recipe places the open slip by its invoker and wires arrow-key\n navigation for any .ae-menu inside.\n\n \n
*/\n(() => {\n if (window.aePop) return;\n window.aePop = true;\n document.addEventListener(\n 'toggle',\n (e) => {\n const pop = e.target;\n if (!(pop instanceof HTMLElement)) return;\n if (!pop.classList.contains('ae-pop')) return;\n if (e.newState !== 'open' || !pop.id) return;\n const inv = document.querySelector(`[popovertarget=\"${pop.id}\"]`);\n if (!inv) return;\n const r = inv.getBoundingClientRect();\n const pr = pop.getBoundingClientRect();\n let top = r.bottom + 6;\n if (top + pr.height > innerHeight - 8) top = r.top - pr.height - 6;\n const left = Math.min(r.left, innerWidth - pr.width - 8);\n pop.style.top = `${Math.max(8, top)}px`;\n pop.style.left = `${Math.max(8, left)}px`;\n\n /* roving tabindex: focus the selected item or the first item on\n open, then arrow-key through the menu */\n const menu = pop.querySelector('.ae-menu');\n if (!menu) return;\n const items = [...menu.querySelectorAll('button')].filter(\n (b) => !b.disabled,\n );\n if (items.length === 0) return;\n items.forEach((b) => (b.tabIndex = -1));\n const sel = menu.querySelector('button.is-sel') || items[0];\n sel.tabIndex = 0;\n sel.focus();\n },\n true,\n );\n\n /* arrow-key roving for .ae-menu inside popovers */\n document.addEventListener('keydown', (e) => {\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;\n const menu = e.target.closest('.ae-menu');\n if (!menu) return;\n const items = [...menu.querySelectorAll('button')].filter(\n (b) => !b.disabled,\n );\n if (items.length === 0) return;\n const i = items.indexOf(e.target);\n let next;\n if (e.key === 'ArrowDown') next = items[(i + 1) % items.length];\n else if (e.key === 'ArrowUp')\n next = items[(i - 1 + items.length) % items.length];\n else if (e.key === 'Home') next = items[0];\n else if (e.key === 'End') next = items[items.length - 1];\n if (!next) return;\n e.preventDefault();\n items.forEach((b) => (b.tabIndex = -1));\n next.tabIndex = 0;\n next.focus();\n });\n})();\n\n/* the dialog: focus, labeling, and restoration for .\n\n The native + showModal() owns the top layer and Escape.\n This recipe fills the accessibility gaps the browser doesn't:\n — focus moves into the dialog on open (the first focusable control,\n or the dialog itself as fallback)\n — focus is trapped inside while open (Tab cycles within)\n — focus returns to the invoker on close (the button that opened it)\n — if the dialog has an aria-labelledby, it is honored; otherwise the\n .ae-dialog-title is wired as the label if neither is set\n\n Pair with: a button that calls dialog.showModal(), and both actions\n (cancel + confirm) call dialog.close().\n\n \n \n

Archive this project?

\n
\n \n \n
\n
\n\n Open it: aeDialog(document.getElementById('open'), dialogEl)\n — or let the recipe auto-wire every [data-ae-dialog] invoker. */\n(() => {\n if (window.aeDialog) return;\n const FOCUSABLE =\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])';\n\n function focusables(dialog) {\n return [...dialog.querySelectorAll(FOCUSABLE)].filter(\n (el) => !el.disabled && el.offsetParent !== null,\n );\n }\n\n function label(dialog) {\n if (\n dialog.hasAttribute('aria-labelledby') ||\n dialog.hasAttribute('aria-label')\n )\n return;\n const title = dialog.querySelector('.ae-dialog-title');\n if (!title) return;\n if (!title.id)\n title.id = `ae-dlg-title-${Math.random().toString(36).slice(2, 9)}`;\n dialog.setAttribute('aria-labelledby', title.id);\n }\n\n function trap(dialog, e) {\n if (e.key !== 'Tab') return;\n const items = focusables(dialog);\n if (items.length === 0) {\n e.preventDefault();\n dialog.focus();\n return;\n }\n const first = items[0];\n const last = items[items.length - 1];\n if (e.shiftKey && document.activeElement === first) {\n e.preventDefault();\n last.focus();\n } else if (!e.shiftKey && document.activeElement === last) {\n e.preventDefault();\n first.focus();\n }\n }\n\n /* wire one invoker → dialog pair. Returns a cleanup function. */\n function aeDialog(invoker, dialog) {\n if (invoker._aeDialog) return invoker._aeDialog.cleanup;\n\n let lastFocused = null;\n label(dialog);\n if (!dialog.hasAttribute('tabindex')) dialog.tabIndex = -1;\n\n const onKey = (e) => trap(dialog, e);\n const open = () => {\n lastFocused = invoker;\n dialog.showModal();\n };\n const onClose = () => {\n if (lastFocused) lastFocused.focus();\n lastFocused = null;\n };\n\n invoker.addEventListener('click', open);\n dialog.addEventListener('keydown', onKey);\n dialog.addEventListener('close', onClose);\n\n const cleanup = () => {\n invoker.removeEventListener('click', open);\n dialog.removeEventListener('keydown', onKey);\n dialog.removeEventListener('close', onClose);\n delete invoker._aeDialog;\n };\n invoker._aeDialog = { open, cleanup };\n return cleanup;\n }\n\n /* auto-wire: */\n document.addEventListener('click', (e) => {\n const invoker = e.target.closest('[data-ae-dialog]');\n if (!invoker) return;\n const sel = invoker.getAttribute('data-ae-dialog');\n const dialog = document.querySelector(sel);\n if (!(dialog instanceof HTMLDialogElement)) return;\n if (!invoker._aeDialog) {\n aeDialog(invoker, dialog);\n // first click: the invoker's listener was added during dispatch and\n // won't fire for this event, so open the dialog directly\n invoker._aeDialog.open();\n }\n // subsequent clicks: the invoker's own listener handles opening\n });\n\n window.aeDialog = aeDialog;\n})();\n" } ] }