HarmoniaA Modern UI Component Library for Alpine.js
Build polished, accessible interfaces in minutes. A comprehensive, beautifully themed component set that brings the power of Tailwind CSS to Alpine.js, with no build step required.
Accessibility is built in, not bolted on. Components ship with proper ARIA roles and states, full keyboard operability, and sensible accessible names out of the box.
Light and Dark Mode
Carefully crafted themes adapt to light and dark automatically, powered by modern oklch color tokens for crisp, consistent visuals on any screen.
Drop in a single script from a CDN and start building immediately, with no compiler, tooling, or configuration. Prefer to bundle? A first-class ESM build is ready when you are.
Lightweight and Standards-Compliant
Harmonia leans on native browser APIs, semantic HTML, and modern web standards with minimal overhead, keeping your pages fast and your markup clean.
Templates
Complete, real-world interfaces built entirely with Harmonia components. Preview a template inline, or open it in a new tab to explore and inspect it.
Slate Dashboard
A single-file analytics dashboard with KPI cards, charts, data tables, and a responsive sidebar.
<div class="vbox size-full items-center justify-center p-4"> <div x-h-info-page> <div x-h-info-page-header> <div x-h-info-page-media.icon> <svg x-h-lucide role="presentation" data-lucide="compass"></svg> </div> <div x-h-info-page-title>Page not found</div> <div x-h-info-page-description x-text="'Nothing lives at ' + $router.context.path + '. It may have been moved, or the address was mistyped.'"></div> </div> <div x-h-info-page-content class="hbox gap-2"> <button x-h-button data-variant="primary" @click="go('/')">Go to dashboard</button> <!-- A real anchor on purpose: Pinecone Router intercepts plain-path links. --> <a x-h-button data-variant="outline" href="/inbox">Open inbox</a> </div> </div></div>
Onyx Chat
A multi-page team chat app with channels, direct messages, reactions, and simulated replies, split across one shell, two scripts, and six page fragments, routed client-side with Pinecone Router.
<div x-data="pageSettings" class="vbox items-center gap-4 p-4"> <section class="vbox w-full max-w-xl pt-2"> <h2 x-h-text.h5>Profile</h2> <p x-h-text.muted>How you appear to teammates. Renaming yourself updates your past messages too.</p> <div x-h-card class="mt-4 w-full"> <div x-h-card-content> <fieldset x-h-fieldset> <div x-h-field-group class="gap-4!"> <div x-h-field> <label x-h-label for="st-name">Display name</label> <input x-h-input id="st-name" x-model="$store.chat.profile.name" /> </div> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div x-h-field> <label x-h-label for="st-title">Title</label> <input x-h-input id="st-title" x-model="$store.chat.profile.title" /> </div> <div x-h-field> <label x-h-label for="st-email">Email</label> <input x-h-input id="st-email" type="email" x-model="$store.chat.profile.email" /> </div> </div> </div> </fieldset> </div> <div x-h-card-footer class="hbox justify-end"> <button x-h-button data-variant="primary" @click="save()">Save changes</button> </div> </div> </section> <section class="vbox w-full max-w-xl pt-2"> <h2 x-h-text.h5>Appearance</h2> <div x-h-card class="mt-4 w-full overflow-hidden"> <ul x-h-list> <li x-h-list-item class="gap-3 px-4 py-3"> <div class="vbox w-full"> <p class="text-base font-medium">Style</p> <p x-h-text.muted>The same switcher lives in the toolbar user menu.</p> </div> <button x-h-button data-variant="outline" x-h-menu-trigger.dropdown.chevron> <!-- svg placeholders on purpose: x-h-lucide renders them in place, so the x-show bindings keep switching the visible icon. --> <svg x-h-lucide x-show="$store.chat.settings.theme === 'light'" role="presentation" data-lucide="sun"></svg> <span x-show="$store.chat.settings.theme === 'light'">Light</span> <svg x-h-lucide x-show="$store.chat.settings.theme === 'dark'" role="presentation" data-lucide="moon"></svg> <span x-show="$store.chat.settings.theme === 'dark'">Dark</span> <svg x-h-lucide x-show="$store.chat.settings.theme === 'auto'" role="presentation" data-lucide="sun-moon"></svg> <span x-show="$store.chat.settings.theme === 'auto'">Auto</span> <svg x-h-lucide role="presentation" data-lucide="chevron-down"></svg> </button> <ul x-h-menu data-align="bottom-end" aria-label="Style"> <li x-h-menu-radio-item="'light'" name="settings-theme" x-model="$store.chat.settings.theme"> <svg x-h-lucide role="presentation" data-lucide="sun"></svg> <span>Light</span> </li> <li x-h-menu-radio-item="'dark'" name="settings-theme" x-model="$store.chat.settings.theme"> <svg x-h-lucide role="presentation" data-lucide="moon"></svg> <span>Dark</span> </li> <li x-h-menu-radio-item="'auto'" name="settings-theme" x-model="$store.chat.settings.theme"> <svg x-h-lucide role="presentation" data-lucide="sun-moon"></svg> <span>Auto</span> </li> </ul> </li> </ul> </div> </section> <section class="vbox w-full max-w-xl pt-2"> <h2 x-h-text.h5>Notifications</h2> <p x-h-text.muted>Each switch gates a real part of the demo's reply simulation.</p> <div x-h-card class="mt-4 w-full overflow-hidden"> <ul x-h-list> <li x-h-list-item class="gap-3 px-4 py-3"> <svg x-h-lucide role="presentation" data-lucide="bell" class="size-5"></svg> <div class="vbox w-full"> <label class="text-base font-medium" for="sw-message-toasts">New message toasts</label> <p x-h-text.muted>Show a toast when a message arrives in a conversation you are not viewing.</p> </div> <span x-h-switch> <input type="checkbox" id="sw-message-toasts" x-model="$store.chat.settings.notifications.messageToasts" /> </span> </li> <li x-h-list-item class="gap-3 px-4 py-3"> <svg x-h-lucide role="presentation" data-lucide="at-sign" class="size-5"></svg> <div class="vbox w-full"> <label class="text-base font-medium" for="sw-mention-alerts">Mention alerts</label> <p x-h-text.muted>Show a toast whenever a teammate mentions you.</p> </div> <span x-h-switch> <input type="checkbox" id="sw-mention-alerts" x-model="$store.chat.settings.notifications.mentionAlerts" /> </span> </li> <li x-h-list-item class="gap-3 px-4 py-3"> <svg x-h-lucide role="presentation" data-lucide="keyboard" class="size-5"></svg> <div class="vbox w-full"> <label class="text-base font-medium" for="sw-typing">Typing indicators</label> <p x-h-text.muted>Show the "is typing" row while a reply is being written.</p> </div> <span x-h-switch> <input type="checkbox" id="sw-typing" x-model="$store.chat.settings.notifications.typingIndicators" /> </span> </li> </ul> </div> </section> <section class="vbox w-full max-w-xl pt-2 pb-4"> <h2 x-h-text.h5>Danger zone</h2> <div x-h-tile data-variant="shadow" class="mt-4"> <div x-h-tile-content> <div x-h-tile-title>Reset demo data</div> <p x-h-tile-description>Restores every conversation, membership, and profile change to its initial state. Anything you sent in this session will be lost.</p> </div> <div x-h-tile-actions> <button x-h-button data-variant="negative" @click="showReset = true">Reset workspace</button> </div> </div> </section> <!-- Reset confirmation dialog --> <div x-h-dialog-overlay :data-open="showReset" class="backdrop-blur-xs" @keydown.escape.window="showReset = false"> <div x-h-dialog> <div x-h-dialog-header> <h2 x-h-dialog-title>Reset workspace</h2> <p x-h-dialog-description>All demo conversations will be restored to their initial state, including channel memberships and your profile. Continue?</p> </div> <div x-h-dialog-footer> <button x-h-button data-variant="outline" @click="showReset = false">Cancel</button> <button x-h-button data-variant="negative" @click="confirmReset()">Reset everything</button> </div> </div> </div></div>
pages/not-found.html
html
<div class="vbox size-full items-center justify-center p-4"> <div x-h-info-page> <div x-h-info-page-header> <div x-h-info-page-media.icon> <svg x-h-lucide role="presentation" data-lucide="compass"></svg> </div> <div x-h-info-page-title>Page not found</div> <div x-h-info-page-description x-text="'Nothing lives at ' + $router.context.path + '. It may have been moved, or the address was mistyped.'"></div> </div> <div x-h-info-page-content class="hbox gap-2"> <button x-h-button data-variant="primary" @click="go('/')">Go to chat</button> <!-- A real anchor on purpose: Pinecone Router intercepts plain-path links. --> <a x-h-button data-variant="outline" href="/channels">Browse channels</a> </div> </div></div>
Quartz Docs
A multi-page documentation and blog site for a fictional JavaScript library, with a split sidebar layout, a command-palette search, and previous/next paging, split across one shell, two scripts, and ten page fragments, routed client-side with Pinecone Router.
<!doctype html><html lang="en" class="size-full"> <head> <meta charset="UTF-8" /> <link rel="icon" href="/harmonia/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Quartz | Harmonia</title> <!-- Pinecone Router must be evaluated before Alpine starts. --> <script src="/harmonia/lib/node_modules/pinecone-router/dist/router.min.js"></script> <script defer src="/harmonia/lib/node_modules/alpinejs/dist/cdn.min.js"></script> <script src="/harmonia/lib/node_modules/@codbex/harmonia/dist/harmonia.min.js"></script> <script src="/harmonia/lib/node_modules/lucide/dist/umd/lucide.min.js"></script> <script src="/harmonia/lib/node_modules/@codbex/harmonia/dist/harmonia-lucide.min.js"></script> <script src="/harmonia/templates/quartz-docs/js/data.js"></script> <script src="/harmonia/templates/quartz-docs/js/app.js"></script> <link href="/harmonia/lib/node_modules/@codbex/harmonia/dist/harmonia.css" rel="stylesheet" /> <link href="/harmonia/fonts.css" rel="stylesheet" /> </head> <body class="bg-background" style="--sidebar-width: 16rem" x-data="AppShell" @keydown.window.ctrl.k.prevent="openSearch()" @keydown.window.meta.k.prevent="openSearch()" @keydown.escape.window="closeSearch()" @pinecone:start.document="onRouteStart" @pinecone:end.document="onRouteEnd" @pinecone:fetch-error.document="onFetchError" > <div x-h-sheet-overlay="showSidebarSheet"> <div x-h-sheet id="sidebar-sheet" data-align="left" x-ref="sidebarSheet"></div> </div> <!-- The document itself scrolls, so the scrollbar is the browser's own, at the right edge of the viewport. The chrome stays put because the navbar and the sidebar are sticky, not because the content sits in a scrolling box. --> <div class="hbox min-h-dvh"> <!-- Left bleed: carries the sidebar color (or the page color) to the edge of the viewport, which is what makes the layout read as split rather than as a floating box. Below 90rem it collapses to zero. --> <div class="shrink-0" :class="showDesktopSidebar ? 'bg-sidebar' : 'bg-background'" style="width: max(0rem, (100% - 90rem) / 2)" aria-hidden="true"></div> <!-- Sidebar slot: pinned, one viewport tall. The height lives here rather than on the sidebar so the component's own h-full resolves against it. --> <div class="sticky top-0 h-dvh shrink-0" x-ref="sidebarSlot" x-show="showDesktopSidebar"> <div x-h-sidebar data-borderless="true" x-ref="sidebar"> <div x-h-sidebar-header data-borderless="true"> <!-- A real anchor on purpose: Pinecone Router intercepts plain-path links. --> <a x-h-sidebar-header-item href="/"> <div x-h-avatar class="rounded-lg" data-variant="primary"> <svg x-h-lucide role="presentation" data-lucide="gem"></svg> </div> <span>Quartz</span> </a> </div> <nav x-h-sidebar-content aria-label="Documentation"> <!-- The groups are authored once and their items come from the nav tree in js/data.js, so the sidebar, the search and the pager cannot drift. --> <div x-h-sidebar-group.collapsed="false"> <!-- The label text goes on a child, since the collapse arrow is added inside the label and x-text would replace it. --> <div x-h-sidebar-group-label><span x-text="$store.quartz.nav[0].label"></span></div> <div x-h-sidebar-group-content> <ul x-h-sidebar-menu> <template x-for="item in $store.quartz.nav[0].items" :key="item.route"> <li x-h-sidebar-menu-item> <a x-h-sidebar-menu-nav :href="item.route" :data-active="path === item.route" x-text="item.title"></a> </li> </template> </ul> </div> </div> <div x-h-sidebar-group.collapsed="false"> <div x-h-sidebar-group-label><span x-text="$store.quartz.nav[1].label"></span></div> <div x-h-sidebar-group-content> <ul x-h-sidebar-menu> <template x-for="item in $store.quartz.nav[1].items" :key="item.route"> <li x-h-sidebar-menu-item> <a x-h-sidebar-menu-nav :href="item.route" :data-active="path === item.route" x-text="item.title"></a> </li> </template> </ul> </div> </div> <div x-h-sidebar-group.collapsed="false"> <div x-h-sidebar-group-label><span x-text="$store.quartz.nav[2].label"></span></div> <div x-h-sidebar-group-content> <ul x-h-sidebar-menu> <template x-for="item in $store.quartz.nav[2].items" :key="item.route"> <li x-h-sidebar-menu-item> <a x-h-sidebar-menu-nav :href="item.route" :data-active="path === item.route" x-text="item.title"></a> </li> </template> </ul> </div> </div> <div x-h-sidebar-group.collapsed="false"> <div x-h-sidebar-group-label><span x-text="$store.quartz.nav[3].label"></span></div> <div x-h-sidebar-group-content> <ul x-h-sidebar-menu> <template x-for="item in $store.quartz.nav[3].items" :key="item.route"> <li x-h-sidebar-menu-item> <a x-h-sidebar-menu-nav :href="item.route" :data-active="path === item.route" x-text="item.title"></a> </li> </template> </ul> </div> </div> </nav> </div> </div> <!-- Content column. It runs to the right edge of the viewport, so the navbar's bottom border does too. --gutter is the leftover between this column and the centered 90rem band, expressed against this column's own width: a 100vw-based formula would include the scrollbar and push the band off centre. --> <main class="z-1 vbox min-w-0 flex-1 bg-background" :class="showDesktopSidebar && 'shadow-sm'" :style="showDesktopSidebar ? '--gutter: max(0rem, 100% + var(--sidebar-width) - 90rem)' : '--gutter: max(0rem, 100% - 90rem)'"> <div class="sticky top-0 z-10"> <div x-h-toolbar data-variant="clear" :data-borderless="!hasSidebar" style="padding-right: calc(var(--gutter) + 0.25rem)"> <button x-show="hasSidebar && isSmallScreen" x-h-button data-variant="transparent" data-size="icon" aria-label="Open navigation" :aria-expanded="showSidebarSheet" aria-controls="sidebar-sheet" @click="showSidebarSheet = true"> <svg x-h-lucide role="presentation" data-lucide="menu"></svg> </button> <!-- On a wide screen the brand lives in the sidebar header, so the navbar only carries it where the sidebar is hidden or has collapsed into the sheet. --> <a x-show="!hasSidebar" href="/" class="ml-1 hbox items-center gap-2 no-underline"> <div x-h-avatar class="rounded-lg" data-variant="primary"> <svg x-h-lucide role="presentation" data-lucide="gem"></svg> </div> <span class="font-semibold">Quartz</span> </a> <div x-h-toolbar-spacer></div> <nav x-h-nav aria-label="Main navigation" data-variant="clear" class="hidden lg:flex"> <ul x-h-nav-list> <li x-h-nav-item> <a x-h-nav-link href="/guide/what-is-quartz" :data-active="isSection('/guide')">Guide</a> </li> <li x-h-nav-item> <a x-h-nav-link href="/api" :data-active="isSection('/api')">Reference</a> </li> <li x-h-nav-item> <a x-h-nav-link href="/blog" :data-active="isSection('/blog')">Blog</a> </li> </ul> </nav> <!-- Below lg the bar has no room for the links, so the same three destinations collapse into a dropdown. --> <nav x-h-nav aria-label="Main navigation" data-variant="clear" class="lg:hidden"> <ul x-h-nav-list> <li x-h-nav-item> <button x-h-nav-trigger aria-label="Main navigation"> <svg x-h-lucide role="presentation" data-lucide="menu"></svg> </button> <ul x-h-menu> <li x-h-menu-item @click="go('/guide/what-is-quartz')" :aria-current="isSection('/guide') ? 'page' : null"> <span>Guide</span> <span x-h-menu-item-secondary x-show="isSection('/guide')"> <svg x-h-icon data-icon="check" role="presentation"></svg> </span> </li> <li x-h-menu-item @click="go('/api')" :aria-current="isSection('/api') ? 'page' : null"> <span>Reference</span> <span x-h-menu-item-secondary x-show="isSection('/api')"> <svg x-h-icon data-icon="check" role="presentation"></svg> </span> </li> <li x-h-menu-item @click="go('/blog')" :aria-current="isSection('/blog') ? 'page' : null"> <span>Blog</span> <span x-h-menu-item-secondary x-show="isSection('/blog')"> <svg x-h-icon data-icon="check" role="presentation"></svg> </span> </li> </ul> </li> </ul> </nav> <button x-h-button data-variant="transparent" data-size="icon" aria-label="Search documentation" @click="openSearch()"> <svg x-h-lucide role="presentation" data-lucide="search"></svg> </button> <button x-h-button data-variant="transparent" data-size="icon" aria-label="Change appearance" x-h-menu-trigger.dropdown> <svg x-h-lucide role="presentation" :data-lucide="themeIcon"></svg> </button> <ul x-h-menu aria-label="Appearance" data-align="bottom-end"> <li x-h-menu-radio-item="'light'" name="theme" x-model="$store.quartz.theme"> <svg x-h-lucide role="presentation" data-lucide="sun"></svg> <span>Light</span> </li> <li x-h-menu-radio-item="'dark'" name="theme" x-model="$store.quartz.theme"> <svg x-h-lucide role="presentation" data-lucide="moon"></svg> <span>Dark</span> </li> <li x-h-menu-radio-item="'auto'" name="theme" x-model="$store.quartz.theme"> <svg x-h-lucide role="presentation" data-lucide="sun-moon"></svg> <span>Auto</span> </li> </ul> </div> <div class="absolute right-0 bottom-0 left-0" x-show="routeLoading" style="display: none"> <div x-h-progress="0" data-loading="true" aria-label="Loading page"></div> </div> </div> <div class="vbox flex-1" style="padding-right: var(--gutter)"> <!-- x-if rather than x-show, so the alert enters the DOM when the error happens and screen readers announce it. --> <template x-if="loadError"> <div class="p-4"> <div x-h-alert data-variant="negative"> <svg x-h-lucide role="presentation" data-lucide="triangle-alert"></svg> <div x-h-alert-title>This page could not be loaded</div> <div x-h-alert-description>The page template could not be fetched. Check the network connection and try again.</div> <div x-h-alert-actions> <button x-h-button data-variant="outline" data-size="sm" @click="loadError = false">Dismiss</button> </div> </div> </div> </template> <!-- Pages own their padding, and the outlet fills the remaining height so the 404 page can center itself in it. tabindex="-1" lets the router hand focus to the new page after a navigation. --> <div id="page-outlet" class="flex-1 outline-hidden" tabindex="-1"></div> </div> </main> </div> <!-- Command palette. The backdrop only handles the scrim and the animation, so dismissal and focus are wired here and on the body element. --> <div x-h-backdrop :data-open="searchOpen" @click.self="closeSearch()" role="dialog" aria-modal="true" aria-label="Search documentation" class="vbox items-center gap-4 p-4 sm:p-12"> <div class="w-full max-w-xl" x-h-backdrop-item> <div x-h-input-group> <!-- Enter is prevented, not just handled: picking a result closes the palette, which hands focus back to the toolbar search button while this keydown is still being processed. Left to run, the browser finishes the keystroke on that button and its activation click reopens the palette. --> <input x-h-input.group x-ref="searchInput" type="text" placeholder="Search documentation..." aria-label="Search documentation" :aria-expanded="searchOpen" x-model="searchQuery" x-h-focus="searchOpen" @keydown.enter.prevent="pickFirst()" /> <div x-h-input-group-addon data-align="inline-start"> <svg x-h-lucide role="presentation" data-lucide="search"></svg> </div> </div> </div> <div x-h-combobox="$refs.searchInput" x-h-backdrop-item aria-label="Search results" class="w-full max-w-xl overflow-auto" style="max-height: 24rem"> <!-- The hint and the empty state live outside the list, since the listbox may only hold options. --> <template x-if="!searchQuery.trim()"> <p class="px-3 py-2 text-sm text-muted-foreground">Search page titles and sections. Press Enter to open the first result, or use the arrow keys to pick another.</p> </template> <template x-if="searchQuery.trim() && !searchResults.length"> <p role="status" class="px-3 py-2 text-sm text-muted-foreground" x-text="'No results for ' + searchQuery.trim() + '.'"></p> </template> <ul x-h-list> <template x-for="result in searchResults" :key="result.route + result.heading"> <li x-h-list-item class="justify-between gap-3" @click="pick(result)"> <div class="vbox min-w-0"> <span class="truncate" x-text="result.label"></span> <span x-h-list-secondary class="truncate text-sm" x-show="result.heading" x-text="result.heading"></span> </div> <span x-h-list-secondary class="text-xs" x-text="result.group"></span> </li> </template> </ul> </div> </div> <!-- Routes: every page is a separate fragment fetched on demand and rendered into #page-outlet (see targetID in js/app.js). --> <template x-route="/" x-template="/harmonia/templates/quartz-docs/pages/home.html"></template> <template x-route="/guide/what-is-quartz" x-template="/harmonia/templates/quartz-docs/pages/guide-what-is-quartz.html"></template> <template x-route="/guide/getting-started" x-template="/harmonia/templates/quartz-docs/pages/guide-getting-started.html"></template> <template x-route="/guide/queries" x-template="/harmonia/templates/quartz-docs/pages/guide-queries.html"></template> <template x-route="/guide/caching" x-template="/harmonia/templates/quartz-docs/pages/guide-caching.html"></template> <template x-route="/api" x-template="/harmonia/templates/quartz-docs/pages/api.html"></template> <template x-route="/blog" x-template="/harmonia/templates/quartz-docs/pages/blog.html"></template> <template x-route="/blog/quartz-1-1" x-template="/harmonia/templates/quartz-docs/pages/blog-quartz-1-1.html"></template> <template x-route="/blog/announcing-quartz-1-0" x-template="/harmonia/templates/quartz-docs/pages/blog-announcing-quartz-1-0.html"></template> <template x-route="notfound" x-handler="notFound" x-template="/harmonia/templates/quartz-docs/pages/not-found.html"></template> </body></html>
js/app.js
js
// Quartz Docs demo application logic: Pinecone Router settings, the shared// "quartz" store (site map, search index and color scheme), the app shell// controller that owns the navigation chrome, and the small docPage controller// that gives every documentation page its previous/next links.// Page fragments in pages/*.html are pure markup that read from the store.document.addEventListener('alpine:init', () => { window.PineconeRouter.settings({ // Hash routing keeps deep links and reloads working on static hosting. hash: true, // Every route template renders into the <div id="page-outlet"> in index.html. targetID: 'page-outlet', // Fetch all page fragments at low priority after the first page renders. preload: true, // Inside the docs homepage iframe, skip history.pushState() so demo // navigation does not pollute the parent page's browser history. pushState: window.self === window.top, }); const data = window.QuartzData; Alpine.store('quartz', { nav: data.nav, posts: data.posts, features: data.features, // Seed from Harmonia's current color scheme ("auto" | "light" | "dark"). // Writing the theme applies it, so the appearance menu's radio items can // bind it with x-model directly. _theme: Harmonia.getColorScheme(), get theme() { return this._theme; }, set theme(mode) { this._theme = mode; Harmonia.setColorScheme(mode); }, // Every page in sidebar order. Drives the previous/next pager. get flatPages() { return this.nav.flatMap((group) => group.items); }, titleFor(path) { return this.flatPages.find((page) => page.route === path)?.title || ''; }, // Matches page titles and their headings. A heading hit navigates to the // page that contains it, which is as deep as a demo search needs to go. search(query) { const needle = query.trim().toLowerCase(); if (!needle) return []; const results = []; for (const group of this.nav) { for (const page of group.items) { if (page.title.toLowerCase().includes(needle)) { results.push({ route: page.route, label: page.title, heading: '', group: group.label }); } for (const heading of page.headings) { if (heading.toLowerCase().includes(needle)) { results.push({ route: page.route, label: page.title, heading, group: group.label }); } } } } return results.slice(0, 8); }, }); // --------------------------------------------------------------------------- // App shell: sidebar, navbar, search palette, routing chrome. // --------------------------------------------------------------------------- Alpine.data('AppShell', () => ({ routeLoading: false, routedOnce: false, showSidebarSheet: false, isSmallScreen: false, loadError: false, searchOpen: false, searchQuery: '', sidebarBreakpointListener: undefined, get path() { return this.$router.context.path; }, // The landing page is full width, like a VitePress home layout. Every other // route keeps the sidebar. get hasSidebar() { return this.path !== '/'; }, get showDesktopSidebar() { return this.hasSidebar && !this.isSmallScreen; }, get themeIcon() { return { light: 'sun', dark: 'moon' }[this.$store.quartz.theme] || 'sun-moon'; }, get searchResults() { return this.$store.quartz.search(this.searchQuery); }, isSection(prefix) { return this.path === prefix || this.path.startsWith(prefix + '/'); }, go(path) { this.showSidebarSheet = false; this.closeSearch(); this.$router.navigate(path); }, // ---- search palette ---- openSearch() { this.searchQuery = ''; this.searchOpen = true; }, closeSearch() { this.searchOpen = false; }, // Reached by a click and by Enter alike, since the combobox activates the // highlighted result by clicking it. pick(result) { this.closeSearch(); this.showSidebarSheet = false; this.$router.navigate(result.route); }, // Only ever runs when nothing is highlighted. Once the user arrows into the // results the combobox takes Enter and this handler never sees it. pickFirst() { const first = this.searchResults[0]; if (first) this.pick(first); }, // ---- routing chrome ---- onRouteStart() { this.routeLoading = true; }, onRouteEnd() { this.routeLoading = false; this.loadError = false; this.showSidebarSheet = false; // The document is the scroll container, so a new page starts from its top. window.scrollTo(0, 0); // Move focus to the fresh page, so keyboard and screen reader users are // not left on the link they came from. The initial route stays untouched. if (this.routedOnce) { document.getElementById('page-outlet').focus({ preventScroll: true }); } this.routedOnce = true; const title = this.$store.quartz.titleFor(this.path); document.title = (title ? title + ' | ' : '') + 'Quartz'; }, onFetchError() { this.routeLoading = false; this.loadError = true; }, // Replaces Pinecone Router's default notfound handler, which logs a console // error for every unknown path. The 404 page is all the feedback we need. notFound() {}, init() { // Below the breakpoint the sidebar moves into the sheet, so the same // markup serves as the desktop sidebar and the mobile navigation drawer. this.sidebarBreakpointListener = Harmonia.getBreakpointListener((matches) => { this.isSmallScreen = matches; if (matches) { this.$refs.sidebarSheet.appendChild(this.$refs.sidebar); } else if (this.$refs.sidebarSheet.firstElementChild) { this.showSidebarSheet = false; this.$refs.sidebarSlot.appendChild(this.$refs.sidebar); } }, 1024); }, destroy() { this.sidebarBreakpointListener.remove(); }, })); // --------------------------------------------------------------------------- // Documentation pages. Every guide, reference and blog post fragment uses this // controller for the previous/next pager at the bottom of the page. // --------------------------------------------------------------------------- Alpine.data('docPage', () => ({ // The post this route is about, for the blog entries. Undefined on the guide // and reference pages, which have no entry in posts. get post() { return this.$store.quartz.posts.find((entry) => entry.route === this.$router.context.path); }, get index() { return this.$store.quartz.flatPages.findIndex((page) => page.route === this.$router.context.path); }, get prev() { const pages = this.$store.quartz.flatPages; return this.index > 0 ? pages[this.index - 1] : null; }, get next() { const pages = this.$store.quartz.flatPages; return this.index >= 0 && this.index < pages.length - 1 ? pages[this.index + 1] : null; }, }));});
js/data.js
js
// Quartz Docs demo dataset. Quartz is a fictional data-fetching library, and// this file is the whole site map: the sidebar, the command palette search and// the previous/next pager all read from the nav tree below, so a page is added// in one place. The headings of every entry mirror the h2 elements of its page// fragment, which is what makes the search find sections and not just pages.window.QuartzData = { posts: [ { route: '/blog/quartz-1-1', title: 'Quartz 1.1: smarter refetching', date: 'June 18, 2026', author: 'Case Aylmer', initials: 'CA', tag: 'Release', excerpt: 'Focus and reconnect refetching are now driven by a single scheduler, so a tab that wakes up after an hour asleep sends one request instead of twelve.', }, { route: '/blog/announcing-quartz-1-0', title: 'Announcing Quartz 1.0', date: 'April 2, 2026', author: 'Iva Petrova', initials: 'IP', tag: 'Release', excerpt: 'After fourteen months of use in production, the cache API is stable. Here is what shipped, what we deliberately left out, and how to upgrade from the beta.', }, ], nav: [ { label: 'Introduction', items: [ { route: '/guide/what-is-quartz', title: 'What is Quartz?', headings: ['Motivation', 'How it works', 'When to use it'] }, { route: '/guide/getting-started', title: 'Getting started', headings: ['Installation', 'Your first query', 'Devtools'] }, ], }, { label: 'Guide', items: [ { route: '/guide/queries', title: 'Queries', headings: ['Query keys', 'Reading state', 'Dependent queries'] }, { route: '/guide/caching', title: 'Caching', headings: ['Staleness', 'Invalidation', 'Garbage collection'] }, ], }, { label: 'Reference', items: [{ route: '/api', title: 'API reference', headings: ['quartz(options)', 'client.query()', 'client.invalidate()', 'Options'] }], }, // Filled in below from posts, so a new post shows up in the sidebar, the // search index and the pager without being listed twice. { label: 'Blog', items: [] }, ], features: [ { icon: 'zap', title: 'Tiny by design', details: '2 kB minified and compressed, with no build step and no plugin system to learn.' }, { icon: 'refresh-cw', title: 'Stale while revalidate', details: 'Render the cached value immediately, refresh it in the background, and swap it in when it lands.' }, { icon: 'layers', title: 'Framework agnostic', details: 'A plain observable cache. Bindings for the big three frameworks are thin wrappers over the same core.' }, { icon: 'database', title: 'Normalized cache', details: 'Entities are stored once and shared by every query that references them, so one update refreshes them all.' }, { icon: 'sparkles', title: 'Devtools included', details: 'Inspect keys, staleness and in-flight requests from a panel that ships in the same package.' }, { icon: 'package', title: 'Zero dependencies', details: 'Nothing but the platform. Works in every browser that supports fetch and AbortController.' }, ],};window.QuartzData.nav[3].items = [{ route: '/blog', title: 'All posts', headings: [] }, ...window.QuartzData.posts.map((post) => ({ route: post.route, title: post.title, headings: [] }))];
pages/home.html
html
<div class="mx-auto vbox w-full max-w-6xl gap-12 px-6 py-12"> <div class="vbox gap-8 lg:hbox"> <div class="vbox gap-4"> <h1 x-h-text.h1 class="sm:text-5xl"> <span class="text-primary">Quartz</span> <br /> Data fetching in 2 kB </h1> <p x-h-text.lead>A stale while revalidate cache for the browser. Render what you have, refresh it in the background, and never write the same loading state twice.</p> <div class="hbox flex-wrap gap-2 pt-2"> <!-- Real anchors on purpose: Pinecone Router intercepts plain-path links. --> <a x-h-button data-variant="primary" href="/guide/getting-started"> Get started <svg x-h-lucide role="presentation" data-lucide="arrow-right"></svg> </a> <a x-h-button data-variant="outline" href="/guide/what-is-quartz">What is Quartz?</a> </div> </div> <!-- self-center works on both axes: horizontally centered while the hero is a column, vertically centered once it becomes a row. --> <div class="hbox shrink-0 items-center justify-center self-center rounded-full bg-muted" style="width: 14rem; height: 14rem"> <svg x-h-lucide role="presentation" class="text-primary" data-lucide="gem" style="width: 6rem; height: 6rem"></svg> </div> </div> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <template x-for="feature in $store.quartz.features" :key="feature.title"> <div x-h-card> <div x-h-card-header class="gap-3"> <div x-h-avatar class="rounded-lg" data-variant="primary"> <svg x-h-lucide role="presentation" :data-lucide="feature.icon"></svg> </div> <div x-h-card-title x-text="feature.title"></div> <div x-h-card-description x-text="feature.details"></div> </div> </div> </template> </div> <p class="text-center text-sm text-muted-foreground">Released under the MIT License. Quartz is a fictional library, written to demonstrate Harmonia.</p></div>
pages/guide-what-is-quartz.html
html
<div class="mx-auto vbox w-full max-w-3xl gap-6 px-6 py-8" x-data="docPage"> <h1 x-h-text.h1>What is Quartz?</h1> <p x-h-text.lead>Quartz is a 2 kB cache that sits between your components and your API. It hands back the value it already has, revalidates it in the background, and tells every subscriber when something better arrives.</p> <h2 id="motivation" x-h-text.h2>Motivation</h2> <p x-h-text> Most applications end up writing the same three states by hand for every endpoint they touch: loading, loaded, and failed. That code is easy to write once and miserable to keep correct across a dozen screens, especially once two of them ask for the same resource at the same time. </p> <p x-h-text>Quartz replaces that with a single cache keyed by strings you choose. Ask for a key twice and you get one request. Ask for it again a second later and you get the cached value with no request at all.</p> <p x-h-text.blockquote>The best request is the one you already made.</p> <h2 id="how-it-works" x-h-text.h2>How it works</h2> <p x-h-text> A client owns the cache. Calling <code x-h-text.code-inline>client.query()</code> returns an observable entry that emits every time its state changes, so rendering is a matter of subscribing and drawing whatever the latest state says. </p> <p x-h-text.code x-text="`const client = quartz({ ttl: 30000 })const user = client.query('user:42', () => fetch('/api/users/42').then((r) => r.json()))user.subscribe((state) => { render(state.data, state.status)})`" ></p> <p x-h-text> The first subscriber triggers the fetcher. Later subscribers attach to the same entry and receive the cached value immediately. When the entry goes stale, the next subscription revalidates it while still serving the old value, so the interface never flashes back to a spinner it has already moved past. </p> <div x-h-alert data-variant="information"> <svg x-h-lucide role="presentation" data-lucide="info"></svg> <div x-h-alert-title>Keys are yours to design</div> <div x-h-alert-description>Quartz never inspects a key. Any string works, and a convention such as resource:id is usually enough to keep invalidation obvious.</div> </div> <h2 id="when-to-use-it" x-h-text.h2>When to use it</h2> <p x-h-text>Reach for Quartz when several parts of a page read overlapping server state, when the same resource is requested from more than one route, or when you want a screen to stay usable while its data refreshes.</p> <p x-h-text>Skip it for one-off requests with no reuse, for streaming responses, and for anything that has to be written and read back in the same tick. A plain fetch is smaller than a cache you never hit twice.</p> <nav aria-label="Previous and next page" class="hbox gap-4 pt-8"> <template x-if="prev"> <a class="vbox flex-1 gap-1 rounded-lg border p-3 no-underline hover:bg-muted" :href="prev.route"> <span class="text-xs text-muted-foreground">Previous page</span> <span class="text-sm font-medium text-primary" x-text="prev.title"></span> </a> </template> <template x-if="!prev"> <div class="flex-1" aria-hidden="true"></div> </template> <template x-if="next"> <a class="vbox flex-1 items-end gap-1 rounded-lg border p-3 text-right no-underline hover:bg-muted" :href="next.route"> <span class="text-xs text-muted-foreground">Next page</span> <span class="text-sm font-medium text-primary" x-text="next.title"></span> </a> </template> <template x-if="!next"> <div class="flex-1" aria-hidden="true"></div> </template> </nav></div>
pages/guide-getting-started.html
html
<div class="mx-auto vbox w-full max-w-3xl gap-6 px-6 py-8" x-data="docPage"> <h1 x-h-text.h1>Getting started</h1> <p x-h-text.lead>Install the package, create a client, and read your first cached value. The whole setup is four lines, and nothing else has to change about how you fetch.</p> <div x-h-alert data-variant="warning"> <svg x-h-lucide role="presentation" data-lucide="triangle-alert"></svg> <div x-h-alert-title>Prerequisites</div> <div x-h-alert-description>Quartz needs fetch and AbortController. Every browser released since 2019 has both, and on the server it needs Node 18 or newer.</div> </div> <h2 id="installation" x-h-text.h2>Installation</h2> <p x-h-text>Quartz ships as an ES module with no build step and no peer dependencies.</p> <p x-h-text.code x-text="`npm install @quartz/core# orpnpm add @quartz/coreyarn add @quartz/core`" ></p> <h2 id="your-first-query" x-h-text.h2>Your first query</h2> <p x-h-text> Create one client for the application and keep it in a module. The client owns the cache, so everything that shares a client shares its entries. The second argument to <code x-h-text.code-inline>client.query()</code> is a plain function that returns a promise, which means any HTTP library works. </p> <p x-h-text.code x-text="`import { quartz } from '@quartz/core'export const client = quartz({ ttl: 30000, retry: 2,})const projects = client.query('projects', () => fetch('/api/projects').then((r) => r.json()))projects.subscribe(({ status, data, error }) => { if (status === 'error') return showError(error) renderProjects(data)})`" ></p> <p x-h-text> The subscriber runs immediately with whatever the cache holds, then again for every state change. On a cold cache that means one call with <code x-h-text.code-inline>status: 'loading'</code> followed by one with the data. On a warm cache the first call already has the data. </p> <h2 id="devtools" x-h-text.h2>Devtools</h2> <p x-h-text>The devtools panel ships in the same package and is a single import. It lists every key in the cache with its status, its age, and whether a request is in flight, and it can force a refetch from the panel.</p> <p x-h-text.code x-text="`import { mountDevtools } from '@quartz/core/devtools'if (import.meta.env.DEV) { mountDevtools(client)}`" ></p> <p x-h-text.muted>The panel is tree shaken out of production builds as long as the import stays behind a development guard.</p> <nav aria-label="Previous and next page" class="hbox gap-4 pt-8"> <template x-if="prev"> <a class="vbox flex-1 gap-1 rounded-lg border p-3 no-underline hover:bg-muted" :href="prev.route"> <span class="text-xs text-muted-foreground">Previous page</span> <span class="text-sm font-medium text-primary" x-text="prev.title"></span> </a> </template> <template x-if="!prev"> <div class="flex-1" aria-hidden="true"></div> </template> <template x-if="next"> <a class="vbox flex-1 items-end gap-1 rounded-lg border p-3 text-right no-underline hover:bg-muted" :href="next.route"> <span class="text-xs text-muted-foreground">Next page</span> <span class="text-sm font-medium text-primary" x-text="next.title"></span> </a> </template> <template x-if="!next"> <div class="flex-1" aria-hidden="true"></div> </template> </nav></div>
pages/guide-queries.html
html
<div class="mx-auto vbox w-full max-w-3xl gap-6 px-6 py-8" x-data="docPage"> <h1 x-h-text.h1>Queries</h1> <p x-h-text.lead>A query is a key, a fetcher, and the entry that binds them. Everything else in Quartz is a rule about when that entry is allowed to go out of date.</p> <h2 id="query-keys" x-h-text.h2>Query keys</h2> <p x-h-text>A key is any string. Two calls with the same key share one entry, one request, and one set of subscribers, no matter which part of the application made them.</p> <p x-h-text.code x-text="`client.query('projects', fetchProjects)client.query('project:17', () => fetchProject(17))client.query('projects?team=core&archived=false', fetchProjects)`" ></p> <p x-h-text> Keep the key derivable from the same inputs the fetcher uses. If a filter changes the response, it belongs in the key. The helper <code x-h-text.code-inline>key()</code> sorts object entries so two equivalent filter objects always produce the same string. </p> <h2 id="reading-state" x-h-text.h2>Reading state</h2> <p x-h-text>Every subscriber receives the same state object. The status tells you which of the other fields are meaningful.</p> <div x-h-table-container> <table x-h-table> <caption class="sr-only"> Query states </caption> <thead x-h-table-header> <tr x-h-table-row> <th x-h-table-head scope="col">Status</th> <th x-h-table-head scope="col">Data</th> <th x-h-table-head scope="col">Meaning</th> </tr> </thead> <tbody x-h-table-body> <tr x-h-table-row> <td x-h-table-cell>loading</td> <td x-h-table-cell>undefined</td> <td x-h-table-cell>First request for this key is in flight.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>success</td> <td x-h-table-cell>fresh</td> <td x-h-table-cell>The value is present and within its time to live.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>stale</td> <td x-h-table-cell>previous</td> <td x-h-table-cell>The value is past its time to live and a refetch is running.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>error</td> <td x-h-table-cell>previous</td> <td x-h-table-cell>The last attempt failed after every retry. The old value is kept.</td> </tr> </tbody> </table> </div> <p x-h-text> Because <code x-h-text.code-inline>stale</code> and <code x-h-text.code-inline>error</code> keep the previous value, a screen can show the data it already had next to a small refresh indicator instead of throwing the interface away every time the network hiccups. </p> <h2 id="dependent-queries" x-h-text.h2>Dependent queries</h2> <p x-h-text> A query that needs the result of another one is expressed by passing <code x-h-text.code-inline>null</code> as the key until the input is ready. Quartz leaves a null key untouched, so no request goes out and the entry stays in the loading state. </p> <p x-h-text.code x-text="`const user = client.query('user:me', fetchMe)const team = client.derive(user, (state) => state.data ? ['team:' + state.data.teamId, () => fetchTeam(state.data.teamId)] : null)`" ></p> <p x-h-text>The derived entry resubscribes whenever its source changes, which means a user switching teams moves the dependent query to a new key without any manual teardown.</p> <nav aria-label="Previous and next page" class="hbox gap-4 pt-8"> <template x-if="prev"> <a class="vbox flex-1 gap-1 rounded-lg border p-3 no-underline hover:bg-muted" :href="prev.route"> <span class="text-xs text-muted-foreground">Previous page</span> <span class="text-sm font-medium text-primary" x-text="prev.title"></span> </a> </template> <template x-if="!prev"> <div class="flex-1" aria-hidden="true"></div> </template> <template x-if="next"> <a class="vbox flex-1 items-end gap-1 rounded-lg border p-3 text-right no-underline hover:bg-muted" :href="next.route"> <span class="text-xs text-muted-foreground">Next page</span> <span class="text-sm font-medium text-primary" x-text="next.title"></span> </a> </template> <template x-if="!next"> <div class="flex-1" aria-hidden="true"></div> </template> </nav></div>
pages/guide-caching.html
html
<div class="mx-auto vbox w-full max-w-3xl gap-6 px-6 py-8" x-data="docPage"> <h1 x-h-text.h1>Caching</h1> <p x-h-text.lead>Three numbers decide the whole lifetime of an entry: how long it stays fresh, when it is thrown away, and how many subscribers are still watching it.</p> <h2 id="staleness" x-h-text.h2>Staleness</h2> <p x-h-text> An entry is fresh for <code x-h-text.code-inline>ttl</code> milliseconds after it resolves. Reading a fresh entry never touches the network. Reading a stale one serves the old value and starts a refetch in the background, which is where the library gets most of its perceived speed. </p> <p x-h-text.code x-text="`const client = quartz({ ttl: 30000, // fresh for 30 seconds gcTime: 300000, // dropped 5 minutes after the last subscriber leaves refetchOnFocus: true,})// This key is read far more often than it changes.client.query('countries', fetchCountries, { ttl: 86400000 })`" ></p> <p x-h-text>Per query options win over client options, so a reference list that changes twice a year and a dashboard that changes every minute can live in the same cache without compromise.</p> <h2 id="invalidation" x-h-text.h2>Invalidation</h2> <p x-h-text>Writes are the other half of caching. After a mutation succeeds, mark the affected keys stale and every screen watching them refreshes itself.</p> <p x-h-text.code x-text="`await saveProject(project)client.invalidate('project:' + project.id)client.invalidate((key) => key.startsWith('projects'))`" ></p> <p x-h-text> Passing a predicate invalidates every matching key at once, which is the usual way to clear a list and all of its filtered variants after an item is added. Entries with no subscribers are simply marked, so nothing is refetched until something asks for it again. </p> <div x-h-alert data-variant="information"> <svg x-h-lucide role="presentation" data-lucide="info"></svg> <div x-h-alert-title>Invalidate, do not clear</div> <div x-h-alert-description>Clearing removes the value and sends every screen back to a spinner. Invalidating keeps it on screen while the fresh copy is fetched.</div> </div> <h2 id="garbage-collection" x-h-text.h2>Garbage collection</h2> <p x-h-text> When the last subscriber to an entry unsubscribes, the entry is kept for <code x-h-text.code-inline>gcTime</code> and then dropped. Navigating away from a route and straight back therefore restores it from cache, while a tab left open overnight does not hold on to data nobody is watching. </p> <p x-h-text.muted>In flight requests for a collected key are aborted, so a route that unmounts mid request does not keep the connection open.</p> <nav aria-label="Previous and next page" class="hbox gap-4 pt-8"> <template x-if="prev"> <a class="vbox flex-1 gap-1 rounded-lg border p-3 no-underline hover:bg-muted" :href="prev.route"> <span class="text-xs text-muted-foreground">Previous page</span> <span class="text-sm font-medium text-primary" x-text="prev.title"></span> </a> </template> <template x-if="!prev"> <div class="flex-1" aria-hidden="true"></div> </template> <template x-if="next"> <a class="vbox flex-1 items-end gap-1 rounded-lg border p-3 text-right no-underline hover:bg-muted" :href="next.route"> <span class="text-xs text-muted-foreground">Next page</span> <span class="text-sm font-medium text-primary" x-text="next.title"></span> </a> </template> <template x-if="!next"> <div class="flex-1" aria-hidden="true"></div> </template> </nav></div>
pages/api.html
html
<div class="mx-auto vbox w-full max-w-3xl gap-6 px-6 py-8" x-data="docPage"> <h1 x-h-text.h1>API reference</h1> <p x-h-text.lead>The whole public surface is one factory and four methods on the client it returns.</p> <h2 id="quartz-options" x-h-text.h2>quartz(options)</h2> <p x-h-text>Creates a client and its cache. Most applications create exactly one and export it.</p> <p x-h-text.code x-text="`import { quartz } from '@quartz/core'const client = quartz({ ttl: 30000, retry: 2 })`" ></p> <h2 id="client-query" x-h-text.h2>client.query()</h2> <p x-h-text> Returns the entry for a key, creating and fetching it if needed. The returned object exposes <code x-h-text.code-inline>subscribe()</code>, <code x-h-text.code-inline>refetch()</code> and a readonly <code x-h-text.code-inline>state</code>. </p> <p x-h-text.code x-text="`const entry = client.query(key, fetcher, options)const stop = entry.subscribe((state) => render(state))stop() // unsubscribe`" ></p> <div x-h-table-container> <table x-h-table> <caption class="sr-only"> Arguments </caption> <thead x-h-table-header> <tr x-h-table-row> <th x-h-table-head scope="col">Argument</th> <th x-h-table-head scope="col">Type</th> <th x-h-table-head scope="col">Description</th> </tr> </thead> <tbody x-h-table-body> <tr x-h-table-row> <td x-h-table-cell>key</td> <td x-h-table-cell>string or null</td> <td x-h-table-cell>Cache identity. A null key stays in the loading state and sends no request.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>fetcher</td> <td x-h-table-cell>function</td> <td x-h-table-cell>Returns a promise. Receives an AbortSignal as its only argument.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>options</td> <td x-h-table-cell>object</td> <td x-h-table-cell>Per query overrides for any of the client options below.</td> </tr> </tbody> </table> </div> <h2 id="client-invalidate" x-h-text.h2>client.invalidate()</h2> <p x-h-text>Marks one key, or every key matching a predicate, as stale. Watched entries refetch immediately and unwatched ones refetch the next time they are read.</p> <p x-h-text.code x-text="`client.invalidate('project:17')client.invalidate((key) => key.startsWith('projects'))client.clear() // drop everything, spinners included`" ></p> <h2 id="options" x-h-text.h2>Options</h2> <div x-h-table-container> <table x-h-table> <caption class="sr-only"> Options </caption> <thead x-h-table-header> <tr x-h-table-row> <th x-h-table-head scope="col">Option</th> <th x-h-table-head scope="col">Type</th> <th x-h-table-head scope="col">Default</th> <th x-h-table-head scope="col">Description</th> </tr> </thead> <tbody x-h-table-body> <tr x-h-table-row> <td x-h-table-cell>ttl</td> <td x-h-table-cell>number</td> <td x-h-table-cell>30000</td> <td x-h-table-cell>Milliseconds an entry stays fresh after it resolves.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>gcTime</td> <td x-h-table-cell>number</td> <td x-h-table-cell>300000</td> <td x-h-table-cell>Milliseconds an unwatched entry is kept before it is dropped.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>retry</td> <td x-h-table-cell>number</td> <td x-h-table-cell>2</td> <td x-h-table-cell>Retries after a failed request, with exponential backoff.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>refetchOnFocus</td> <td x-h-table-cell>boolean</td> <td x-h-table-cell>true</td> <td x-h-table-cell>Revalidate stale entries when the tab regains focus.</td> </tr> <tr x-h-table-row> <td x-h-table-cell>refetchOnReconnect</td> <td x-h-table-cell>boolean</td> <td x-h-table-cell>true</td> <td x-h-table-cell>Revalidate stale entries when the browser comes back online.</td> </tr> </tbody> </table> </div> <nav aria-label="Previous and next page" class="hbox gap-4 pt-8"> <template x-if="prev"> <a class="vbox flex-1 gap-1 rounded-lg border p-3 no-underline hover:bg-muted" :href="prev.route"> <span class="text-xs text-muted-foreground">Previous page</span> <span class="text-sm font-medium text-primary" x-text="prev.title"></span> </a> </template> <template x-if="!prev"> <div class="flex-1" aria-hidden="true"></div> </template> <template x-if="next"> <a class="vbox flex-1 items-end gap-1 rounded-lg border p-3 text-right no-underline hover:bg-muted" :href="next.route"> <span class="text-xs text-muted-foreground">Next page</span> <span class="text-sm font-medium text-primary" x-text="next.title"></span> </a> </template> <template x-if="!next"> <div class="flex-1" aria-hidden="true"></div> </template> </nav></div>
<div class="mx-auto vbox w-full max-w-3xl gap-6 px-6 py-8" x-data="docPage"> <!-- A real anchor on purpose: Pinecone Router intercepts plain-path links. --> <a class="hbox w-max items-center gap-1 text-sm text-muted-foreground no-underline" href="/blog"> <svg x-h-lucide role="presentation" data-lucide="arrow-left"></svg> All posts </a> <!-- Title and byline come from the posts list in js/data.js, so the card on the blog index and this page cannot disagree. --> <h1 x-h-text.h1 x-text="post.title"></h1> <div class="hbox items-center gap-2"> <div x-h-avatar class="text-sm" aria-hidden="true" x-text="post.initials"></div> <div class="vbox"> <span class="text-sm font-medium" x-text="post.author"></span> <span class="text-sm text-muted-foreground" x-text="post.date"></span> </div> </div> <p x-h-text.lead>Focus and reconnect refetching are now driven by a single scheduler. A tab that wakes up after an hour asleep sends one request instead of twelve.</p> <h2 x-h-text.h2>The problem</h2> <p x-h-text> Until 1.0.4 every entry listened for its own focus and online events. That was simple and worked well for a handful of keys, but a dashboard holding thirty entries woke up to thirty independent revalidations, all racing for the same connection pool and all arriving at roughly the same useless moment. </p> <p x-h-text>Worse, entries that were still fresh joined in anyway, because the freshness check ran after the event handler rather than before it.</p> <h2 x-h-text.h2>What changed</h2> <p x-h-text>There is now one scheduler per client. It collects wake events, filters the cache down to entries that are both stale and watched, and releases them in batches with a small stagger between them.</p> <p x-h-text.code x-text="`const client = quartz({ refetchOnFocus: true, // New in 1.1: cap the burst after a wake event. revalidateBatch: 4, revalidateStagger: 120,})`" ></p> <p x-h-text>The defaults are conservative and most applications will never touch them. Setting the batch size to zero restores the previous all at once behavior.</p> <h2 x-h-text.h2>Upgrading</h2> <p x-h-text> This is a minor release with no breaking changes. Update the package and the scheduler takes over. If you were working around the burst by turning <code x-h-text.code-inline>refetchOnFocus</code> off, this is a good moment to turn it back on. </p> <nav aria-label="Previous and next page" class="hbox gap-4 pt-8"> <template x-if="prev"> <a class="vbox flex-1 gap-1 rounded-lg border p-3 no-underline hover:bg-muted" :href="prev.route"> <span class="text-xs text-muted-foreground">Previous page</span> <span class="text-sm font-medium text-primary" x-text="prev.title"></span> </a> </template> <template x-if="!prev"> <div class="flex-1" aria-hidden="true"></div> </template> <template x-if="next"> <a class="vbox flex-1 items-end gap-1 rounded-lg border p-3 text-right no-underline hover:bg-muted" :href="next.route"> <span class="text-xs text-muted-foreground">Next page</span> <span class="text-sm font-medium text-primary" x-text="next.title"></span> </a> </template> <template x-if="!next"> <div class="flex-1" aria-hidden="true"></div> </template> </nav></div>
pages/blog-announcing-quartz-1-0.html
html
<div class="mx-auto vbox w-full max-w-3xl gap-6 px-6 py-8" x-data="docPage"> <!-- A real anchor on purpose: Pinecone Router intercepts plain-path links. --> <a class="hbox w-max items-center gap-1 text-sm text-muted-foreground no-underline" href="/blog"> <svg x-h-lucide role="presentation" data-lucide="arrow-left"></svg> All posts </a> <!-- Title and byline come from the posts list in js/data.js, so the card on the blog index and this page cannot disagree. --> <h1 x-h-text.h1 x-text="post.title"></h1> <div class="hbox items-center gap-2"> <div x-h-avatar class="text-sm" aria-hidden="true" x-text="post.initials"></div> <div class="vbox"> <span class="text-sm font-medium" x-text="post.author"></span> <span class="text-sm text-muted-foreground" x-text="post.date"></span> </div> </div> <p x-h-text.lead>After fourteen months in production the cache API is stable. Here is what shipped, what we deliberately left out, and how to move off the beta.</p> <h2 x-h-text.h2>Why we built it</h2> <p x-h-text> We kept writing the same cache. Every project started with a map of promises, grew a staleness check, then grew a way to invalidate after a write, and by the third one it was obvious the interesting part was never the fetching. It was deciding when a value is allowed to be old. </p> <p x-h-text>Quartz is that decision, extracted. The rest of the library is deliberately boring.</p> <h2 x-h-text.h2>What we left out</h2> <p x-h-text> There is no request builder, no schema layer, no plugin system and no framework binding in the core package. Each of those was prototyped and each one made the library harder to explain than the problem it solved. Keeping them out is what holds the bundle at 2 kB. </p> <p x-h-text.blockquote>A cache that everyone can read the source of is a cache that everyone can debug.</p> <h2 x-h-text.h2>Getting started</h2> <p x-h-text>The beta exported a default. The 1.0 entry point is a named export, which is the only change most codebases need to make.</p> <p x-h-text.code x-text="`- import quartz from '@quartz/core'+ import { quartz } from '@quartz/core'const client = quartz({ ttl: 30000 })`" ></p> <nav aria-label="Previous and next page" class="hbox gap-4 pt-8"> <template x-if="prev"> <a class="vbox flex-1 gap-1 rounded-lg border p-3 no-underline hover:bg-muted" :href="prev.route"> <span class="text-xs text-muted-foreground">Previous page</span> <span class="text-sm font-medium text-primary" x-text="prev.title"></span> </a> </template> <template x-if="!prev"> <div class="flex-1" aria-hidden="true"></div> </template> <template x-if="next"> <a class="vbox flex-1 items-end gap-1 rounded-lg border p-3 text-right no-underline hover:bg-muted" :href="next.route"> <span class="text-xs text-muted-foreground">Next page</span> <span class="text-sm font-medium text-primary" x-text="next.title"></span> </a> </template> <template x-if="!next"> <div class="flex-1" aria-hidden="true"></div> </template> </nav></div>
pages/not-found.html
html
<div class="vbox size-full items-center justify-center p-4"> <div x-h-info-page> <div x-h-info-page-header> <div x-h-info-page-media.icon> <svg x-h-lucide role="presentation" data-lucide="compass"></svg> </div> <h1 x-h-info-page-title>Page not found</h1> <div x-h-info-page-description x-text="'Nothing is documented at ' + $router.context.path + '. It may have moved, or the address was mistyped.'"></div> </div> <div x-h-info-page-content class="hbox gap-2"> <button x-h-button data-variant="primary" @click="go('/')">Go home</button> <!-- A real anchor on purpose: Pinecone Router intercepts plain-path links. --> <a x-h-button data-variant="outline" href="/guide/what-is-quartz">Read the guide</a> </div> </div></div>
Ember Habits
A single-file, mobile-first habit tracker with bottom navigation, a quick-add floating action button, progress rings, and a completion calendar.