From 9d52cdde69658f0e32e166c3f1a6c090e5875f36 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 16:47:30 -0500 Subject: [PATCH 01/14] fix(lightbox): stop trigger buttons from submitting enclosing forms Triggers were set to type="submit" (an inverted typo for "button"), so a lightbox button inside any form opened the lightbox and submitted the form. Also only set the type on actual button elements. --- src/lib/modules/lightbox.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/modules/lightbox.ts b/src/lib/modules/lightbox.ts index ec77f29..7c303bf 100644 --- a/src/lib/modules/lightbox.ts +++ b/src/lib/modules/lightbox.ts @@ -248,7 +248,10 @@ export default ModuleSchema.implement(els => { groups[group].push(el) - el.setAttribute('type', 'submit') + if (el instanceof HTMLButtonElement) { + el.type = 'button' + } + listen(el, 'click', () => open(el)) listen(el, 'mouseover', () => preload(el)) } From bba0b3c608e60dafaacb1c24012b17b152b1fe9a Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 16:47:37 -0500 Subject: [PATCH 02/14] fix(slide): flush start height so slide transitions animate Start and end heights were written in the same synchronous block, so the browser only ever saw auto -> 0 / auto -> Npx, which is not interpolable and made slideUp/slideDown jump instantly. Force a reflow between the writes to give the transition an interpolable origin. --- src/lib/util/slide.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/util/slide.ts b/src/lib/util/slide.ts index c3f6d69..4df5a48 100644 --- a/src/lib/util/slide.ts +++ b/src/lib/util/slide.ts @@ -4,6 +4,7 @@ export function slideUp(target: HTMLElement, duration = 500): void { target.style.boxSizing = 'border-box' target.style.height = `${target.offsetHeight}px` target.style.overflow = 'hidden' + void target.offsetHeight // flush the start height so the transition has an interpolable origin target.style.height = '0' target.style.paddingTop = '0' target.style.paddingBottom = '0' @@ -48,6 +49,7 @@ export function slideDown(target: HTMLElement, duration = 500): void { target.style.boxSizing = 'border-box' target.style.transitionProperty = 'height, margin, padding' target.style.transitionDuration = `${duration}ms` + void target.offsetHeight // flush the zeroed start state so the transition has an interpolable origin target.style.height = `${data.height}px` target.style.removeProperty('padding-top') target.style.removeProperty('padding-bottom') From f46db52482cc5a03076f6c70aef7473c100d055b Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 16:47:45 -0500 Subject: [PATCH 03/14] fix(buildchain): harden Vite config and tinify plugin - Only fall back to the Vite dev server in the dev environment; a missing manifest in staging/production now fails loudly instead of serving every asset from the dev server port. - Refuse to run the pre-build rmSync when VITE_BASE/VITE_TEMP resolve to an empty path, which would have emptied the webroot. - Pass TINYPNG_KEY into the tinify plugin from the validated env parse; loadEnv() never populates process.env, so the plugin would have thrown on the first raster image in a bundle. - Hash raw asset bytes for the tinify cache checksum instead of a lossy UTF-8 decode, which could collide across distinct images and serve the wrong cached file. Existing cache entries re-tinify once. --- config/vite.php | 2 +- utility/vite-plugin-tinify.ts | 8 ++++---- vite.config.ts | 10 ++++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/config/vite.php b/config/vite.php index b7b91f9..f1a8ea6 100644 --- a/config/vite.php +++ b/config/vite.php @@ -16,7 +16,7 @@ return [ 'manifestPath' => $manifest, - 'useDevServer' => !is_file($manifest), + 'useDevServer' => App::env('CRAFT_ENVIRONMENT') === 'dev' && !is_file($manifest), 'serverPublic' => UrlHelper::siteHost() . $viteBase . '/', 'devServerPublic' => implode(':', [ $primarySiteUrl, diff --git a/utility/vite-plugin-tinify.ts b/utility/vite-plugin-tinify.ts index f8333d5..ed224ab 100644 --- a/utility/vite-plugin-tinify.ts +++ b/utility/vite-plugin-tinify.ts @@ -3,12 +3,12 @@ import fs from 'node:fs' import tinify from 'tinify' import type { Plugin } from 'vite' -export default (): Plugin => ({ +export default (options: { key?: string } = {}): Plugin => ({ name: 'vite-plugin-tinify', async generateBundle(_, bundler) { for (const [path, asset] of Object.entries(bundler)) { if (/\.(png|jpe?g)$/.test(path) && asset.type === 'asset' && typeof asset.source !== 'undefined') { - const checksum = crypto.createHash('sha1').update(asset.source.toString()).digest('hex') + const checksum = crypto.createHash('sha1').update(asset.source).digest('hex') const checksumfile = `node_modules/.vite/tinify/${checksum}` let content: Buffer | Uint8Array | undefined @@ -16,7 +16,7 @@ export default (): Plugin => ({ if (fs.existsSync(checksumfile)) { content = fs.readFileSync(checksumfile) } else { - if (!process.env.TINYPNG_KEY) { + if (!options.key) { throw new Error('vite-plugin-tinify: TINYPNG_KEY not defined. **Images not optimized**') } @@ -40,7 +40,7 @@ export default (): Plugin => ({ }, process.cwd()) } - tinify.key = process.env.TINYPNG_KEY + tinify.key = options.key content = await tinify.fromBuffer(asset.source).toBuffer() fs.writeFile(checksumfile, content, error => error && console.log(error)) diff --git a/vite.config.ts b/vite.config.ts index 6784305..24ea735 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,11 +12,17 @@ import tinify from './utility/vite-plugin-tinify' const root = dirname(fileURLToPath(import.meta.url)) export default defineConfig(({ mode }) => { - const { VITE_BASE, VITE_PORT, VITE_TEMP, PRIMARY_SITE_URL } = env.parse(mode) + const { VITE_BASE, VITE_PORT, VITE_TEMP, PRIMARY_SITE_URL, TINYPNG_KEY } = env.parse(mode) const basePath = toBasePath(VITE_BASE) const outDir = join('web', VITE_TEMP ? toBasePath(VITE_TEMP) : basePath) + if (outDir === 'web') { + throw new Error( + 'VITE_BASE (or VITE_TEMP) must resolve to a subdirectory of web/ — refusing to empty the webroot', + ) + } + fs.rmSync(outDir, { recursive: true, force: true, @@ -25,7 +31,7 @@ export default defineConfig(({ mode }) => { return { publicDir: false, base: `/${basePath}/`, - plugins: [tailwindcss(), svelte(), tinify(), svgo()], + plugins: [tailwindcss(), svelte(), tinify({ key: TINYPNG_KEY }), svgo()], css: { transformer: 'lightningcss', lightningcss: { From ae8a11ddaf2eb97d3c3027b88118c2f83a81a2f7 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 16:47:50 -0500 Subject: [PATCH 04/14] fix(global): parse window.$app lazily with a descriptive error The strict AppSchema parse ran at module init, so any mismatch in the Twig-emitted $app object threw while importing nearly every module and killed all page JS. The parse (still strict) now runs on first property access behind a memoized proxy, scoping a failure to the code that actually reads craft, and reports the offending paths via prettifyError. --- src/lib/stores/global.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/lib/stores/global.ts b/src/lib/stores/global.ts index 8901a66..04de0cc 100644 --- a/src/lib/stores/global.ts +++ b/src/lib/stores/global.ts @@ -1,4 +1,5 @@ import { MediaQuery } from 'svelte/reactivity' +import * as z from 'zod/mini' import { AppSchema, type App } from '$lib/schemas/app' const root = getComputedStyle(document.body) @@ -21,7 +22,29 @@ interface ScreenState { 'is2xl': MediaQuery } -export const craft: Readonly = Object.freeze(AppSchema.parse(window.$app)) +let app: Readonly | null = null + +/** Parsed on first access rather than at module init, so a `window.$app` mismatch only fails the code that reads `craft`. */ +function resolve(): Readonly { + if (!app) { + const result = AppSchema.safeParse(window.$app) + + if (!result.success) { + throw new Error(`window.$app failed validation:\n${z.prettifyError(result.error)}`) + } + + app = Object.freeze(result.data) + } + + return app +} + +export const craft: Readonly = new Proxy({} as App, { + get: (_, prop) => Reflect.get(resolve(), prop), + has: (_, prop) => Reflect.has(resolve(), prop), + ownKeys: () => Reflect.ownKeys(resolve()), + getOwnPropertyDescriptor: (_, prop) => Object.getOwnPropertyDescriptor(resolve(), prop), +}) export const screen: Readonly = Object.freeze({ 'prefersReducedMotion': new MediaQuery('prefers-reduced-motion: reduce'), From 1d6e9af371d3deb0bcdcdb2f33bb1b090e936fdc Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:21:52 -0500 Subject: [PATCH 05/14] chore(tooling): enable bracketSameLine and switch Zed to typescript-ls --- .zed/settings.json | 4 ++-- oxfmt.config.ts | 1 + src/README.md | 3 +-- src/lib/components/common/Image.svelte | 3 +-- src/lib/components/common/Picture.svelte | 3 +-- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index c4ba147..7de0777 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -16,14 +16,14 @@ "JavaScript": { "format_on_save": "on", "prettier": { "allowed": false }, - "language_servers": ["oxlint", "oxfmt", "tsgo"], + "language_servers": ["oxlint", "oxfmt", "typescript-ls"], "formatter": [{ "language_server": { "name": "oxfmt" } }], "code_actions_on_format": { "source.fixAll.oxc": true } }, "TypeScript": { "format_on_save": "on", "prettier": { "allowed": false }, - "language_servers": ["oxlint", "oxfmt", "tsgo"], + "language_servers": ["oxlint", "oxfmt", "typescript-ls"], "formatter": [{ "language_server": { "name": "oxfmt" } }], "code_actions_on_format": { "source.fixAll.oxc": true } }, diff --git a/oxfmt.config.ts b/oxfmt.config.ts index ed2a01d..dea0587 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -4,6 +4,7 @@ const config: OxfmtConfig = { semi: false, singleQuote: true, arrowParens: 'avoid', + bracketSameLine: true, sortTailwindcss: true, sortPackageJson: true, quoteProps: 'consistent', diff --git a/src/README.md b/src/README.md index 7995a56..a611d3e 100644 --- a/src/README.md +++ b/src/README.md @@ -114,8 +114,7 @@ All `data-*` attributes on the target element are automatically converted to com data-uid="123e4567-e89b-12d3-a456-426614174000" data-play-inline="true" data-config='{"autoplay": false, "controls": true}' - data-delay="1500" -> + data-delay="1500"> ``` diff --git a/src/lib/components/common/Image.svelte b/src/lib/components/common/Image.svelte index f85c4a5..812903d 100644 --- a/src/lib/components/common/Image.svelte +++ b/src/lib/components/common/Image.svelte @@ -39,8 +39,7 @@ {height} {...rest} {style} - src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" - /> + src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" /> {/snippet} {/if} diff --git a/src/lib/components/common/Picture.svelte b/src/lib/components/common/Picture.svelte index 642cdf5..37bfeca 100644 --- a/src/lib/components/common/Picture.svelte +++ b/src/lib/components/common/Picture.svelte @@ -32,6 +32,5 @@ ? `visibility:hidden;aspect-ratio:${width}/${height};max-width:${width}px;max-height:${height}px;` : undefined} onerror={() => (isLoading = false)} - onload={() => (isLoading = false)} - /> + onload={() => (isLoading = false)} /> From 845ea1dae805b45d625b84823eb405e1286e67e3 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:21:59 -0500 Subject: [PATCH 06/14] fix(lightbox): rebuild on native showModal dialog The dialog was opened via the open attribute, so there was no top layer, focus trap, or inert background, and the Tab handler navigated images without preventDefault, walking focus through the obscured page. Use showModal()/close() for native modal semantics, move the nav buttons (now labeled) inside the dialog, navigate with arrow keys only, restore focus to the trigger on close, and style the native ::backdrop with a defined color in place of the undefined brand token. jsdom ships HTMLDialogElement without its methods, so add a minimal test-only polyfill via a vitest setup file. --- src/lib/modules/lightbox.ts | 85 ++++++++++++++++--------------------- tests/lightbox.test.ts | 22 +++++----- tests/setup.ts | 17 ++++++++ vitest.config.ts | 1 + 4 files changed, 65 insertions(+), 60 deletions(-) create mode 100644 tests/setup.ts diff --git a/src/lib/modules/lightbox.ts b/src/lib/modules/lightbox.ts index 7c303bf..a870183 100644 --- a/src/lib/modules/lightbox.ts +++ b/src/lib/modules/lightbox.ts @@ -12,7 +12,6 @@ const preloaded = new Set() export default ModuleSchema.implement(els => { const forward = document.createElement('button') const backward = document.createElement('button') - const backdrop = document.createElement('div') const dialog = document.createElement('dialog') const groups: Record = {} const cleanups: Array<() => void> = [] @@ -20,26 +19,26 @@ export default ModuleSchema.implement(els => { let current: HTMLElement | null = null let scrollRelease: (() => void) | null = null - document.body.append(backdrop) - backdrop.append(dialog) - backdrop.append(forward) - backdrop.append(backward) + document.body.append(dialog) + dialog.append(backward) + dialog.append(forward) + backward.type = 'button' + backward.setAttribute('aria-label', 'previous image') backward.setAttribute( 'class', - 'flex fixed left-4 bottom-6 z-50 transition sm:bottom-auto sm:top-1/2 hover:text-white text-brand-orange', + 'flex fixed left-4 bottom-6 z-50 transition sm:bottom-auto sm:top-1/2 hover:text-white text-white/60', ) + forward.type = 'button' + forward.setAttribute('aria-label', 'next image') forward.setAttribute( 'class', - 'flex fixed right-4 bottom-6 z-50 transition sm:bottom-auto sm:top-1/2 hover:text-white text-brand-orange', - ) - backdrop.setAttribute( - 'class', - 'fixed inset-0 z-20 opacity-0 transition pointer-events-none bg-brand-gray-darker/95', + 'flex fixed right-4 bottom-6 z-50 transition sm:bottom-auto sm:top-1/2 hover:text-white text-white/60', ) + dialog.setAttribute('aria-label', 'image viewer') dialog.setAttribute( 'class', - 'overflow-auto fixed top-1/2 left-1/2 z-50 max-w-7xl rounded-md transform -translate-x-1/2 -translate-y-1/2 w-[90dvw] max-h-[90dvh]', + 'overflow-auto fixed top-1/2 left-1/2 z-50 max-w-7xl rounded-md transform -translate-x-1/2 -translate-y-1/2 w-[90dvw] max-h-[90dvh] backdrop:bg-neutral-950/95', ) forward.innerHTML = markup(rightArrowIcon, { @@ -95,13 +94,13 @@ export default ModuleSchema.implement(els => { return } - const { code, shiftKey } = event + const { code } = event if (code === 'Escape') { close() } - if (!dialog.hasAttribute('open')) { + if (!dialog.open) { return } @@ -111,51 +110,35 @@ export default ModuleSchema.implement(els => { const collection = getGroup(current) - if (code === 'ArrowLeft' || (code === 'Tab' && shiftKey)) { - const i = prev(collection.indexOf(current), collection.length) - - close() - open(collection[i]) - - current.focus() + if (code === 'ArrowLeft') { + event.preventDefault() + open(collection[prev(collection.indexOf(current), collection.length)]) } - if (code === 'ArrowRight' || (code === 'Tab' && !shiftKey)) { - const i = next(collection.indexOf(current), collection.length) - - close() - open(collection[i]) - - current.focus() + if (code === 'ArrowRight') { + event.preventDefault() + open(collection[next(collection.indexOf(current), collection.length)]) } }) - listen(forward, 'click', event => { - event.stopPropagation() - + listen(forward, 'click', () => { if (!current) { return } const collection = getGroup(current) - const i = next(collection.indexOf(current), collection.length) - close() - open(collection[i]) + open(collection[next(collection.indexOf(current), collection.length)]) }) - listen(backward, 'click', event => { - event.stopPropagation() - + listen(backward, 'click', () => { if (!current) { return } const collection = getGroup(current) - const i = prev(collection.indexOf(current), collection.length) - close() - open(collection[i]) + open(collection[prev(collection.indexOf(current), collection.length)]) }) const preload = (el: HTMLElement | undefined) => { @@ -204,10 +187,9 @@ export default ModuleSchema.implement(els => { img.setAttribute('src', src) } - backdrop.classList.remove('opacity-0') - backdrop.classList.remove('pointer-events-none') - - dialog.setAttribute('open', '') + if (!dialog.open) { + dialog.showModal() + } if (!scrollRelease) { scrollRelease = lockScroll() @@ -226,12 +208,17 @@ export default ModuleSchema.implement(els => { scrollRelease = null } - backdrop.classList.add('opacity-0') - backdrop.classList.add('pointer-events-none') - dialog.removeAttribute('open') + dialog.close() + current?.focus() } - listen(backdrop, 'click', () => close()) + listen(dialog, 'click', event => { + if (event.target === dialog) { + close() + } + }) + + listen(dialog, 'cancel', () => close()) for (const el of els) { const group = el.dataset.lightboxGroup || DEFAULT_GROUP @@ -262,7 +249,7 @@ export default ModuleSchema.implement(els => { } close() - backdrop.remove() + dialog.remove() current = null } }) diff --git a/tests/lightbox.test.ts b/tests/lightbox.test.ts index 6441f8d..e04c276 100644 --- a/tests/lightbox.test.ts +++ b/tests/lightbox.test.ts @@ -43,13 +43,13 @@ describe('lightbox', () => { const cleanup = mountLightbox('') const button = document.querySelector('[data-lightbox]') - const backdrop = document.querySelector('div') + const dialog = document.querySelector('dialog') button?.click() expect(document.body.style.overflow).toBe('hidden') - backdrop?.click() + dialog?.click() expect(document.body.style.overflow).toBe('visible') @@ -76,15 +76,15 @@ describe('lightbox', () => { secondCleanup() }) - test('registers one backdrop close listener per initialization', () => { - const divListener = vi.spyOn(HTMLDivElement.prototype, 'addEventListener') + test('registers one dialog close listener per initialization', () => { + const dialogListener = vi.spyOn(HTMLDialogElement.prototype, 'addEventListener') const cleanup = mountLightbox(` `) - expect(divListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1) + expect(dialogListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1) cleanup() }) @@ -96,13 +96,13 @@ describe('lightbox', () => { `) const buttons = Array.from(document.querySelectorAll('[data-lightbox]')) - const backdrop = document.querySelector('div') + const dialog = document.querySelector('dialog') - if (!backdrop) { - throw new Error('Lightbox backdrop was not created.') + if (!dialog) { + throw new Error('Lightbox dialog was not created.') } - const navButtons = Array.from(backdrop.children).filter( + const navButtons = Array.from(dialog.children).filter( (child): child is HTMLButtonElement => child instanceof HTMLButtonElement, ) @@ -111,10 +111,10 @@ describe('lightbox', () => { expect(navButtons).toHaveLength(2) expect(navButtons.every(button => button.hidden)).toBe(true) - backdrop.click() + dialog.click() buttons[1]?.click() - expect(navButtons.every(button => button.parentElement === backdrop)).toBe(true) + expect(navButtons.every(button => button.parentElement === dialog)).toBe(true) expect(navButtons.every(button => !button.hidden)).toBe(true) cleanup() diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..8fdb0ff --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,17 @@ +// jsdom exposes HTMLDialogElement but implements none of its methods. +if (typeof HTMLDialogElement !== 'undefined' && typeof HTMLDialogElement.prototype.showModal !== 'function') { + HTMLDialogElement.prototype.show = function (this: HTMLDialogElement) { + this.open = true + } + + HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) { + this.open = true + } + + HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) { + if (this.open) { + this.open = false + this.dispatchEvent(new Event('close')) + } + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 16df249..2124b81 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,5 +21,6 @@ export default defineConfig({ test: { environment: 'jsdom', include: ['tests/**/*.test.ts'], + setupFiles: ['tests/setup.ts'], }, }) From a33963fae739fa54485aa937c14a0572963c4573 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:22:09 -0500 Subject: [PATCH 07/14] fix(components): repair Modal focus handling and undefined colors The focus trap cached its focusable list at mount (missing async children) and its selector omitted iframes and media controls, so a video modal looped Tab on the close button forever. Query focusables at keydown time with a broadened selector, gate key handling on the active modal id, recapture focus that escapes the dialog, fire onclose when another modal replaces this one, restore focus to the previously focused element on close, and add a label prop for aria-label. Also replace undefined brand-* color utilities with neutral built-ins in Modal and Video. --- src/lib/components/common/Modal.svelte | 71 ++++++++++++++++++-------- src/lib/components/common/Video.svelte | 24 +++------ 2 files changed, 59 insertions(+), 36 deletions(-) diff --git a/src/lib/components/common/Modal.svelte b/src/lib/components/common/Modal.svelte index 7e45c69..91d687f 100644 --- a/src/lib/components/common/Modal.svelte +++ b/src/lib/components/common/Modal.svelte @@ -1,5 +1,6 @@ @@ -148,23 +181,21 @@ tabindex="-1" role="dialog" aria-modal="true" + aria-label={label} onclick={onBackdropClick} onkeydown={onBackdropKeydown} transition:blur={{ duration: 150 }} - {@attach modal} - > + {@attach modal}>
+ class:border-neutral-300={overlay === 'polite'}> {@render children()} diff --git a/src/lib/components/common/Video.svelte b/src/lib/components/common/Video.svelte index 8d28c4b..ef3616b 100644 --- a/src/lib/components/common/Video.svelte +++ b/src/lib/components/common/Video.svelte @@ -122,8 +122,7 @@ {#snippet preview()} {#snippet icon()}
+ class="inset-center absolute flex rounded-full bg-white p-4 text-black transition group-hover:bg-neutral-200">
{/snippet} @@ -135,8 +134,7 @@ onclick={playInline ? activateInline : openModal} class="group relative w-full" aria-label="play video" - style:--focusable-color="currentcolor" - > + style:--focusable-color="currentcolor"> {@render icon()} {embed.title} + loading="lazy" /> {:else if upload} @@ -173,8 +168,7 @@
+ style:--aspect-ratio="{embed.width}/{embed.height}">
{:else if upload} @@ -194,8 +187,7 @@ autoplay playsinline preload="metadata" - {@attach playVideo} - > + {@attach playVideo}> {/if} From d3c120fb1339b84dc808ee3fcae6451efeea3a17 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:22:15 -0500 Subject: [PATCH 08/14] fix(image): fall back to asset dimensions like the PHP original Sources with null or partial transform args rendered width="0" height="0" images and baked zeroed dimensions into the 2x srcset URL. Fall back to the asset intrinsic size for the attributes and only double dimensions that were actually provided, matching GeneralExtension::imageAttributes(). --- src/lib/util/image.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/util/image.ts b/src/lib/util/image.ts index 2ff8593..8cd2ee1 100644 --- a/src/lib/util/image.ts +++ b/src/lib/util/image.ts @@ -59,7 +59,11 @@ function attributes(source: ImageSource, loading = 'lazy') { if (asset.uid) { const src = imgix(asset.src, args || {}) - const src2x = imgix(asset.src, { ...args, width: width * 2, height: height * 2 }) + const src2x = imgix(asset.src, { + ...args, + ...(width ? { width: width * 2 } : {}), + ...(height ? { height: height * 2 } : {}), + }) const alt = asset.alt if (!width && height) { @@ -70,6 +74,9 @@ function attributes(source: ImageSource, loading = 'lazy') { height = Math.floor(Math.min(asset.width, width) * (asset.height / asset.width)) } + width = width || asset.width + height = height || asset.height + Object.assign(attrs, { width, height, From 870b9ed02a6d3f4b7eb0f26964fba27adf7211ac Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:22:21 -0500 Subject: [PATCH 09/14] perf(nav): eager-load the menu once for both navigations Desktop and mobile menus each lazy-loaded navigation.main and every dropdown item lazy-loaded item.links, costing roughly 2 + 2N queries per uncached request. Compute the menu once with eagerly(), pass it to the mobile include explicitly, and batch child links. --- templates/common/_mobileNavigation.twig | 4 ++-- templates/common/_navigation.twig | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/common/_mobileNavigation.twig b/templates/common/_mobileNavigation.twig index 33c534b..d4c8aad 100644 --- a/templates/common/_mobileNavigation.twig +++ b/templates/common/_mobileNavigation.twig @@ -2,7 +2,7 @@
-{{ include('common/_mobileNavigation') }} +{{ include('common/_mobileNavigation', { menu: menu }) }} From 5a276fdaad25201392aa9907561ebbbc9257e978 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:22:28 -0500 Subject: [PATCH 10/14] fix(module): make onlyEnv return nothing outside target environments Non-matching environments received the markup wrapped in a template element, so dev-only block comments (type handles and element ids) shipped in production HTML. --- modules/general/web/twig/GeneralExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/general/web/twig/GeneralExtension.php b/modules/general/web/twig/GeneralExtension.php index 2b92f34..558a647 100644 --- a/modules/general/web/twig/GeneralExtension.php +++ b/modules/general/web/twig/GeneralExtension.php @@ -406,7 +406,7 @@ public static function onlyEnv(?string $markup, string|array $environments): str return $markup; } - return sprintf('', $markup); + return ''; } public static function plain(mixed $string): string From 4ca1127563fec047c3dcf2dfe1ae758d74814786 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:22:36 -0500 Subject: [PATCH 11/14] fix(install): harden the installer script Add a bash shebang (the script uses bash-only syntax but ran under sh via the POSIX fallback, failing on Debian-family systems) and set -euo pipefail so a signed-out 1Password CLI aborts instead of silently writing an empty Tinify key. Quote substitutions and stop clobbering an existing .env on re-run. --- utility/install.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/utility/install.sh b/utility/install.sh index caf9807..46058f8 100755 --- a/utility/install.sh +++ b/utility/install.sh @@ -1,5 +1,9 @@ -function get_op() { - op item get 'ENVIRONMENT_DEFAULTS' --fields=label=$1 --reveal --account=mostlyserious.1password.com --vault=Employee +#!/usr/bin/env bash + +set -euo pipefail + +get_op() { + op item get 'ENVIRONMENT_DEFAULTS' --fields=label="$1" --reveal --account=mostlyserious.1password.com --vault=Employee } if ! command -v ddev &> /dev/null @@ -10,10 +14,10 @@ fi # @todo: check if we can collect Fort Awesome token up-front -cp .env.example .env -ddev dotenv set .env --primary-site-url="https://$(basename $PWD).ddev.site" -ddev dotenv set .env --imgix-url="https://$(basename $PWD).imgix.net" -ddev config --project-name=$(basename $PWD) +[ -f .env ] || cp .env.example .env +ddev dotenv set .env --primary-site-url="https://$(basename "$PWD").ddev.site" +ddev dotenv set .env --imgix-url="https://$(basename "$PWD").imgix.net" +ddev config --project-name="$(basename "$PWD")" ddev start ddev composer update ddev craft setup/keys @@ -22,7 +26,7 @@ ddev pnpm install --frozen-lockfile if command -v op &> /dev/null then - ddev dotenv set .env --tinypng-key=$(get_op TINYPNG_KEY) + ddev dotenv set .env --tinypng-key="$(get_op TINYPNG_KEY)" ddev pnpm run build else echo "1Password CLI not found." From 69b2fe3d5962d1e1141eb39c0956ac097035f490 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:22:36 -0500 Subject: [PATCH 12/14] chore: ignore .pnpm-store pnpm falls back to a project-local store when running inside the ddev container, since it cannot hard-link across the project mount boundary. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 055b45a..cfde346 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /.cache /vendor /node_modules +/.pnpm-store /web/static /web/cpresources /web/assets From 77b8028afb8d60a4769d4faeeeb0bd4eb44277c1 Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:41:12 -0500 Subject: [PATCH 13/14] fix(init): tear down module bindings on scope re-initialization init discarded the cleanup functions modules return, so re-running init over the same scope (e.g. after a Sprig/htmx swap) double-bound everything: a second lightbox dialog and keydown listener, self- canceling play/pause clicks, stacked parallax scroll handlers. Track cleanups per scope and selector in a WeakMap, run them before re-binding, and tear down bindings whose elements are gone. Cleanups only run on re-initialization of the same scope; fragment scopes that are discarded without a further init call still leak their bindings until an explicit destroy() counterpart exists. --- src/lib/init.ts | 30 ++++++++++++++++++++++++++---- tests/init.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 tests/init.test.ts diff --git a/src/lib/init.ts b/src/lib/init.ts index 0172faa..a351a7a 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -12,15 +12,37 @@ const modules = { 'x-svelte': () => import('$lib/sveltify'), } satisfies Record Promise<{ default: ModuleHandler }>> +const cleanups = new WeakMap void>>() + export default function init(scope: Document | Element): void { + let scopeCleanups = cleanups.get(scope) + + if (!scopeCleanups) { + scopeCleanups = new Map() + cleanups.set(scope, scopeCleanups) + } + for (const [selector, request] of object.entries(modules)) { const els = scope.querySelectorAll(selector) - if (els.length) { - request() - .then(({ default: module }) => module(els)) - .catch(error => console.error(error)) + if (!els.length) { + scopeCleanups.get(selector)?.() + scopeCleanups.delete(selector) + continue } + + request() + .then(({ default: module }) => { + scopeCleanups.get(selector)?.() + scopeCleanups.delete(selector) + + const cleanup = module(els) + + if (typeof cleanup === 'function') { + scopeCleanups.set(selector, cleanup) + } + }) + .catch(error => console.error(error)) } for (const el of scope.querySelectorAll('[target=_blank]')) { diff --git a/tests/init.test.ts b/tests/init.test.ts new file mode 100644 index 0000000..2428c33 --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import init from '$lib/init' + +async function flush() { + await vi.dynamicImportSettled() + await new Promise(resolve => setTimeout(resolve, 0)) +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('init', () => { + test('re-initializing a scope replaces module bindings instead of stacking them', async () => { + document.body.innerHTML = '' + + init(document) + await flush() + + init(document) + await flush() + + expect(document.querySelectorAll('dialog')).toHaveLength(1) + }) + + test('tears down a module when its elements are gone on re-initialization', async () => { + document.body.innerHTML = '' + + init(document) + await flush() + + document.body.innerHTML = '' + init(document) + await flush() + + expect(document.querySelector('dialog')).toBeNull() + }) +}) From 2058b7babc4ffde0301c962b5ed1af9c5b6e099b Mon Sep 17 00:00:00 2001 From: Cornelius Ukena Date: Sat, 8 Aug 2026 17:49:43 -0500 Subject: [PATCH 14/14] fix(init): prevent a superseded pass from binding stale elements A re-initialization that found no matching elements ran its teardown synchronously, so an earlier pass whose dynamic import was still in flight would land afterward and bind its captured, now-detached elements anyway, installing global artifacts with no owner. Track a per-scope, per-selector pass counter and have the async continuation bail when it has been superseded, so only the latest pass ever binds. --- src/lib/init.ts | 34 ++++++++++++++++++++++------------ tests/init.test.ts | 12 ++++++++++++ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/lib/init.ts b/src/lib/init.ts index a351a7a..e00ef26 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -12,35 +12,45 @@ const modules = { 'x-svelte': () => import('$lib/sveltify'), } satisfies Record Promise<{ default: ModuleHandler }>> -const cleanups = new WeakMap void>>() +interface Binding { + pass: number + cleanup: (() => void) | null +} + +const bindings = new WeakMap>() export default function init(scope: Document | Element): void { - let scopeCleanups = cleanups.get(scope) + let scopeBindings = bindings.get(scope) - if (!scopeCleanups) { - scopeCleanups = new Map() - cleanups.set(scope, scopeCleanups) + if (!scopeBindings) { + scopeBindings = new Map() + bindings.set(scope, scopeBindings) } for (const [selector, request] of object.entries(modules)) { const els = scope.querySelectorAll(selector) + const binding = scopeBindings.get(selector) ?? { pass: 0, cleanup: null } + const pass = ++binding.pass + + scopeBindings.set(selector, binding) if (!els.length) { - scopeCleanups.get(selector)?.() - scopeCleanups.delete(selector) + binding.cleanup?.() + binding.cleanup = null continue } request() .then(({ default: module }) => { - scopeCleanups.get(selector)?.() - scopeCleanups.delete(selector) + if (binding.pass !== pass) { + return + } + + binding.cleanup?.() const cleanup = module(els) - if (typeof cleanup === 'function') { - scopeCleanups.set(selector, cleanup) - } + binding.cleanup = typeof cleanup === 'function' ? cleanup : null }) .catch(error => console.error(error)) } diff --git a/tests/init.test.ts b/tests/init.test.ts index 2428c33..7e277d4 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -23,6 +23,18 @@ describe('init', () => { expect(document.querySelectorAll('dialog')).toHaveLength(1) }) + test('a re-initialization with no matching elements supersedes a pending earlier pass', async () => { + document.body.innerHTML = '' + + init(document) + + document.body.innerHTML = '' + init(document) + await flush() + + expect(document.querySelector('dialog')).toBeNull() + }) + test('tears down a module when its elements are gone on re-initialization', async () => { document.body.innerHTML = ''