diff --git a/app/components/home/HomeConsole.vue b/app/components/home/HomeConsole.vue
index d74fba4..8004d73 100644
--- a/app/components/home/HomeConsole.vue
+++ b/app/components/home/HomeConsole.vue
@@ -3,10 +3,10 @@
ref="root"
class="console"
:class="{ 'console--compact': compact, 'console--still': reduced }"
- @pointerenter="hovered = true"
- @pointerleave="hovered = false"
- @focusin="hovered = true"
- @focusout="hovered = false"
+ @pointerenter="pointerIn = true; hold()"
+ @pointerleave="pointerIn = false; release()"
+ @focusin="focusIn = true; hold()"
+ @focusout="onFocusOut"
>
@@ -30,12 +30,17 @@
type="button"
role="tab"
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 }"
+ :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)"
+ @pointerleave="hoverKey = null"
+ @focus="focusKey = axis.key; lift(axis.key)"
+ @blur="focusKey = null"
>
@@ -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;
// 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.
+//
+// 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 BEATS_PER_AXIS = 3
const levels = reactive>>({})
const reduced = ref(false)
-const hovered = ref(false)
const visible = ref(true)
const chosen = ref(false)
+const pointerIn = ref(false)
+const focusIn = ref(false)
+const hoverKey = ref(null)
+const focusKey = ref(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()
+let engaged = false
+let held = false
let beat = 0
let timer: ReturnType | 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 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
+ const up = axis.key === active.value || pushed.has(axis.key)
+ levels[axis.key] = up ? upLevel(i) : downLevel(i)
})
}
+/** Level currently drawn (mid-transition included), read from the DOM */
+function drawnLevel(key: AxisKey) {
+ const el = root.value?.querySelector(`[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() {
const idx = props.axes.findIndex(a => a.key === active.value)
active.value = props.axes[(idx + 1) % props.axes.length]!.key
}
function onBeat() {
- if (!visible.value) return
+ if (!visible.value || held) return
beat++
- if (!chosen.value && !hovered.value && beat % BEATS_PER_AXIS === 0) advance()
+ if (!chosen.value && beat % BEATS_PER_AXIS === 0) advance()
place()
}
@@ -171,7 +233,11 @@ function choose(key: AxisKey) {
active.value = key
}
-watch(active, place)
+watch(active, (key) => {
+ if (engaged) pushed.add(key)
+ if (held) lift(key)
+ else place()
+})
function onKeydown(e: KeyboardEvent) {
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']
@@ -279,6 +345,11 @@ 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);
+}
+
.fader:focus-visible {
outline: 2px solid hsl(var(--hue));
outline-offset: 2px;
@@ -544,6 +615,7 @@ onMounted(() => {
@media (prefers-reduced-motion: reduce) {
.fader,
+ .fader--lifting,
.fader-fill,
.fader-name,
.fader-lever,