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>
691 lines
19 KiB
Vue
691 lines
19 KiB
Vue
<template>
|
|
<div
|
|
ref="root"
|
|
class="console"
|
|
: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">
|
|
<!-- Scale, read bottom to top: from asservissement to émancipation -->
|
|
<div class="console-scale" aria-hidden="true">
|
|
<span class="console-scale-bottom">{{ bottomLabel }}</span>
|
|
<span class="console-scale-line" />
|
|
<span class="console-scale-top">{{ topLabel }}</span>
|
|
</div>
|
|
|
|
<div
|
|
class="console-faders"
|
|
role="tablist"
|
|
:aria-label="`Les trois axes — ${bottomLabel} ou ${topLabel}`"
|
|
@keydown="onKeydown"
|
|
>
|
|
<button
|
|
v-for="(axis, i) in axes"
|
|
:id="`fader-${uid}-${axis.key}`"
|
|
:key="axis.key"
|
|
ref="tabs"
|
|
type="button"
|
|
role="tab"
|
|
class="fader"
|
|
: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 }"
|
|
:data-axis="axis.key"
|
|
:aria-selected="axis.key === active"
|
|
:aria-controls="`panel-${uid}`"
|
|
:tabindex="axis.key === active ? 0 : -1"
|
|
@pointerdown="lastPointer = $event.pointerType"
|
|
@pointerenter="onEnter($event, axis.key)"
|
|
@pointerleave="hoverKey = null"
|
|
@focus="onFocus($event, axis.key)"
|
|
@click="onClick($event, axis.key)"
|
|
>
|
|
<span class="fader-track" aria-hidden="true">
|
|
<span class="fader-fill" />
|
|
<span class="fader-cap" />
|
|
</span>
|
|
<span class="fader-label">
|
|
<span class="fader-prefix">{{ prefix }}</span>
|
|
<span class="fader-name">{{ axis.short }}</span>
|
|
</span>
|
|
<span class="fader-lever">{{ axis.lever }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="current"
|
|
:id="`panel-${uid}`"
|
|
role="tabpanel"
|
|
class="console-panel"
|
|
:style="{ '--hue': `var(${current.hue})` }"
|
|
:aria-labelledby="`fader-${uid}-${current.key}`"
|
|
>
|
|
<Transition name="panel" mode="out-in">
|
|
<div :key="current.key" class="panel-inner">
|
|
<p class="panel-upstream">
|
|
<span class="panel-upstream-label">{{ upstreamLabel }}</span>
|
|
{{ current.lever }}
|
|
</p>
|
|
<h2 class="panel-title">{{ current.title }}</h2>
|
|
<p v-if="current.promise" class="panel-promise">{{ current.promise }}</p>
|
|
|
|
<ul class="panel-tools">
|
|
<li v-for="tool in current.items" :key="tool.label">
|
|
<NuxtLink v-if="tool.to" :to="tool.to" class="panel-tool">
|
|
<span class="panel-tool-name">{{ tool.label }}</span>
|
|
<span v-if="tool.presentation" class="panel-tool-app">{{ tool.presentation.title }}</span>
|
|
<span v-if="tool.status || tool.gestation" class="panel-tool-state">{{ (tool.status || 'En gestation').toLowerCase() }}</span>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
|
|
<p v-if="current.key === 'numerique' && stacks?.length" class="panel-stacks">
|
|
<span class="panel-stacks-label">{{ stacksLabel }}</span>
|
|
<span v-for="s in stacks.slice(0, maxStacks)" :key="s.name" class="panel-stack">{{ s.name }}</span>
|
|
<NuxtLink v-if="stacks.length > maxStacks && stacksTo" :to="stacksTo" class="panel-stacks-more">
|
|
+{{ stacks.length - maxStacks }}
|
|
</NuxtLink>
|
|
</p>
|
|
|
|
<NuxtLink :to="current.to" class="panel-more">
|
|
Voir l'axe {{ current.short }}
|
|
<span class="i-lucide-arrow-right h-4 w-4" aria-hidden="true" />
|
|
</NuxtLink>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { AxisKey, HomeAxis, HomeStack } from '~/composables/useHomeData'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
axes: HomeAxis[]
|
|
prefix?: string
|
|
topLabel?: string
|
|
bottomLabel?: string
|
|
upstreamLabel?: string
|
|
stacks?: HomeStack[]
|
|
stacksLabel?: string
|
|
stacksTo?: string
|
|
maxStacks?: number
|
|
compact?: boolean
|
|
}>(), {
|
|
prefix: 'Autonomie',
|
|
topLabel: 'Émancipation',
|
|
bottomLabel: 'Asservissement',
|
|
maxStacks: 8,
|
|
upstreamLabel: 'en amont',
|
|
stacksLabel: 'En service',
|
|
compact: false,
|
|
})
|
|
|
|
const active = defineModel<AxisKey>({ default: 'numerique' })
|
|
const uid = useId()
|
|
const root = ref<HTMLElement | null>(null)
|
|
const tabs = ref<HTMLButtonElement[]>([])
|
|
|
|
const current = computed(() => props.axes.find(a => a.key === active.value) ?? props.axes[0])
|
|
|
|
// ── Motion ──────────────────────────────────────────────────────────────
|
|
// 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; the low
|
|
// ones drift, and every few beats another axis is pushed up to its own
|
|
// graduation (03/11), the level the hand gives it.
|
|
// SSR renders every cursor at the bottom, so the first move is the rise.
|
|
//
|
|
// 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 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 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 }
|
|
/** The focus and the click of one tap, within this delay, give one rise */
|
|
const SAME_GESTURE_MS = 500
|
|
|
|
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) */
|
|
const rising = reactive(new Set<AxisKey>())
|
|
const reduced = ref(false)
|
|
const visible = ref(true)
|
|
const engaged = ref(false)
|
|
const pointerIn = ref(false)
|
|
const focusIn = ref(false)
|
|
const hoverKey = ref<AxisKey | null>(null)
|
|
let lastPointer = ''
|
|
/** 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>> = {}
|
|
let beat = 0
|
|
let timer: ReturnType<typeof setInterval> | undefined
|
|
|
|
function place() {
|
|
props.axes.forEach((axis, i) => {
|
|
// The axis put forward goes up to its own graduation, as under the hand
|
|
if (axis.key === active.value) {
|
|
levels[axis.key] = GRADUATION[axis.key] ?? 0.75
|
|
return
|
|
}
|
|
levels[axis.key] = reduced.value ? 0.3 : 0.3 + 0.14 * Math.sin(beat * 0.9 + i * 2.1)
|
|
})
|
|
}
|
|
|
|
function faderEl(key: AxisKey) {
|
|
return root.value?.querySelector<HTMLElement>(`[data-axis="${key}"]`) ?? null
|
|
}
|
|
|
|
/** First gesture: the rotation stops for good. The untouched cursors keep the target the
|
|
* rotation gave them and finish their glide to it (no freeze mid-course) */
|
|
function engage() {
|
|
if (engaged.value) return
|
|
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) {
|
|
held.add(key)
|
|
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. Every rollover replays
|
|
* the rise, however soon after the previous one (Yvv, 03/11) */
|
|
function touch(key: AxisKey) {
|
|
engage()
|
|
active.value = 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) {
|
|
// 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 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
|
|
if (hoverKey.value === key || !isKeyboardFocus(e.currentTarget as HTMLElement)) return
|
|
focusRiseAt[key] = Date.now()
|
|
touch(key)
|
|
}
|
|
|
|
function onClick(e: MouseEvent, key: AxisKey) {
|
|
const pointer = (e as PointerEvent).pointerType || lastPointer
|
|
lastPointer = ''
|
|
// A tap is a rollover: one rise per tap, even when the browser also gave it a keyboard focus
|
|
const tapFocused = Date.now() - (focusRiseAt[key] ?? 0) < SAME_GESTURE_MS
|
|
if (pointer === 'touch' && !tapFocused) return touch(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) {
|
|
focusIn.value = !!root.value?.contains(e.relatedTarget as Node | null)
|
|
}
|
|
|
|
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 || engaged.value) return
|
|
beat++
|
|
if (!pointerIn.value && !focusIn.value && beat % BEATS_PER_AXIS === 0) advance()
|
|
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()
|
|
// 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
|
|
let next = idx
|
|
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 === 'Home') next = 0
|
|
if (e.key === 'End') next = last
|
|
const key = props.axes[next]!.key
|
|
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(() => {
|
|
reduced.value = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
requestAnimationFrame(place)
|
|
if (reduced.value) return
|
|
|
|
timer = setInterval(onBeat, BEAT_MS)
|
|
const io = new IntersectionObserver(([entry]) => { visible.value = !!entry?.isIntersecting })
|
|
if (root.value) io.observe(root.value)
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(timer)
|
|
io.disconnect()
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.console {
|
|
--track-h: 11rem;
|
|
--cap-h: 1.25rem;
|
|
display: grid;
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.console--compact {
|
|
--track-h: 8rem;
|
|
}
|
|
|
|
/* ── Board ── */
|
|
.console-board {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 0.9rem;
|
|
padding: 1.25rem 1.25rem 1rem;
|
|
border-radius: 16px;
|
|
background: hsl(var(--color-surface));
|
|
box-shadow:
|
|
inset 0 1px 0 hsl(var(--color-text) / 0.04),
|
|
0 18px 40px -24px hsl(var(--color-text) / 0.35);
|
|
}
|
|
|
|
/* Vertical scale, read from bottom to top */
|
|
.console-scale {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
height: var(--track-h);
|
|
writing-mode: vertical-rl;
|
|
transform: rotate(180deg);
|
|
font-family: var(--font-display);
|
|
font-size: 0.72rem;
|
|
letter-spacing: 0.02em;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.console-scale-bottom {
|
|
color: hsl(var(--color-text) / 0.45);
|
|
}
|
|
|
|
.console-scale-top {
|
|
font-weight: 800;
|
|
color: hsl(var(--color-text));
|
|
}
|
|
|
|
.console-scale-line {
|
|
flex: 1;
|
|
width: 1px;
|
|
background: linear-gradient(to bottom, hsl(var(--color-text) / 0.12), hsl(var(--color-text) / 0.45));
|
|
}
|
|
|
|
.console-faders {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/* ── Fader ── */
|
|
.fader {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
padding: 0 0.25rem 0.25rem;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
/* --level is a registered property (main.css): the cursors glide */
|
|
transition: --level 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95);
|
|
transition-delay: calc(var(--i) * 160ms);
|
|
}
|
|
|
|
/* A cursor the hand has touched no longer glides: it drops to the bottom at once, then
|
|
replays its rise. The untouched ones finish their glide, then the rotation is over */
|
|
.fader--held {
|
|
transition: none;
|
|
}
|
|
|
|
.fader--held.fader--rising {
|
|
transition: --level 1.1s cubic-bezier(0.22, 1, 0.36, 1);
|
|
}
|
|
|
|
.fader:focus-visible {
|
|
outline: 2px solid hsl(var(--hue));
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.fader-track {
|
|
position: relative;
|
|
width: 0.5rem;
|
|
height: var(--track-h);
|
|
border-radius: 999px;
|
|
background: hsl(var(--color-text) / 0.1);
|
|
}
|
|
|
|
.fader-fill {
|
|
position: absolute;
|
|
inset: auto 0 0 0;
|
|
height: calc(var(--level) * (var(--track-h) - var(--cap-h)) + var(--cap-h) / 2);
|
|
border-radius: inherit;
|
|
background: hsl(var(--hue) / 0.45);
|
|
transition: background 0.6s ease;
|
|
}
|
|
|
|
.fader--active .fader-fill {
|
|
background: hsl(var(--hue));
|
|
}
|
|
|
|
.fader-cap {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 0;
|
|
width: 2.6rem;
|
|
height: var(--cap-h);
|
|
margin-left: -1.3rem;
|
|
border-radius: 6px;
|
|
background: hsl(var(--color-bg));
|
|
box-shadow:
|
|
0 1px 0 hsl(var(--color-text) / 0.08),
|
|
0 4px 10px -2px hsl(var(--color-text) / 0.3);
|
|
transform: translateY(calc(-1 * var(--level) * (var(--track-h) - var(--cap-h))));
|
|
}
|
|
|
|
/* Cap groove */
|
|
.fader-cap::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0.45rem;
|
|
right: 0.45rem;
|
|
top: 50%;
|
|
height: 2px;
|
|
margin-top: -1px;
|
|
border-radius: 2px;
|
|
background: hsl(var(--hue));
|
|
}
|
|
|
|
.fader-label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.fader-prefix {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.6rem;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: hsl(var(--color-text) / 0.5);
|
|
}
|
|
|
|
.fader-name {
|
|
font-family: var(--font-display);
|
|
font-weight: 800;
|
|
font-size: 1.05rem;
|
|
letter-spacing: -0.01em;
|
|
color: hsl(var(--color-text) / 0.65);
|
|
transition: color 0.6s ease;
|
|
}
|
|
|
|
.fader--active .fader-name {
|
|
color: hsl(var(--hue));
|
|
}
|
|
|
|
.fader-lever {
|
|
padding: 0.2rem 0.55rem;
|
|
border-radius: 20px;
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 0.8rem;
|
|
line-height: 1.2;
|
|
text-align: center;
|
|
color: hsl(var(--color-text) / 0.8);
|
|
background: hsl(var(--hue) / 0.1);
|
|
transition: background 0.6s ease, color 0.6s ease;
|
|
}
|
|
|
|
.fader--active .fader-lever {
|
|
color: hsl(var(--color-bg));
|
|
background: hsl(var(--hue));
|
|
}
|
|
|
|
/* ── Panel ── */
|
|
.console-panel {
|
|
position: relative;
|
|
padding: 1.25rem 1.4rem 1.2rem;
|
|
border-radius: 16px;
|
|
background: hsl(var(--hue) / 0.08);
|
|
}
|
|
|
|
.panel-upstream {
|
|
margin: 0 0 0.35rem;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.72rem;
|
|
color: hsl(var(--hue));
|
|
}
|
|
|
|
.panel-upstream-label {
|
|
color: hsl(var(--color-text) / 0.45);
|
|
}
|
|
|
|
.panel-upstream-label::after {
|
|
content: ' ↑ ';
|
|
}
|
|
|
|
.panel-title {
|
|
margin: 0;
|
|
font-family: var(--font-display);
|
|
font-weight: 800;
|
|
font-size: clamp(1.35rem, 2.6vw, 1.7rem);
|
|
letter-spacing: -0.025em;
|
|
line-height: 1.1;
|
|
color: hsl(var(--color-text));
|
|
}
|
|
|
|
.panel-promise {
|
|
margin: 0.5rem 0 0;
|
|
font-size: 0.98rem;
|
|
line-height: 1.5;
|
|
color: hsl(var(--color-text) / 0.78);
|
|
}
|
|
|
|
.panel-tools {
|
|
list-style: none;
|
|
margin: 0.9rem 0 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.panel-tool {
|
|
display: inline-flex;
|
|
align-items: baseline;
|
|
gap: 0.4rem;
|
|
padding: 0.4rem 0.8rem;
|
|
border-radius: 20px;
|
|
background: hsl(var(--color-bg) / 0.7);
|
|
font-size: 0.85rem;
|
|
color: hsl(var(--color-text));
|
|
transition: transform 0.1s ease, background 0.2s ease;
|
|
}
|
|
|
|
.panel-tool:hover {
|
|
transform: translateY(-1px);
|
|
background: hsl(var(--color-bg));
|
|
}
|
|
|
|
.panel-tool:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.panel-tool-name {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.panel-tool-app {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
color: hsl(var(--hue));
|
|
}
|
|
|
|
.panel-tool-state {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.65rem;
|
|
color: hsl(var(--color-text) / 0.45);
|
|
}
|
|
|
|
.panel-stacks {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: baseline;
|
|
gap: 0.25rem 0.6rem;
|
|
margin: 0.9rem 0 0;
|
|
font-size: 0.8rem;
|
|
color: hsl(var(--color-text) / 0.7);
|
|
}
|
|
|
|
.panel-stacks-label {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.68rem;
|
|
color: hsl(var(--color-text) / 0.45);
|
|
}
|
|
|
|
.panel-stacks-more {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.72rem;
|
|
color: hsl(var(--hue));
|
|
}
|
|
|
|
.panel-stack::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 0.4rem;
|
|
height: 0.4rem;
|
|
margin-right: 0.3rem;
|
|
border-radius: 50%;
|
|
background: hsl(var(--hue));
|
|
vertical-align: 0.05em;
|
|
}
|
|
|
|
.panel-more {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
margin-top: 1rem;
|
|
font-family: var(--font-display);
|
|
font-weight: 700;
|
|
font-size: 0.92rem;
|
|
color: hsl(var(--hue));
|
|
}
|
|
|
|
.panel-more:hover span {
|
|
transform: translateX(2px);
|
|
}
|
|
|
|
.panel-more span {
|
|
transition: transform 0.12s ease;
|
|
}
|
|
|
|
.panel-enter-active,
|
|
.panel-leave-active {
|
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
|
}
|
|
|
|
.panel-enter-from {
|
|
opacity: 0;
|
|
transform: translateY(6px);
|
|
}
|
|
|
|
.panel-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.console { --track-h: 8.5rem; }
|
|
.console-board { padding: 1rem 0.6rem 0.75rem; gap: 0.35rem; }
|
|
.console-scale { font-size: 0.62rem; }
|
|
.fader-cap { width: 2.2rem; margin-left: -1.1rem; }
|
|
.fader-name { font-size: 0.88rem; }
|
|
.fader-prefix { font-size: 0.52rem; letter-spacing: 0.08em; }
|
|
.fader-lever { font-size: 0.68rem; padding: 0.2rem 0.4rem; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.fader,
|
|
.fader--held.fader--rising,
|
|
.fader-fill,
|
|
.fader-name,
|
|
.fader-lever,
|
|
.panel-enter-active,
|
|
.panel-leave-active {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|