fix: visuals reference matching overhaul and better workflow

This commit is contained in:
fsylvain committed 2026-09-26 21:04:51 +02:00
1 parent 930b836241
commit d493a181e1
25 files changed
+461 -241

No files matched your search

+30 -17
View File
@@ -33,29 +33,38 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-sm'))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center',
belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
'flex min-h-screen flex-col items-center gap-5',
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
])
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardUi = computed(() => ({
root: belowSm.value ? 'ring-0' : '',
container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
header: 'mb-6',
title: 'text-xl font-semibold leading-7',
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
container: belowSm.value
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
header: props.title ? 'mb-6' : 'mb-0',
title: 'text-xl/7 font-semibold',
description: 'mt-1.5 text-base text-muted',
}))
const footerClass = computed(() => [
'mt-4 flex w-full items-center justify-between',
belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
'flex w-full flex-wrap items-center justify-between gap-3 text-sm text-muted',
belowSm.value ? 'px-5 pb-6' : 'p-0',
cardClass.value,
])
const colorModeSelectUi = { trailing: 'hidden' }
const colorModeSelectUi = {
base: 'h-7 ps-7 pe-2 text-sm text-toned',
leading: 'ps-2',
leadingIcon: 'size-3.5 text-toned',
trailing: 'hidden',
}
watch(
() => props.title,
@@ -96,16 +105,20 @@ watch(
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
</template>
<div v-if="slots.notice" class="mb-4">
<slot name="notice" />
</div>
<slot v-if="slots.notice" name="notice" />
<slot :control-size="controlSize" :control-class="controlClass" />
<slot
:control-size="controlSize"
:control-class="controlClass"
:input-class="inputClass"
/>
</UPageCard>
<footer :class="footerClass">
<slot name="footer" />
<UColorModeSelect size="sm" class="ms-auto" :ui="colorModeSelectUi" />
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
<span class="flex flex-wrap items-center gap-1.5">
<slot name="footer" />
</span>
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
</footer>
</main>
</template>
+8 -3
View File
@@ -76,7 +76,12 @@ function onCancel() {
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:ui="{
content:
'max-sm:!inset-x-0 max-sm:!bottom-0 max-sm:!top-auto max-sm:!left-0 max-sm:!right-0 max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:!w-full max-sm:!max-w-none',
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 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',
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
title: 'text-lg max-sm:text-[1.0625rem]/6',
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
footer: 'p-6 sm:px-6 max-sm:p-5',
}"
>
<template v-if="error" #body>
@@ -96,7 +101,7 @@ function onCancel() {
color="neutral"
variant="outline"
block
class="sm:w-auto"
class="h-11 justify-center text-[0.9375rem] text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
:disabled="pending"
@click="onCancel"
>
@@ -107,7 +112,7 @@ function onCancel() {
:loading="pending"
:disabled="pending"
block
class="sm:w-auto"
class="h-11 justify-center text-[0.9375rem] sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
@click="onConfirm"
>
{{ confirmText }}
+16 -12
View File
@@ -11,16 +11,23 @@ const props = defineProps<{
showErrors: boolean
}>()
function itemClass(rule: PasswordRule) {
function iconClass(rule: PasswordRule) {
if (rule.met) {
return 'text-success'
}
return props.showErrors ? 'text-error' : 'text-toned'
return props.showErrors ? 'text-error' : 'text-muted'
}
function textClass(rule: PasswordRule) {
if (rule.met) {
return 'text-default'
}
return props.showErrors ? 'text-error' : 'text-muted'
}
function itemIcon(rule: PasswordRule) {
if (rule.met) {
return 'i-lucide-circle-check'
return 'i-lucide-check'
}
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
}
@@ -28,21 +35,18 @@ function itemIcon(rule: PasswordRule) {
const items = computed(() =>
props.rules.map((rule) => ({
...rule,
classes: itemClass(rule),
iconClass: iconClass(rule),
textClass: textClass(rule),
icon: itemIcon(rule),
})),
)
</script>
<template>
<ul class="flex flex-col gap-1">
<li
v-for="item in items"
:key="item.label"
:class="['flex items-center gap-1.5 text-sm', item.classes]"
>
<UIcon :name="item.icon" class="shrink-0" />
{{ item.label }}
<ul class="flex flex-col gap-1.5">
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
</li>
</ul>
+17 -10
View File
@@ -21,9 +21,9 @@ const props = defineProps<{
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
success: 'bg-success/10 text-success',
error: 'bg-error/10 text-error',
info: 'bg-info/10 text-info',
success: 'bg-(--rc-success-soft) text-success',
error: 'bg-(--rc-error-soft) text-error',
info: 'bg-(--rc-info-soft) text-info',
}
const tileClass = computed(() => toneTileClass[props.tone])
@@ -38,24 +38,31 @@ onMounted(async () => {
<template>
<div :role="role" class="flex flex-col gap-4">
<div :class="['flex h-11 w-11 items-center justify-center', tileClass]">
<UIcon :name="icon" class="h-6 w-6" />
<div :class="['flex size-11 items-center justify-center', tileClass]">
<UIcon :name="icon" class="size-5.5" />
</div>
<div class="flex flex-col gap-1.5">
<h1 ref="titleEl" tabindex="-1" class="text-xl font-semibold leading-7 outline-none">
<h1
ref="titleEl"
tabindex="-1"
class="text-xl/7 font-semibold text-highlighted outline-none"
>
{{ title }}
</h1>
<p class="text-toned text-sm">{{ description }}</p>
<p class="text-base text-pretty text-toned">{{ description }}</p>
<slot />
</div>
<slot />
<div class="flex w-full flex-col gap-2">
<div v-if="actions.length" class="flex w-full flex-col gap-2">
<UButton
v-for="action in actions"
:key="action.label"
block
size="lg"
:color="action.color ?? 'primary'"
:variant="action.variant ?? 'solid'"
:class="[
'h-11 justify-center text-base sm:h-10',
action.variant === 'outline' ? 'text-highlighted' : '',
]"
:to="action.to"
:disabled="action.disabled"
@click="action.onClick"
@@ -116,7 +116,7 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
})
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
it('uses the muted background token, keeps the card ring, capped width and 12vh top offset', () => {
it('uses the muted background token, keeps the card border, capped width and 12vh top offset', () => {
stubMatchMedia(false)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
@@ -130,7 +130,7 @@ describe('The page sits on the muted background from sm up, and the card keeps i
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).toContain('max-w-[400px]')
expect(card.classes().some((c) => c === 'ring-0')).toBe(false)
expect(card.classes()).toContain('border')
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).not.toContain('p-5')
@@ -55,57 +55,6 @@ afterEach(async () => {
resetAuthShellNavigation()
})
describe('Card padding and control heights at sm and up', () => {
it('pads the card 40px on every side and sizes controls at 40px, 14px text', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('p-10')
expect(container.classes()).not.toContain('pt-2')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const submitButton = wrapper.find('button[type="submit"]')
for (const el of [usernameInput, passwordInput, submitButton]) {
expect(el.classes()).toContain('!h-10')
expect(el.classes()).toContain('!text-sm')
}
const title = wrapper.find('[data-slot="title"]')
expect(title.classes()).toContain('text-xl')
expect(title.classes()).toContain('font-semibold')
expect(title.classes()).toContain('leading-7')
})
})
describe('Card padding and control heights below sm', () => {
it('splits card/footer padding and sizes controls at 44px, 16px text', async () => {
stubMatchMedia(true)
const wrapper = await mountLogin()
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('pt-2')
expect(container.classes()).toContain('px-5')
expect(container.classes()).toContain('pb-0')
const footer = wrapper.find('footer')
expect(footer.classes()).toContain('pt-0')
expect(footer.classes()).toContain('px-5')
expect(footer.classes()).toContain('pb-6')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const submitButton = wrapper.find('button[type="submit"]')
for (const el of [usernameInput, passwordInput, submitButton]) {
expect(el.classes()).toContain('!h-11')
expect(el.classes()).toContain('!text-base')
}
})
})
describe('"Forgot password?" is underlined and muted', () => {
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
const wrapper = await mountLogin()
@@ -151,6 +100,7 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
describe('The theme select is right-aligned in the footer with no chevron', () => {
it('renders as the last child, showing only the mode icon and label', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
const footer = wrapper.find('footer')
@@ -201,7 +151,9 @@ describe('The alert renders inside the card, under the description', () => {
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true)
expect(alert.classes().some((c) => /^bg-(info|success)\/10$/.test(c))).toBe(true)
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
true,
)
},
)
})