console : au survol, le curseur monte sans faire descendre les autres
- survol ou focus d'un curseur : il monte en 0,9 s ; les autres restent figés à leur niveau tant que la main est sur la console (dérive et rotation suspendues) - dès le premier geste (survol, focus, clic, flèches), ce qui est monté reste monté : le visiteur pousse les curseurs un à un - avant tout geste, l'animation du 29/09 est inchangée (dérive lente, un axe mis en avant à tour de rôle, arrêt au clic) - prefers-reduced-motion : aucune transition, mêmes règles de niveaux Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,10 @@
|
|||||||
ref="root"
|
ref="root"
|
||||||
class="console"
|
class="console"
|
||||||
:class="{ 'console--compact': compact, 'console--still': reduced }"
|
:class="{ 'console--compact': compact, 'console--still': reduced }"
|
||||||
@pointerenter="hovered = true"
|
@pointerenter="pointerIn = true; hold()"
|
||||||
@pointerleave="hovered = false"
|
@pointerleave="pointerIn = false; release()"
|
||||||
@focusin="hovered = true"
|
@focusin="focusIn = true; hold()"
|
||||||
@focusout="hovered = false"
|
@focusout="onFocusOut"
|
||||||
>
|
>
|
||||||
<div class="console-board">
|
<div class="console-board">
|
||||||
<!-- Scale, read bottom to top: from asservissement to émancipation -->
|
<!-- Scale, read bottom to top: from asservissement to émancipation -->
|
||||||
@@ -30,12 +30,17 @@
|
|||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
class="fader"
|
class="fader"
|
||||||
:class="{ 'fader--active': axis.key === active }"
|
:class="{ 'fader--active': axis.key === active, 'fader--lifting': axis.key === hot }"
|
||||||
: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"
|
||||||
:aria-selected="axis.key === active"
|
:aria-selected="axis.key === active"
|
||||||
:aria-controls="`panel-${uid}`"
|
:aria-controls="`panel-${uid}`"
|
||||||
:tabindex="axis.key === active ? 0 : -1"
|
:tabindex="axis.key === active ? 0 : -1"
|
||||||
@click="choose(axis.key)"
|
@click="choose(axis.key)"
|
||||||
|
@pointerenter="hoverKey = axis.key; lift(axis.key)"
|
||||||
|
@pointerleave="hoverKey = null"
|
||||||
|
@focus="focusKey = axis.key; lift(axis.key)"
|
||||||
|
@blur="focusKey = null"
|
||||||
>
|
>
|
||||||
<span class="fader-track" aria-hidden="true">
|
<span class="fader-track" aria-hidden="true">
|
||||||
<span class="fader-fill" />
|
<span class="fader-fill" />
|
||||||
@@ -131,38 +136,95 @@ const current = computed(() => props.axes.find(a => a.key === active.value) ?? p
|
|||||||
// beats another axis is pushed up towards émancipation. Visitors watch it;
|
// beats another axis is pushed up towards émancipation. Visitors watch it;
|
||||||
// they can still pick an axis (click, arrows), which stops the rotation.
|
// they can still pick an axis (click, arrows), which stops the rotation.
|
||||||
// SSR renders every cursor at the bottom, so the first move is the rise.
|
// 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.
|
||||||
const BEAT_MS = 2600
|
const BEAT_MS = 2600
|
||||||
const BEATS_PER_AXIS = 3
|
const BEATS_PER_AXIS = 3
|
||||||
|
|
||||||
const levels = reactive<Partial<Record<AxisKey, number>>>({})
|
const levels = reactive<Partial<Record<AxisKey, number>>>({})
|
||||||
const reduced = ref(false)
|
const reduced = ref(false)
|
||||||
const hovered = ref(false)
|
|
||||||
const visible = ref(true)
|
const visible = ref(true)
|
||||||
const chosen = ref(false)
|
const chosen = 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 beat = 0
|
let beat = 0
|
||||||
let timer: ReturnType<typeof setInterval> | undefined
|
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() {
|
function place() {
|
||||||
props.axes.forEach((axis, i) => {
|
props.axes.forEach((axis, i) => {
|
||||||
const isActive = axis.key === active.value
|
const up = axis.key === active.value || pushed.has(axis.key)
|
||||||
if (reduced.value) {
|
levels[axis.key] = up ? upLevel(i) : downLevel(i)
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Level currently drawn (mid-transition included), read from the DOM */
|
||||||
|
function drawnLevel(key: AxisKey) {
|
||||||
|
const el = root.value?.querySelector<HTMLElement>(`[data-axis="${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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hold() {
|
||||||
|
if (held) return
|
||||||
|
held = true
|
||||||
|
freeze()
|
||||||
|
}
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
if (pointerIn.value || focusIn.value) return
|
||||||
|
held = false
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function advance() {
|
||||||
const idx = props.axes.findIndex(a => a.key === active.value)
|
const idx = props.axes.findIndex(a => a.key === active.value)
|
||||||
active.value = props.axes[(idx + 1) % props.axes.length]!.key
|
active.value = props.axes[(idx + 1) % props.axes.length]!.key
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBeat() {
|
function onBeat() {
|
||||||
if (!visible.value) return
|
if (!visible.value || held) return
|
||||||
beat++
|
beat++
|
||||||
if (!chosen.value && !hovered.value && beat % BEATS_PER_AXIS === 0) advance()
|
if (!chosen.value && beat % BEATS_PER_AXIS === 0) advance()
|
||||||
place()
|
place()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +233,11 @@ function choose(key: AxisKey) {
|
|||||||
active.value = key
|
active.value = key
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(active, place)
|
watch(active, (key) => {
|
||||||
|
if (engaged) pushed.add(key)
|
||||||
|
if (held) lift(key)
|
||||||
|
else place()
|
||||||
|
})
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']
|
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']
|
||||||
@@ -279,6 +345,11 @@ onMounted(() => {
|
|||||||
transition-delay: calc(var(--i) * 160ms);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
.fader:focus-visible {
|
.fader:focus-visible {
|
||||||
outline: 2px solid hsl(var(--hue));
|
outline: 2px solid hsl(var(--hue));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -544,6 +615,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.fader,
|
.fader,
|
||||||
|
.fader--lifting,
|
||||||
.fader-fill,
|
.fader-fill,
|
||||||
.fader-name,
|
.fader-name,
|
||||||
.fader-lever,
|
.fader-lever,
|
||||||
|
|||||||
Reference in New Issue
Block a user