console : chaque survol rejoue la montée, aucun curseur figé à mi-course ; pied de page sans lien mort

Les quatre écarts de la relecture « 64e714c : ok », graduation d'Yvv inchangée
(0,75, 0,50, 0,667, montée rejouée à chaque survol) :
- premier geste pendant la rotation : un curseur non touché finit en douceur le
  mouvement que la rotation avait commencé (jusqu'à son cran, ou en bas), puis reste.
  La main ne fait descendre aucun curseur (« ne fait pas descendre les autres ») ;
- un nouveau survol, même à moins de 500 ms, rejoue la montée ; seuls la flèche et
  le focus qui la suit, ou le focus et le clic d'un même tap, font une seule montée ;
- un clic sans survol ni focus (lecteur d'écran, script) monte le curseur avec son
  panneau ;
- la flèche part de l'onglet qui a le focus, plus de l'axe sélectionné.

Pied de page : un lien vers une page qui n'existe pas (/mentions-legales) n'est pas
affiché ; il reviendra seul quand la page existera. site.yml n'est pas touché.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 12:34:15 +01:00
co-authored by Claude Opus 5.5
parent 44e9576b38
commit 8de9a81b65
2 changed files with 59 additions and 30 deletions
+49 -28
View File
@@ -30,7 +30,7 @@
type="button" type="button"
role="tab" role="tab"
class="fader" class="fader"
:class="{ 'fader--active': axis.key === active, 'fader--rising': rising.has(axis.key) }" :class="{ 'fader--active': axis.key === active, 'fader--held': held.has(axis.key), 'fader--rising': rising.has(axis.key) }"
:style="{ '--hue': `var(${axis.hue})`, '--level': levels[axis.key] ?? 0, '--i': i }" :style="{ '--hue': `var(${axis.hue})`, '--level': levels[axis.key] ?? 0, '--i': i }"
:data-axis="axis.key" :data-axis="axis.key"
:aria-selected="axis.key === active" :aria-selected="axis.key === active"
@@ -142,15 +142,20 @@ const current = computed(() => props.axes.find(a => a.key === active.value) ?? p
// The visitor's hand takes over for good (03/11): from the first hover, // The visitor's hand takes over for good (03/11): from the first hover,
// keyboard focus or tap on a cursor, the rotation stops. The touched cursor // keyboard focus or tap on a cursor, the rotation stops. The touched cursor
// replays its rise, from the bottom to its own graduation, and its panel // replays its rise, from the bottom to its own graduation, and its panel
// shows. The others do not move; a cursor never touched stays where it was. // shows. The others are never lowered by the hand: a cursor already touched
// stays at its graduation; a cursor never touched finishes, gently, the move
// the rotation had begun (up to its graduation, or down to its low level),
// then stays there. None stays frozen mid-course.
const BEAT_MS = 2600 const BEAT_MS = 2600
const BEATS_PER_AXIS = 3 const BEATS_PER_AXIS = 3
/** Graduation of each axis, from asservissement (0) to émancipation (1) */ /** Graduation of each axis, from asservissement (0) to émancipation (1) */
const GRADUATION: Record<AxisKey, number> = { numerique: 3 / 4, economie: 1 / 2, politique: 2 / 3 } const GRADUATION: Record<AxisKey, number> = { numerique: 3 / 4, economie: 1 / 2, politique: 2 / 3 }
/** A focus and a click within this delay are one gesture (a tap): one rise */ /** The focus and the click of one tap, within this delay, give one rise */
const SAME_GESTURE_MS = 500 const SAME_GESTURE_MS = 500
const levels = reactive<Partial<Record<AxisKey, number>>>({}) const levels = reactive<Partial<Record<AxisKey, number>>>({})
/** Cursors the hand has touched: they no longer glide with the rotation */
const held = reactive(new Set<AxisKey>())
/** Cursors replaying their rise (fast transition) */ /** Cursors replaying their rise (fast transition) */
const rising = reactive(new Set<AxisKey>()) const rising = reactive(new Set<AxisKey>())
const reduced = ref(false) const reduced = ref(false)
@@ -160,7 +165,10 @@ const pointerIn = ref(false)
const focusIn = ref(false) const focusIn = ref(false)
const hoverKey = ref<AxisKey | null>(null) const hoverKey = ref<AxisKey | null>(null)
let lastPointer = '' let lastPointer = ''
const lastRise: Partial<Record<AxisKey, number>> = {} /** The tab the arrow keys are moving the focus to: its focus is the same gesture */
let keyFocus: AxisKey | null = null
/** When a keyboard focus last raised each cursor (to pair it with the click of a tap) */
const focusRiseAt: Partial<Record<AxisKey, number>> = {}
const riseRun: Partial<Record<AxisKey, number>> = {} const riseRun: Partial<Record<AxisKey, number>> = {}
let beat = 0 let beat = 0
let timer: ReturnType<typeof setInterval> | undefined let timer: ReturnType<typeof setInterval> | undefined
@@ -180,24 +188,17 @@ function faderEl(key: AxisKey) {
return root.value?.querySelector<HTMLElement>(`[data-axis="${key}"]`) ?? null return root.value?.querySelector<HTMLElement>(`[data-axis="${key}"]`) ?? null
} }
/** Level currently drawn (mid-transition included), read from the DOM */ /** First gesture: the rotation stops for good. The untouched cursors keep the target the
function drawnLevel(key: AxisKey) { * rotation gave them and finish their glide to it (no freeze mid-course) */
const el = faderEl(key)
const v = el ? Number.parseFloat(getComputedStyle(el).getPropertyValue('--level')) : Number.NaN
return Number.isFinite(v) ? v : (levels[key] ?? 0)
}
/** First gesture: the rotation stops for good, every cursor stays where it is drawn */
function engage() { function engage() {
if (engaged.value) return if (engaged.value) return
for (const axis of props.axes) levels[axis.key] = drawnLevel(axis.key)
engaged.value = true engaged.value = true
clearInterval(timer) clearInterval(timer)
} }
/** The touched cursor replays its rise: back to the bottom, then up to its graduation */ /** The touched cursor replays its rise: back to the bottom, then up to its graduation */
async function rise(key: AxisKey) { async function rise(key: AxisKey) {
lastRise[key] = Date.now() held.add(key)
const target = GRADUATION[key] ?? 0.75 const target = GRADUATION[key] ?? 0.75
if (reduced.value) { if (reduced.value) {
levels[key] = target levels[key] = target
@@ -216,14 +217,20 @@ async function rise(key: AxisKey) {
levels[key] = target levels[key] = target
} }
/** A cursor under the visitor's hand: it rises and its panel shows */ /** A cursor under the visitor's hand: it rises and its panel shows. Every rollover replays
* the rise, however soon after the previous one (Yvv, 03/11) */
function touch(key: AxisKey) { function touch(key: AxisKey) {
engage() engage()
active.value = key active.value = key
if (Date.now() - (lastRise[key] ?? 0) < SAME_GESTURE_MS) return
rise(key) rise(key)
} }
/** The panel of a cursor already raised by this same gesture: no second rise */
function select(key: AxisKey) {
engage()
active.value = key
}
function onEnter(e: PointerEvent, key: AxisKey) { function onEnter(e: PointerEvent, key: AxisKey) {
// A finger scrolling over a cursor is not a rollover: taps go through the click // A finger scrolling over a cursor is not a rollover: taps go through the click
if (e.pointerType === 'touch') return if (e.pointerType === 'touch') return
@@ -241,19 +248,24 @@ function isKeyboardFocus(el: HTMLElement) {
} }
function onFocus(e: FocusEvent, key: AxisKey) { function onFocus(e: FocusEvent, key: AxisKey) {
// The arrow key that moved the focus here already raised the cursor
if (keyFocus === key) return
// Keyboard focus is a rollover; a focus from the mouse or a tap is handled elsewhere // Keyboard focus is a rollover; a focus from the mouse or a tap is handled elsewhere
if (hoverKey.value === key || !isKeyboardFocus(e.currentTarget as HTMLElement)) return if (hoverKey.value === key || !isKeyboardFocus(e.currentTarget as HTMLElement)) return
focusRiseAt[key] = Date.now()
touch(key) touch(key)
} }
function onClick(e: MouseEvent, key: AxisKey) { function onClick(e: MouseEvent, key: AxisKey) {
const pointer = (e as PointerEvent).pointerType || lastPointer const pointer = (e as PointerEvent).pointerType || lastPointer
lastPointer = '' lastPointer = ''
if (pointer === 'touch') touch(key) // A tap is a rollover: one rise per tap, even when the browser also gave it a keyboard focus
else { const tapFocused = Date.now() - (focusRiseAt[key] ?? 0) < SAME_GESTURE_MS
engage() if (pointer === 'touch' && !tapFocused) return touch(key)
active.value = key // A click with no hover nor focus before it (a screen reader, a script) raises the cursor
} // with its panel; after a hover or a keyboard focus, the cursor is already up
if (hoverKey.value !== key && !held.has(key)) return touch(key)
select(key)
} }
function onFocusOut(e: FocusEvent) { function onFocusOut(e: FocusEvent) {
@@ -281,15 +293,23 @@ function onKeydown(e: KeyboardEvent) {
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End'] const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']
if (!keys.includes(e.key)) return if (!keys.includes(e.key)) return
e.preventDefault() e.preventDefault()
const idx = props.axes.findIndex(a => a.key === active.value) // From the tab that has the focus: a hover may have selected another axis meanwhile
const from = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-axis]')?.dataset.axis
const idx = props.axes.findIndex(a => a.key === (from ?? active.value))
const last = props.axes.length - 1 const last = props.axes.length - 1
let next = idx let next = idx
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = idx === last ? 0 : idx + 1 if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = idx === last ? 0 : idx + 1
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = idx === 0 ? last : idx - 1 if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = idx === 0 ? last : idx - 1
if (e.key === 'Home') next = 0 if (e.key === 'Home') next = 0
if (e.key === 'End') next = last if (e.key === 'End') next = last
touch(props.axes[next]!.key) const key = props.axes[next]!.key
nextTick(() => tabs.value[next]?.focus()) touch(key)
nextTick(() => {
// focus() fires the focus event at once: it is part of this key press, not a new rise
keyFocus = key
tabs.value[next]?.focus()
keyFocus = null
})
} }
onMounted(() => { onMounted(() => {
@@ -383,12 +403,13 @@ onMounted(() => {
transition-delay: calc(var(--i) * 160ms); transition-delay: calc(var(--i) * 160ms);
} }
/* The visitor's hand has taken over: nothing glides any more but the touched cursor */ /* A cursor the hand has touched no longer glides: it drops to the bottom at once, then
.console--hand .fader { replays its rise. The untouched ones finish their glide, then the rotation is over */
.fader--held {
transition: none; transition: none;
} }
.console--hand .fader--rising { .fader--held.fader--rising {
transition: --level 1.1s cubic-bezier(0.22, 1, 0.36, 1); transition: --level 1.1s cubic-bezier(0.22, 1, 0.36, 1);
} }
@@ -657,7 +678,7 @@ onMounted(() => {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.fader, .fader,
.console--hand .fader--rising, .fader--held.fader--rising,
.fader-fill, .fader-fill,
.fader-name, .fader-name,
.fader-lever, .fader-lever,
+10 -2
View File
@@ -10,9 +10,9 @@
</p> </p>
<!-- Links --> <!-- Links -->
<nav class="flex items-center gap-4" aria-label="Liens du pied de page"> <nav v-if="links.length" class="flex items-center gap-4" aria-label="Liens du pied de page">
<NuxtLink <NuxtLink
v-for="link in site?.footer.links" v-for="link in links"
:key="link.to" :key="link.to"
:to="link.to" :to="link.to"
class="text-sm text-[hsl(var(--color-text)/0.4)] transition-colors hover:text-[hsl(var(--color-text)/0.7)]" class="text-sm text-[hsl(var(--color-text)/0.4)] transition-colors hover:text-[hsl(var(--color-text)/0.7)]"
@@ -27,6 +27,14 @@
<script setup lang="ts"> <script setup lang="ts">
const { data: site } = await useSiteContent() const { data: site } = await useSiteContent()
const router = useRouter()
/** The footer links of site.yml, without those to a page the site does not have yet: the link
* to /mentions-legales (03/11: no such page, its text is still to be written) shows again by
* itself once the page exists */
interface FooterLink { label: string, to: string }
const links = computed<FooterLink[]>(() => (((site.value as any)?.footer?.links ?? []) as FooterLink[])
.filter(l => /^(https?:|mailto:)/.test(l.to) || router.resolve(l.to).matched.length > 0))
</script> </script>
<style scoped> <style scoped>