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
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/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/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(' %s ', $markup);
+ return '';
}
public static function plain(mixed $string): string
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/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/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)} />
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()}

+ 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}
diff --git a/src/lib/init.ts b/src/lib/init.ts
index 0172faa..e00ef26 100644
--- a/src/lib/init.ts
+++ b/src/lib/init.ts
@@ -12,15 +12,47 @@ const modules = {
'x-svelte': () => import('$lib/sveltify'),
} satisfies Record
Promise<{ default: ModuleHandler }>>
+interface Binding {
+ pass: number
+ cleanup: (() => void) | null
+}
+
+const bindings = new WeakMap>()
+
export default function init(scope: Document | Element): void {
+ let scopeBindings = bindings.get(scope)
+
+ 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
- if (els.length) {
- request()
- .then(({ default: module }) => module(els))
- .catch(error => console.error(error))
+ scopeBindings.set(selector, binding)
+
+ if (!els.length) {
+ binding.cleanup?.()
+ binding.cleanup = null
+ continue
}
+
+ request()
+ .then(({ default: module }) => {
+ if (binding.pass !== pass) {
+ return
+ }
+
+ binding.cleanup?.()
+
+ const cleanup = module(els)
+
+ binding.cleanup = typeof cleanup === 'function' ? cleanup : null
+ })
+ .catch(error => console.error(error))
}
for (const el of scope.querySelectorAll('[target=_blank]')) {
diff --git a/src/lib/modules/lightbox.ts b/src/lib/modules/lightbox.ts
index ec77f29..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
@@ -248,7 +235,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))
}
@@ -259,7 +249,7 @@ export default ModuleSchema.implement(els => {
}
close()
- backdrop.remove()
+ dialog.remove()
current = null
}
})
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'),
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,
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')
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 @@