feat(media): align visual checks and variant UI with merged design

This commit is contained in:
Florian Sylvain committed 2026-10-13 02:35:20 +02:00
1 parent e6746f2be7
commit 160c0a49ce
23 files changed
+606 -99

No files matched your search

+11 -5
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, useSlots, watch } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
const props = withDefaults(
@@ -23,6 +24,8 @@ const props = withDefaults(
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const slots = useSlots()
const open = defineModel<boolean>('open', { default: true })
const pending = ref(false)
const error = ref('')
@@ -39,6 +42,10 @@ const previewThumb = computed(() => ({
content_type: '',
sha256: '',
}))
const hasBody = computed(() => Boolean(error.value || props.preview || slots.body))
const bodyClass = computed(() =>
hasBody.value ? 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0' : 'p-0 sm:p-0',
)
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
@@ -108,13 +115,12 @@ function onCancel() {
'aria-busy': pending ? 'true' : undefined,
}"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
body: bodyClass,
footer: SHEET_FOOTER_CLASS,
}"
>
<template #body>
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
@@ -197,7 +198,7 @@ const modalDescription = computed(() => {
const modalContentClass = computed(
() =>
`divide-y-0 shadow-(--shadow-modal) ring-0 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 ${
`${SHEET_CONTENT_CLASS} ${
showTabs.value ? 'sm:top-10' : 'sm:top-35'
} ${wideModal.value ? 'sm:max-w-160' : 'sm:max-w-120'}`,
)
@@ -765,7 +766,7 @@ onUnmounted(() => {
close: 'top-5 end-6',
title: 'text-lg/6 font-semibold',
body: 'p-0 sm:p-0',
footer: 'px-6 py-5 sm:px-6',
footer: SHEET_FOOTER_CLASS,
}"
>
<template #body>
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import MediaLinksCard from './MediaLinksCard.vue'
describe('MediaLinksCard', () => {
it('links to the variants and cache pages', () => {
const wrapper = mount(MediaLinksCard, { global: { plugins: [router, uiPlugin] } })
const hrefs = wrapper.findAll('a').map((link) => link.attributes('href'))
expect(hrefs).toEqual(['/settings/media/variants', '/settings/media/cache'])
expect(wrapper.text()).toContain('Manage image variants')
expect(wrapper.text()).toContain('Generated images and disk space')
})
})
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const links = computed(() => [
{
to: '/settings/media/variants',
icon: 'i-lucide-layers',
label: t('Manage image variants'),
hint: t('Named sizes requested with ?v='),
},
{
to: '/settings/media/cache',
icon: 'i-lucide-database',
label: t('Cache usage'),
hint: t('Generated images and disk space'),
},
])
</script>
<template>
<section
aria-labelledby="media-links-h"
data-testid="media-links-card"
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
>
<div class="flex flex-col gap-0.5">
<h2 id="media-links-h" class="text-[0.9375rem]/6 font-semibold text-highlighted">
{{ t('Media') }}
</h2>
<p class="text-pretty text-muted">
{{ t('Image sizes, cache and cleanup for your media library.') }}
</p>
</div>
<div class="flex flex-col">
<RouterLink
v-for="link in links"
:key="link.to"
:to="link.to"
class="flex min-h-11 items-center gap-3 border-t border-default py-2 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
>
<UIcon :name="link.icon" class="size-4 shrink-0" />
<span class="flex min-w-0 flex-1 flex-col">
<span class="font-medium">{{ link.label }}</span>
<span class="text-muted">{{ link.hint }}</span>
</span>
<UIcon name="i-lucide-chevron-right" class="size-3.5 shrink-0" />
</RouterLink>
</div>
</section>
</template>
+6
View File
@@ -0,0 +1,6 @@
export const SHEET_MOBILE_CLASS =
'max-sm:!inset-x-0 max-sm:!top-auto max-sm:!bottom-0 max-sm:!left-0 max-sm:!w-full max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:pt-3 max-sm:before:absolute max-sm:before:top-2 max-sm:before:left-1/2 max-sm:before:h-1 max-sm:before:w-9 max-sm:before:-translate-x-1/2 max-sm:before:bg-(--ui-border-accented)'
export const SHEET_CONTENT_CLASS = `divide-y-0 shadow-(--shadow-modal) ring-0 sm:translate-y-0 ${SHEET_MOBILE_CLASS}`
export const SHEET_FOOTER_CLASS = 'px-6 py-5 sm:px-6'
@@ -18,6 +18,7 @@ defineProps<{ files: number }>()
root: 'items-center rounded-none px-3.5 py-3',
icon: 'size-4 animate-spin',
title: 'font-semibold',
description: 'mt-2',
}"
>
<template #description>
+10 -1
View File
@@ -9,7 +9,8 @@ const VARIANT_ROWS = 5
const IMAGE_ROWS = 6
const BAR = 'rounded-none bg-accented'
const CARD_UI = {
root: 'min-w-0 rounded-none border border-default bg-default ring-0',
root: 'min-w-0 rounded-none border border-default bg-default ring-0 divide-y-0',
header: 'flex min-h-11 items-center border-b border-default px-4 py-0 sm:px-4 sm:py-0',
body: 'flex flex-col gap-5 p-4 sm:p-4',
}
</script>
@@ -24,12 +25,20 @@ const CARD_UI = {
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[460px_minmax(0,1fr)]">
<UCard :ui="CARD_UI">
<template #header>
<h2 class="font-semibold text-highlighted">{{ t('By variant') }}</h2>
</template>
<div v-for="row in VARIANT_ROWS" :key="row" class="flex flex-col gap-2">
<USkeleton class="h-3.25 w-[50%]" :class="BAR" />
<USkeleton class="h-2 w-full" :class="BAR" />
</div>
</UCard>
<UCard v-if="desktop" :ui="CARD_UI">
<template #header>
<h2 class="font-semibold text-highlighted">
{{ t('Top 10 images by variant disk usage') }}
</h2>
</template>
<USkeleton
v-for="row in IMAGE_ROWS"
:key="row"
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
@@ -55,13 +56,12 @@ function onConfirm() {
:close="false"
:content="{ role: 'alertdialog' }"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
footer: SHEET_FOOTER_CLASS,
}"
@update:open="open = $event"
>
@@ -41,6 +41,7 @@ const overlay = useLocalizedOverlay()
const { announceFailure } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const form = useVariantForm(props.mode, props.initial)
const side = computed(() => (isDesktop.value ? 'right' : 'bottom'))
const saving = ref(false)
const saveFailed = ref(false)
@@ -63,6 +64,29 @@ const discardDescription = computed(() =>
: t("You have unsaved changes. If you close now, they're lost."),
)
const closeButton = computed(() => ({
icon: 'i-lucide-x',
color: 'neutral' as const,
variant: 'ghost' as const,
'aria-label': t('Close'),
class: isDesktop.value
? 'size-7 justify-center text-muted'
: 'top-4.5 end-1.5 size-11 justify-center p-0 text-muted',
ui: { leadingIcon: isDesktop.value ? 'size-4' : 'size-5' },
}))
const overlayUi = computed(() => ({
content: isDesktop.value
? 'w-full max-w-none sm:max-w-240'
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'h-14 min-h-14 gap-3 px-5 py-0 max-sm:gap-1 max-sm:px-1.5',
wrapper: 'min-w-0 flex-1',
title: 'truncate text-lg font-semibold',
description: 'sr-only',
body: 'min-h-0 p-0 sm:overflow-hidden sm:p-0',
footer: 'h-18 gap-2 px-4 py-0 sm:h-16 sm:gap-3 sm:px-5',
}))
function focusField(field: FieldName) {
const target = document.querySelector<HTMLElement>(
`[data-field="${field}"] input:not([type="hidden"])`,
@@ -208,50 +232,19 @@ async function submit() {
<template>
<USlideover
:open="true"
side="right"
:side="side"
:title="title"
:description="t('Define a named size and format for generated images.')"
:close="
isDesktop
? {
icon: 'i-lucide-x',
color: 'neutral',
variant: 'ghost',
'aria-label': t('Close'),
class: 'size-7 justify-center text-muted',
}
: false
"
:close="closeButton"
:content="{ onOpenAutoFocus: focusOnOpen }"
:ui="{
content: 'w-full max-w-none sm:max-w-240',
header: 'h-14 min-h-14 gap-3 px-5 py-0 max-sm:gap-1 max-sm:px-1.5',
wrapper: 'min-w-0 flex-1',
title: 'truncate text-lg font-semibold',
description: 'sr-only',
body: 'min-h-0 p-0 sm:overflow-hidden sm:p-0',
footer: 'h-18 gap-2 px-4 py-0 sm:h-16 sm:gap-3 sm:px-5',
}"
:ui="overlayUi"
@update:open="onOpenChange"
>
<template #actions>
<UButton
v-if="!isDesktop"
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
:aria-label="t('Back')"
class="order-first size-11 shrink-0 justify-center"
:ui="{ leadingIcon: 'size-5' }"
@click="requestClose"
/>
</template>
<template #body>
<UForm
:state="form.values"
:validate-on="[]"
class="flex flex-col gap-6 p-4 max-sm:pb-25 sm:grid sm:h-full sm:grid-cols-[440px_minmax(0,1fr)] sm:gap-0 sm:p-0"
class="flex flex-col gap-6 p-4 sm:grid sm:h-full sm:grid-cols-[440px_minmax(0,1fr)] sm:gap-0 sm:p-0"
@submit="submit"
>
<div class="min-w-0 sm:overflow-y-auto sm:p-5">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed } from 'vue'
@@ -41,14 +42,13 @@ const originalTitle = computed(
:title="t('Preview: {0}', [variant.name])"
:description="t('Both images are shown at actual size. Scroll to pan.')"
:ui="{
content:
'sm:top-20 sm:translate-y-0 sm:max-w-220 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 divide-y-0 shadow-(--shadow-modal) ring-0',
content: `${SHEET_CONTENT_CLASS} sm:top-20 sm:max-w-220`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
close: 'top-5 end-6',
close: 'top-5 end-6 max-sm:top-8 max-sm:size-11 max-sm:justify-center',
title: 'text-lg/6 font-semibold',
description: 'sr-only',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
footer: SHEET_FOOTER_CLASS,
}"
>
<template #body>
+6
View File
@@ -985,6 +985,12 @@ export const messages = {
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
Meaning: 'Signification',
'Media cleanup': 'Nettoyage des médias',
'Image sizes, cache and cleanup for your media library.':
'Tailles d’image, cache et nettoyage de votre médiathèque.',
'Manage image variants': 'Gérer les variantes d’image',
'Named sizes requested with ?v=': 'Tailles nommées demandées avec ?v=',
'Cache usage': 'Utilisation du cache',
'Generated images and disk space': 'Images générées et espace disque',
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.':
'Fusionnez les images identiques pour garder votre médiathèque ordonnée. Les articles qui utilisent une copie passent à la plus utilisée.',
'Automatic purge': 'Purge automatique',
+1 -1
View File
@@ -300,7 +300,7 @@ function onPurgeImage(item: CacheImageStat) {
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<h1 class="text-xl/8 font-semibold text-highlighted sm:text-stat">
<h1 class="text-xl/7 font-semibold text-highlighted">
{{ t('Cache') }}
</h1>
<p class="max-w-155 text-pretty text-muted">
+9 -3
View File
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import MediaLinksCard from '@/components/MediaLinksCard.vue'
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
import RestartOverlay from '@/components/RestartOverlay.vue'
import RetentionCard from '@/components/RetentionCard.vue'
@@ -378,6 +379,7 @@ onBeforeUnmount(() => {
<template>
<DashboardShell
compact-padding
bleed-offline-banner
:offline-message="t(OFFLINE_COPY)"
offline-icon="i-lucide-wifi-off"
@@ -391,9 +393,9 @@ onBeforeUnmount(() => {
:now="update.restartNow"
@reload="reloadPage"
/>
<div class="flex max-w-164 flex-col gap-5" :inert="update.restartRun !== null">
<div class="flex max-w-164 flex-col gap-4" :inert="update.restartRun !== null">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">
<h1 class="text-xl/7 font-semibold text-highlighted">
{{ t('Settings') }}
</h1>
<p class="text-muted">{{ t('Workspace information.') }}</p>
@@ -430,7 +432,9 @@ onBeforeUnmount(() => {
</UAlert>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-[0.9375rem]/6 font-semibold text-highlighted">{{ t('Version') }}</span>
<span class="text-[0.9375rem]/6 font-semibold text-highlighted">{{
t('Version')
}}</span>
<USkeleton
v-if="loadState === 'loading' && showSkeleton"
data-testid="settings-skeleton"
@@ -459,6 +463,8 @@ onBeforeUnmount(() => {
@restart="onRestart"
/>
<MediaLinksCard />
<MediaCleanupCard />
<RetentionCard
+1 -1
View File
@@ -325,7 +325,7 @@ async function onDelete(name: string) {
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<h1 class="text-xl/8 font-semibold text-highlighted sm:text-stat">
<h1 class="text-xl/7 font-semibold text-highlighted">
{{ t('Image variants') }}
</h1>
<p class="max-w-155 text-pretty text-muted">
@@ -760,7 +760,7 @@ describe('Copy URL, Purge and Preview', () => {
const preview = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
).find((entry) => textOf(entry).includes('Preview: thumb'))!
expect(preview.className).toContain('max-sm:!bottom-4')
expect(preview.className).toContain('max-sm:!bottom-0')
expect(findButton('Close', preview)!.className).toContain('h-11')
})
})
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import {
CREATE,
alertDialog,
buttonByLabel,
buttonIn,
click,
clickBackdrop,
deferred,
field,
mountPage,
openAddForm,
openEditForm,
pressEscape,
rows,
slideover,
stubBrowser,
textOf,
typeInto,
unmountPage,
variant,
} from './variantsTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
function sheet() {
return slideover()!
}
function addButton() {
return document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
}
beforeEach(() => {
toasts.add.mockClear()
localStorage.clear()
initializeLocale()
stubBrowser()
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
vi.unstubAllGlobals()
await unmountPage()
localStorage.clear()
initializeLocale()
})
describe('Rule: The variant form follows the overlay pattern', () => {
it('Add variant is a right slide-over on desktop', async () => {
await mountPage()
await openAddForm()
const panel = sheet()
expect(panel.getAttribute('data-side')).toBe('right')
expect(panel.className).toContain('sm:max-w-240')
expect(panel.className).not.toContain('h-23/25')
expect(buttonByLabel('Close')).not.toBeNull()
expect(buttonByLabel('Back')).toBeNull()
expect(panel.querySelector('[data-slot="body"] form')!.className).toContain(
'sm:grid-cols-[440px_minmax(0,1fr)]',
)
expect(rows()).toHaveLength(6)
})
it('Add variant is a tall bottom sheet on mobile', async () => {
await mountPage({ desktop: false })
await openAddForm()
const panel = sheet()
expect(panel.getAttribute('data-side')).toBe('bottom')
expect(panel.className).toContain('h-23/25')
expect(panel.className).toContain('before:w-9')
expect(buttonByLabel('Close')!.className).toContain('size-11')
expect(buttonByLabel('Back')).toBeNull()
expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('h-18')
expect(rows()).toHaveLength(6)
})
it.each([
['desktop', true],
['mobile', false],
])('Edit uses the same container on %s', async (_viewport, desktop) => {
await mountPage({ desktop })
await openAddForm()
const addSide = sheet().getAttribute('data-side')
await click(buttonByLabel('Close'))
await openEditForm('hero')
expect(sheet().getAttribute('data-side')).toBe(addSide)
expect(sheet().querySelector('[data-slot="title"]')?.textContent?.trim()).toBe(
'Edit variant: hero',
)
})
it('Focus moves to the Name field in create mode', async () => {
await mountPage({ desktop: false })
await openAddForm()
expect(document.activeElement).toBe(field('name'))
})
})
describe('Rule: Dismissing the mobile sheet', () => {
const gestures: [string, () => Promise<void>][] = [
['the close button', () => click(buttonByLabel('Close'))],
['Cancel', () => click(buttonIn(sheet(), 'Cancel'))],
['Escape', () => pressEscape()],
['the scrim', () => clickBackdrop()],
]
it.each(gestures)(
'A pristine form closes with %s and returns focus',
async (_name, dismiss) => {
await mountPage({ desktop: false })
await openAddForm()
await dismiss()
expect(slideover()).toBeNull()
expect(alertDialog()).toBeNull()
expect(document.activeElement).toBe(addButton())
},
)
it.each(gestures)('A dirty form asks first with %s', async (_name, dismiss) => {
await mountPage({ desktop: false })
await openAddForm()
await typeInto(field('name'), 'banner')
await dismiss()
expect(textOf(alertDialog()!)).toContain('Discard changes?')
expect(slideover()).not.toBeNull()
})
it('Nothing closes while saving', async () => {
const pending = deferred<unknown>()
await mountPage({ desktop: false, routes: { [CREATE]: () => pending.promise } })
await openAddForm()
await typeInto(field('name'), 'banner')
await typeInto(field('width'), '400')
await click(buttonIn(sheet(), 'Add variant'))
await pressEscape()
await click(buttonByLabel('Close'))
await click(buttonIn(sheet(), 'Cancel'))
await clickBackdrop()
expect(slideover()).not.toBeNull()
expect(alertDialog()).toBeNull()
pending.resolve(variant({ name: 'banner' }))
await flushPromises()
})
})
+17 -7
View File
@@ -185,23 +185,33 @@ describe('Rule: Slide-over opening and closing', () => {
expect(rows()).toHaveLength(6)
})
it('Add variant opens a full page on mobile', async () => {
it('Add variant opens a bottom sheet on mobile', async () => {
await mountPage({ desktop: false })
await openAddForm()
const panel = form()
expect(buttonByLabel('Close')).toBeNull()
const back = buttonByLabel('Back')!
expect(iconNames(back)).toContain('i-lucide-arrow-left')
expect(panel.getAttribute('data-side')).toBe('bottom')
expect(panel.className).toContain('h-23/25')
expect(panel.className).toContain('before:w-9')
expect(panel.className).toContain('before:h-1')
expect(panel.className).toContain('before:bg-(--ui-border-accented)')
expect(buttonByLabel('Back')).toBeNull()
const close = buttonByLabel('Close')!
expect(iconNames(close)).toContain('i-lucide-x')
expect(close.className).toContain('size-11')
expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe('Add variant')
expect(panel.className).toContain('w-full')
expect(panel.className).toContain('inset-x-0')
expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('h-18')
expect(buttonIn(panel, 'Cancel')!.className).toContain('h-11')
expect(buttonIn(panel, 'Cancel')!.className).toContain('flex-1')
expect(saveButton().className).toContain('h-11')
expect(saveButton().className).toContain('flex-1')
expect(panel.textContent).toContain('Preview')
const sections = panel.querySelectorAll('[data-slot="body"] form > div')
expect(sections[1]!.className).toContain('border-t')
expect(rows()).toHaveLength(6)
expect(document.body.querySelector('[data-slot="overlay"]')).not.toBeNull()
})
it('Edit opens with the stored values', async () => {
@@ -287,12 +297,12 @@ describe('Rule: Slide-over opening and closing', () => {
expect(dialog.querySelectorAll('button')[1]!.className).toContain('bg-error')
})
it('Dismissing a dirty form on mobile with the back arrow asks first', async () => {
it('Dismissing a dirty form on mobile with the close button asks first', async () => {
await mountPage({ desktop: false })
await openAddForm()
await typeInto(field('name'), 'banner')
await click(buttonByLabel('Back'))
await click(buttonByLabel('Close'))
expect(textOf(alertDialog()!)).toContain('Discard changes?')
})
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { visualStates } from '../../../visual/states'
const GROUPS: Record<string, string[]> = {
'Variants list': [
'Loaded',
'Loading',
'Empty',
'Error loading',
'Forbidden',
'Row menu',
'Row action in progress',
'Purge confirm',
'Delete confirm',
'Delete in progress',
'Request reference',
'Entry: sidebar',
],
'Add edit variant': [
'Create: pristine',
'Create: dirty',
'Field errors',
'No dimension',
'Name already taken',
'Saving',
'Save error',
'Edit: pristine',
'Edit: dirty',
'Unsaved changes',
'PNG: no quality',
],
'Media library': [
'V3a · Grid with badges',
'V3b · Sort menu',
'V3c · Filter: With variants, sorted by total size',
'V3d · Filter: Without variants',
'V3e · Filter: nothing to show',
'V3f · Delete confirm',
],
'Image detail': [
'V4a · Partly generated',
'V4b · Nothing generated yet',
'V4c · Generating',
'V4d · Generation failed',
'V4e · No variants defined',
'V4f · Source is an SVG',
'V4h · Preview dialog',
],
'Cache panel': [
'V5a · Loaded',
'V5b · Loading',
'V5c · Empty cache',
'V5d · Purging',
'V5e · Purge done',
'V5f · Purge failed',
'V5g · Error loading',
'V5h · Purge all: confirm',
'V5i · Purge all: confirm, typed',
'V5j · Purge unused: confirm',
],
'Editor picker': [
'V6a · Variant select',
'V6b · Select open',
'V6c · No variants defined',
'V6d · Variant deleted',
'V6e · Variant disabled',
],
}
describe('Visual setups of the image variants screens', () => {
it.each(Object.entries(GROUPS))('Every state of %s has a setup', (_group, states) => {
const setups = visualStates['image-variants']!
expect(states.filter((state) => !(state in setups))).toEqual([])
})
it('No setup carries a clock-sensitive skip without a reason to', () => {
const setups = Object.values(visualStates['image-variants']!)
expect(setups.filter((setup) => setup.skip === 'pending')).toEqual([])
})
})
+36 -31
View File
@@ -3722,8 +3722,8 @@ const cacheBoardStats = {
by_variant: [
{ name: 'hero', enabled: true, files: 244, bytes: 88080384 },
{ name: 'card', enabled: true, files: 318, bytes: 60817408 },
{ name: 'print', enabled: false, files: 74, bytes: 22020096 },
{ name: 'thumb', enabled: true, files: 412, bytes: 22020096 },
{ name: 'print', enabled: false, files: 74, bytes: 22020096 },
{ name: 'square', enabled: true, files: 140, bytes: 19922944 },
{ name: 'og', enabled: true, files: 96, bytes: 9437184 },
],
@@ -3777,23 +3777,23 @@ async function confirmPurgeAll(page: Page) {
}
const cacheStates: Record<string, VisualState> = {
'5a · Loaded': cacheState(),
'5b · Loading': cacheState({ status: 200, hang: true }),
'5c · Empty cache': cacheState({ status: 200, body: cacheEmptyStats }),
'5d · Purging': cacheState(
'V5a · Loaded': cacheState(),
'V5b · Loading': cacheState({ status: 200, hang: true }),
'V5c · Empty cache': cacheState({ status: 200, body: cacheEmptyStats }),
'V5d · Purging': cacheState(
{ status: 200, body: cacheBoardStats },
{ [cachePurgeKey]: { status: 200, hang: true } },
confirmPurgeAll,
),
'5e · Purge done': cacheState(
'V5e · Purge done': cacheState(
{
status: 200,
bodyFor: (_url, call) => (call === 0 ? cacheBoardStats : cacheEmptyStats),
bodyFor: (_url, call) => (call === 1 ? cacheBoardStats : cacheEmptyStats),
},
{ [cachePurgeKey]: { status: 200, body: cachePurgeAllResult } },
confirmPurgeAll,
),
'5f · Purge failed': cacheState(
'V5f · Purge failed': cacheState(
{ status: 200, body: cacheBoardStats },
{
[cachePurgeKey]: {
@@ -3808,20 +3808,20 @@ const cacheStates: Record<string, VisualState> = {
},
confirmPurgeAll,
),
'5g · Error loading': cacheState({ status: 500 }),
'5h · Purge all: confirm': cacheState(undefined, {}, async (page) => {
'V5g · Error loading': cacheState({ status: 500 }),
'V5h · Purge all: confirm': cacheState(undefined, {}, async (page) => {
await page.getByRole('button', { name: 'Purge all variants', exact: true }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.getByRole('alertdialog').getByRole('textbox').fill('pur')
await page.mouse.move(0, 0)
}),
'5i · Purge all: confirm, typed': cacheState(undefined, {}, async (page) => {
'V5i · Purge all: confirm, typed': cacheState(undefined, {}, async (page) => {
await page.getByRole('button', { name: 'Purge all variants', exact: true }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.getByRole('alertdialog').getByRole('textbox').fill('purge')
await page.mouse.move(0, 0)
}),
'5j · Purge unused: confirm': cacheState(undefined, {}, async (page) => {
'V5j · Purge unused: confirm': cacheState(undefined, {}, async (page) => {
await page.getByRole('button', { name: 'Purge unused', exact: true }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
@@ -4094,6 +4094,11 @@ const variantsMediaStates: Record<string, VisualState> = {
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
exception: {
maxPercent: { desktop: 27, mobile: 10 },
reason: 'board Media page behind the sidebar is a simplified mock (no tabs, Select all, view toggle, Duplicate badges)',
approvedBy: 'user',
},
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
@@ -4108,8 +4113,8 @@ const variantsMediaStates: Record<string, VisualState> = {
await page.waitForTimeout(250)
},
},
'3a · Grid with badges': variantsMediaState(),
'3b · Sort menu': variantsMediaState(
'V3a · Grid with badges': variantsMediaState(),
'V3b · Sort menu': variantsMediaState(
{},
async (page) => {
await openSortMenuOf(page)
@@ -4117,28 +4122,28 @@ const variantsMediaStates: Record<string, VisualState> = {
},
'/media?sort=total_size',
),
'3c · Filter: With variants, sorted by total size': variantsMediaState(
'V3c · Filter: With variants, sorted by total size': variantsMediaState(
{},
undefined,
'/media?variants=with&sort=total_size',
[1, 7, 9, 12, 5, 11, 2, 3, 10],
),
'3d · Filter: Without variants': variantsMediaState(
'V3d · Filter: Without variants': variantsMediaState(
{},
undefined,
'/media?variants=without',
[4, 6, 8],
),
'3e · Filter: nothing to show': variantsMediaState({}, undefined, '/media?variants=with', []),
'3f · Delete confirm': variantsMediaState({}, async (page) => {
'V3e · Filter: nothing to show': variantsMediaState({}, undefined, '/media?variants=with', []),
'V3f · Delete confirm': variantsMediaState({}, async (page) => {
await openImagePanel(page)
await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'4a · Partly generated': variantsTabState(variantsPartlyGenerated),
'4b · Nothing generated yet': variantsTabState([]),
'4c · Generating': variantsTabState(
'V4a · Partly generated': variantsTabState(variantsPartlyGenerated),
'V4b · Nothing generated yet': variantsTabState([]),
'V4c · Generating': variantsTabState(
['thumb'],
{
'POST /v1/media/1/variants/card': { status: 200, hang: true },
@@ -4150,7 +4155,7 @@ const variantsMediaStates: Record<string, VisualState> = {
await page.mouse.move(0, 0)
},
),
'4d · Generation failed': variantsTabState(
'V4d · Generation failed': variantsTabState(
['thumb'],
{ 'POST /v1/media/1/variants': variantsFailedGeneration },
async (page) => {
@@ -4159,7 +4164,7 @@ const variantsMediaStates: Record<string, VisualState> = {
await page.mouse.move(0, 0)
},
),
'4e · No variants defined': {
'V4e · No variants defined': {
path: variantsMediaPath,
api: variantsMediaApi(undefined, {
'GET /v1/media/1/variants': { status: 200, body: { unsupported: null, items: [] } },
@@ -4168,7 +4173,7 @@ const variantsMediaStates: Record<string, VisualState> = {
await openVariantsTabOf(page)
},
},
'4f · Source is an SVG': {
'V4f · Source is an SVG': {
path: variantsMediaPath,
api: variantsMediaApi(undefined, {
'GET /v1/media/4/variants': { status: 200, body: { unsupported: 'svg', items: [] } },
@@ -4178,7 +4183,7 @@ const variantsMediaStates: Record<string, VisualState> = {
await openVariantsTabOf(page, variantsSvgId)
},
},
'4h · Preview dialog': variantsTabState(variantsPartlyGenerated, {}, async (page) => {
'V4h · Preview dialog': variantsTabState(variantsPartlyGenerated, {}, async (page) => {
await variantRow(page, 'thumb').getByRole('button', { name: 'Preview' }).click()
await page.getByRole('dialog').filter({ hasText: 'Preview: thumb' }).waitFor()
await page.mouse.move(0, 0)
@@ -4186,26 +4191,26 @@ const variantsMediaStates: Record<string, VisualState> = {
}
const variantsEditorStates: Record<string, VisualState> = {
'6a · Variant select': variantsPickerState(variantsBoardList, async (page) => {
'V6a · Variant select': variantsPickerState(variantsBoardList, async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await chooseBoardVariant(page, 'Hero')
}),
'6b · Select open': variantsPickerState(variantsBoardList, async (page) => {
'V6b · Select open': variantsPickerState(variantsBoardList, async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await page.getByTestId('variant-select').click()
await page.getByRole('listbox').waitFor()
await page.mouse.move(0, 0)
}),
'6c · No variants defined': variantsPickerState([], async (page) => {
'V6c · No variants defined': variantsPickerState([], async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await page.getByTestId('variant-help').waitFor()
await page.mouse.move(0, 0)
}),
'6d · Variant deleted': variantsBlockState(variantsWithoutHero),
'6e · Variant disabled': variantsBlockState(variantsHeroDisabled),
'V6d · Variant deleted': variantsBlockState(variantsWithoutHero),
'V6e · Variant disabled': variantsBlockState(variantsHeroDisabled),
}
const boardPredatesPages = 'board predates current Media page and editor'
@@ -4232,7 +4237,7 @@ const variantExceptionCaps: Record<string, { desktop: number; mobile: number }>
function withVariantExceptions(states: Record<string, VisualState>) {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const cap = variantExceptionCaps[name.slice(0, 2)]
const cap = variantExceptionCaps[name.slice(1, 3)]
return [
name,
cap