console : graduation d'Yvv, la montée se rejoue à chaque survol

Consigne d'Yvv du 03/11, mot pour mot dans docs/consignes-yvv.md (avec
celles du 29/09) : « Au rollover, numérique monte à 3/4, économie monte à
1/2, citoyenne monte à 2/3. Les curseurs restent. Si on rollover à nouveau
ils refont leur montée. Et le pavé correspondant s'affiche bien sûr au même
rollover. »

- survol à la souris, focus au clavier ou tap : le curseur repart d'en bas
  et monte en 1,1 s à son cran (0,75 / 0,50 / 0,667), puis il y reste ;
  le panneau de l'axe s'affiche au même geste (aria-selected et tabindex
  suivent)
- au premier geste, la rotation s'arrête pour de bon ; les autres
  curseurs restent là où ils sont dessinés, jamais survolés compris
- un défilement au doigt parti d'un curseur ne compte plus comme survol
- flèches du clavier : la montée part avec sa transition rapide
- remplace les choix d'interprétation de 230f79a (curseurs poussés en
  haut, rotation qui reprend, panneau au clic seulement)
- avant tout geste, l'animation du 29/09 est inchangée ; sous
  prefers-reduced-motion, mêmes crans sans transition

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 05:42:32 +01:00
co-authored by Claude Opus 5.5
parent 230f79a8c0
commit 03f763ab57
2 changed files with 163 additions and 73 deletions
+115 -73
View File
@@ -2,10 +2,10 @@
<div
ref="root"
class="console"
:class="{ 'console--compact': compact, 'console--still': reduced }"
@pointerenter="pointerIn = true; hold()"
@pointerleave="pointerIn = false; release()"
@focusin="focusIn = true; hold()"
:class="{ 'console--compact': compact, 'console--still': reduced, 'console--hand': engaged }"
@pointerenter="pointerIn = true"
@pointerleave="pointerIn = false"
@focusin="focusIn = true"
@focusout="onFocusOut"
>
<div class="console-board">
@@ -30,17 +30,17 @@
type="button"
role="tab"
class="fader"
:class="{ 'fader--active': axis.key === active, 'fader--lifting': axis.key === hot }"
:class="{ 'fader--active': axis.key === active, 'fader--rising': rising.has(axis.key) }"
:style="{ '--hue': `var(${axis.hue})`, '--level': levels[axis.key] ?? 0, '--i': i }"
:data-axis="axis.key"
:aria-selected="axis.key === active"
:aria-controls="`panel-${uid}`"
:tabindex="axis.key === active ? 0 : -1"
@click="choose(axis.key)"
@pointerenter="hoverKey = axis.key; lift(axis.key)"
@pointerdown="lastPointer = $event.pointerType"
@pointerenter="onEnter($event, axis.key)"
@pointerleave="hoverKey = null"
@focus="focusKey = axis.key; lift(axis.key)"
@blur="focusKey = null"
@focus="onFocus($event, axis.key)"
@click="onClick($event, axis.key)"
>
<span class="fader-track" aria-hidden="true">
<span class="fader-fill" />
@@ -132,88 +132,132 @@ const tabs = ref<HTMLButtonElement[]>([])
const current = computed(() => props.axes.find(a => a.key === active.value) ?? props.axes[0])
// ── Motion ──────────────────────────────────────────────────────────────
// The cursors move on their own, slowly: each one drifts, and every few
// beats another axis is pushed up towards émancipation. Visitors watch it;
// they can still pick an axis (click, arrows), which stops the rotation.
// Yvv's instructions, word for word: docs/consignes-yvv.md (29/09 and 03/11).
//
// Before any gesture (29/09): the cursors move on their own, slowly; each one
// drifts, and every few beats another axis is pushed up towards émancipation.
// SSR renders every cursor at the bottom, so the first move is the rise.
//
// Under the visitor's hand (hover or focus), the board holds still: the
// hovered cursor rises, the others keep their level. From the first touch
// on, what is up stays up — the visitor pushes the cursors, one by one.
// 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
// 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.
const BEAT_MS = 2600
const BEATS_PER_AXIS = 3
/** Graduation of each axis, from asservissement (0) to émancipation (1) */
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 */
const SAME_GESTURE_MS = 500
const levels = reactive<Partial<Record<AxisKey, number>>>({})
/** Cursors replaying their rise (fast transition) */
const rising = reactive(new Set<AxisKey>())
const reduced = ref(false)
const visible = ref(true)
const chosen = ref(false)
const engaged = ref(false)
const pointerIn = ref(false)
const focusIn = ref(false)
const hoverKey = ref<AxisKey | null>(null)
const focusKey = ref<AxisKey | null>(null)
/** The cursor under the visitor's hand: it rises faster */
const hot = computed(() => hoverKey.value ?? focusKey.value)
/** Axes the visitor has pushed up: they never go back down */
const pushed = new Set<AxisKey>()
let engaged = false
let held = false
let lastPointer = ''
const lastRise: Partial<Record<AxisKey, number>> = {}
const riseRun: Partial<Record<AxisKey, number>> = {}
let beat = 0
let timer: ReturnType<typeof setInterval> | undefined
function upLevel(i: number) {
return reduced.value ? 0.94 : 0.87 + 0.06 * Math.sin(beat * 0.9 + i * 2.1)
}
function downLevel(i: number) {
return reduced.value ? 0.3 : 0.3 + 0.14 * Math.sin(beat * 0.9 + i * 2.1)
}
function place() {
props.axes.forEach((axis, i) => {
const up = axis.key === active.value || pushed.has(axis.key)
levels[axis.key] = up ? upLevel(i) : downLevel(i)
const isActive = axis.key === active.value
if (reduced.value) {
levels[axis.key] = isActive ? 0.94 : 0.3
return
}
const wave = Math.sin(beat * 0.9 + i * 2.1)
levels[axis.key] = isActive ? 0.87 + 0.06 * wave : 0.3 + 0.14 * wave
})
}
function faderEl(key: AxisKey) {
return root.value?.querySelector<HTMLElement>(`[data-axis="${key}"]`) ?? null
}
/** Level currently drawn (mid-transition included), read from the DOM */
function drawnLevel(key: AxisKey) {
const el = root.value?.querySelector<HTMLElement>(`[data-axis="${key}"]`)
const el = faderEl(key)
const v = el ? Number.parseFloat(getComputedStyle(el).getPropertyValue('--level')) : Number.NaN
return Number.isFinite(v) ? v : (levels[key] ?? 0)
}
/** Stop every cursor where it stands, except the one being lifted */
function freeze() {
for (const axis of props.axes) {
if (axis.key !== hot.value) levels[axis.key] = drawnLevel(axis.key)
/** First gesture: the rotation stops for good, every cursor stays where it is drawn */
function engage() {
if (engaged.value) return
for (const axis of props.axes) levels[axis.key] = drawnLevel(axis.key)
engaged.value = true
clearInterval(timer)
}
/** The touched cursor replays its rise: back to the bottom, then up to its graduation */
async function rise(key: AxisKey) {
lastRise[key] = Date.now()
const target = GRADUATION[key] ?? 0.75
if (reduced.value) {
levels[key] = target
return
}
const run = (riseRun[key] ?? 0) + 1
riseRun[key] = run
rising.delete(key)
levels[key] = 0
await nextTick()
// Commit the bottom (no transition) before the rise starts from it
const el = faderEl(key)
if (el) getComputedStyle(el).getPropertyValue('--level')
if (riseRun[key] !== run) return
rising.add(key)
levels[key] = target
}
/** A cursor under the visitor's hand: it rises and its panel shows */
function touch(key: AxisKey) {
engage()
active.value = key
if (Date.now() - (lastRise[key] ?? 0) < SAME_GESTURE_MS) return
rise(key)
}
function onEnter(e: PointerEvent, key: AxisKey) {
// A finger scrolling over a cursor is not a rollover: taps go through the click
if (e.pointerType === 'touch') return
hoverKey.value = key
touch(key)
}
function isKeyboardFocus(el: HTMLElement) {
try {
return el.matches(':focus-visible')
}
catch {
return true
}
}
function hold() {
if (held) return
held = true
freeze()
function onFocus(e: FocusEvent, key: AxisKey) {
// 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
touch(key)
}
function release() {
if (pointerIn.value || focusIn.value) return
held = false
function onClick(e: MouseEvent, key: AxisKey) {
const pointer = (e as PointerEvent).pointerType || lastPointer
lastPointer = ''
if (pointer === 'touch') touch(key)
else {
engage()
active.value = key
}
}
function onFocusOut(e: FocusEvent) {
focusIn.value = !!root.value?.contains(e.relatedTarget as Node | null)
release()
}
/** The visitor touches an axis: it rises, nothing else moves or comes down */
function lift(key: AxisKey) {
if (!engaged) {
engaged = true
pushed.add(active.value)
}
pushed.add(key)
const i = props.axes.findIndex(a => a.key === key)
levels[key] = Math.max(drawnLevel(key), upLevel(i))
}
function advance() {
@@ -222,24 +266,18 @@ function advance() {
}
function onBeat() {
if (!visible.value || held) return
if (!visible.value || engaged.value) return
beat++
if (!chosen.value && beat % BEATS_PER_AXIS === 0) advance()
if (!pointerIn.value && !focusIn.value && beat % BEATS_PER_AXIS === 0) advance()
place()
}
function choose(key: AxisKey) {
chosen.value = true
active.value = key
}
watch(active, (key) => {
if (engaged) pushed.add(key)
if (held) lift(key)
else place()
watch(active, () => {
if (!engaged.value) place()
})
function onKeydown(e: KeyboardEvent) {
lastPointer = ''
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']
if (!keys.includes(e.key)) return
e.preventDefault()
@@ -250,7 +288,7 @@ function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = idx === 0 ? last : idx - 1
if (e.key === 'Home') next = 0
if (e.key === 'End') next = last
choose(props.axes[next]!.key)
touch(props.axes[next]!.key)
nextTick(() => tabs.value[next]?.focus())
}
@@ -345,9 +383,13 @@ onMounted(() => {
transition-delay: calc(var(--i) * 160ms);
}
/* Under the visitor's hand, the cursor answers at once */
.fader--lifting {
transition: --level 0.9s cubic-bezier(0.22, 1, 0.36, 1);
/* The visitor's hand has taken over: nothing glides any more but the touched cursor */
.console--hand .fader {
transition: none;
}
.console--hand .fader--rising {
transition: --level 1.1s cubic-bezier(0.22, 1, 0.36, 1);
}
.fader:focus-visible {
@@ -615,7 +657,7 @@ onMounted(() => {
@media (prefers-reduced-motion: reduce) {
.fader,
.fader--lifting,
.console--hand .fader--rising,
.fader-fill,
.fader-name,
.fader-lever,