{ "$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: '