06bce11e1c
Roteamento por URL (substitui o ref local secaoAberta):
- routes.misc.js: rota vira /preview/melissa/:secao? — param opcional
- MelissaLayout.vue: secaoAberta agora e computed do route.params.secao,
validado contra SECOES (chave invalida -> null). abrirSecao/fecharSecao
fazem router.push em vez de mutar ref. Habilita back/forward, refresh
e deep-link tipo /preview/melissa/agenda.
Pagina Pacientes (WIP, ainda nao wireada no slot do Layout):
- src/layout/melissa/MelissaPacientes.vue (novo, ~? linhas) — fullscreen
3-col espelhando MelissaAgenda: aside esquerda com filtros (status /
grupos / tags), lista central com cards + busca, quick view direita
com KPIs do paciente selecionado + acoes.
- Carrega pacientes (todos os status), grupos/tags do tenant, vinculos
patient_groups + patient_tags + session counts em paralelo.
- Integra PatientProntuario (overlay), PatientCadastroDialog,
PatientCreatePopover + ComponentCadastroRapido, e
conversationDrawerStore (acao WhatsApp da quick view).
useMelissaPacientes ganha opcao { onlyActive }:
- default true (compat com cards do resumo / cronometro / eventos hoje
— so faz sentido com ativos)
- false retorna Ativo + Inativo + Arquivado, pra uso na pagina nova
- select agora inclui data_nascimento (necessario pros KPIs da quick view)
Cronometro: zera ao parar — terminou a sessao, fica pronto pra proxima
sem precisar reabrir o popover.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
623 lines
21 KiB
Vue
623 lines
21 KiB
Vue
<script setup>
|
||
/*
|
||
* MelissaCronometro
|
||
* --------------------------------------------------
|
||
* Cronômetro de sessão estilo "janela do Windows":
|
||
* - Dialog centralizado com select de paciente, display gigante e ações
|
||
* - Click fora minimiza (chip no canto superior esquerdo)
|
||
* - X/ESC fecha (destrói)
|
||
* - Botão inferior alterna entre "▶ Começar" e "⏹ Parar"
|
||
* - "+1 minuto" estende o tempo
|
||
* - Quando minimizado, o timer continua rodando em background
|
||
*
|
||
* Uso:
|
||
* <MelissaCronometro
|
||
* ref="cronoRef"
|
||
* :pacientes="lista"
|
||
* default-paciente-id="p1"
|
||
* :duracao-minutos="50"
|
||
* @visible-change="cronoVisivel = $event"
|
||
* />
|
||
*
|
||
* cronoRef.value.abrir() // abre / restaura
|
||
* cronoRef.value.fechar() // destrói
|
||
*/
|
||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||
import { playToque } from './melissaToques';
|
||
|
||
const STORAGE_KEY = 'melissa.cronometro.v1';
|
||
|
||
const props = defineProps({
|
||
pacientes: {
|
||
type: Array,
|
||
default: () => []
|
||
// Cada item: { id: string, nome: string }
|
||
},
|
||
defaultPacienteId: {
|
||
type: [String, Number, null],
|
||
default: null
|
||
},
|
||
duracaoMinutos: {
|
||
type: Number,
|
||
default: 50
|
||
},
|
||
toqueTermino: {
|
||
type: String,
|
||
default: 'sino'
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits(['visible-change', 'close', 'complete']);
|
||
|
||
// ── Estado interno ─────────────────────────────────────────────
|
||
const exists = ref(false);
|
||
const minimized = ref(false);
|
||
const running = ref(false);
|
||
const seconds = ref(props.duracaoMinutos * 60);
|
||
const pacienteId = ref(props.defaultPacienteId);
|
||
let timer = null;
|
||
|
||
// True só durante a transição de minimizar (dialog → chip).
|
||
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
|
||
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
|
||
const isMinimizing = ref(false);
|
||
|
||
// ── Computeds ──────────────────────────────────────────────────
|
||
const visible = computed(() => exists.value && !minimized.value);
|
||
|
||
const dialogTransitionName = computed(() => (isMinimizing.value ? 'minimize' : 'lift'));
|
||
|
||
const display = computed(() => {
|
||
const total = seconds.value;
|
||
const sign = total < 0 ? '-' : '';
|
||
const abs = Math.abs(total);
|
||
const m = Math.floor(abs / 60);
|
||
const s = abs % 60;
|
||
return `${sign}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||
});
|
||
|
||
const excedido = computed(() => seconds.value < 0);
|
||
|
||
const status = computed(() => {
|
||
if (running.value) return 'Em andamento';
|
||
if (seconds.value === props.duracaoMinutos * 60) return 'Pronto';
|
||
return 'Pausado';
|
||
});
|
||
|
||
const pacienteNome = computed(() => {
|
||
if (!pacienteId.value) return 'Atividade livre';
|
||
const p = props.pacientes.find((x) => x.id === pacienteId.value);
|
||
return p ? p.nome : '';
|
||
});
|
||
|
||
// ── Watch: avisa parent quando dialog aparece/some ─────────────
|
||
watch(visible, (v) => emit('visible-change', v));
|
||
|
||
// ── Ações ──────────────────────────────────────────────────────
|
||
function abrir() {
|
||
if (exists.value) {
|
||
// Já existe — apenas restaura se tava minimizado (não cria outro)
|
||
if (minimized.value) minimized.value = false;
|
||
return;
|
||
}
|
||
seconds.value = props.duracaoMinutos * 60;
|
||
pacienteId.value = props.defaultPacienteId;
|
||
running.value = false;
|
||
minimized.value = false;
|
||
exists.value = true;
|
||
}
|
||
|
||
function toggle() {
|
||
if (running.value) {
|
||
if (timer) clearInterval(timer);
|
||
timer = null;
|
||
running.value = false;
|
||
// Zera ao parar — sessão acabou, deixa pronto pra próxima
|
||
seconds.value = props.duracaoMinutos * 60;
|
||
} else {
|
||
if (timer) clearInterval(timer);
|
||
timer = setInterval(() => {
|
||
const wasPositive = seconds.value > 0;
|
||
seconds.value -= 1;
|
||
// Toca exatamente na transição positivo → zero/negativo (uma única vez)
|
||
if (wasPositive && seconds.value <= 0) {
|
||
playToque(props.toqueTermino);
|
||
}
|
||
}, 1000);
|
||
running.value = true;
|
||
}
|
||
}
|
||
|
||
function minimizar() {
|
||
isMinimizing.value = true;
|
||
minimized.value = true;
|
||
// Reset do flag depois da animação completar (340ms + buffer)
|
||
setTimeout(() => { isMinimizing.value = false; }, 380);
|
||
}
|
||
|
||
function restaurar() {
|
||
minimized.value = false;
|
||
}
|
||
|
||
function fechar() {
|
||
if (timer) clearInterval(timer);
|
||
timer = null;
|
||
running.value = false;
|
||
minimized.value = false;
|
||
exists.value = false;
|
||
emit('close');
|
||
}
|
||
|
||
function ajustarMinutos(delta) {
|
||
seconds.value += delta * 60;
|
||
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
|
||
saveState();
|
||
}
|
||
|
||
// ── Persistência (localStorage) ────────────────────────────────
|
||
// Snapshot inclui timestamp pra calcular elapsed real no restore —
|
||
// caso o usuário recarregue/feche a aba enquanto o timer roda, ao
|
||
// voltar o seconds reflete o tempo real (a sessão acontece na sala,
|
||
// não na aba).
|
||
function saveState() {
|
||
if (!exists.value) {
|
||
try { localStorage.removeItem(STORAGE_KEY); } catch {}
|
||
return;
|
||
}
|
||
const snap = {
|
||
pacienteId: pacienteId.value,
|
||
minimized: !!minimized.value,
|
||
running: !!running.value,
|
||
seconds: seconds.value,
|
||
savedAt: Date.now()
|
||
};
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {}
|
||
}
|
||
|
||
function loadState() {
|
||
let raw;
|
||
try { raw = localStorage.getItem(STORAGE_KEY); } catch { return false; }
|
||
if (!raw) return false;
|
||
|
||
let snap;
|
||
try { snap = JSON.parse(raw); } catch { return false; }
|
||
if (!snap || typeof snap !== 'object') return false;
|
||
|
||
// Sanitização: cada campo do storage é input externo, não confiar
|
||
const validIds = new Set(props.pacientes.map((p) => p.id));
|
||
const pid = snap.pacienteId === null || validIds.has(snap.pacienteId)
|
||
? snap.pacienteId
|
||
: props.defaultPacienteId;
|
||
|
||
const savedSeconds = Number(snap.seconds);
|
||
if (!Number.isFinite(savedSeconds)) return false;
|
||
|
||
const savedAt = Number(snap.savedAt);
|
||
const wasRunning = !!snap.running;
|
||
|
||
// Se rodava, desconta tempo real desde o save (limite 24h pra evitar abuso de relógio)
|
||
let restoredSeconds = savedSeconds;
|
||
if (wasRunning && Number.isFinite(savedAt)) {
|
||
const elapsed = Math.max(0, Math.min(86400, Math.floor((Date.now() - savedAt) / 1000)));
|
||
restoredSeconds = savedSeconds - elapsed;
|
||
}
|
||
|
||
pacienteId.value = pid;
|
||
minimized.value = !!snap.minimized;
|
||
seconds.value = restoredSeconds;
|
||
exists.value = true;
|
||
running.value = false; // toggle abaixo flipa pra true
|
||
|
||
if (wasRunning) {
|
||
// Retoma o interval. NÃO toca o toque retroativo — se o tempo
|
||
// já está negativo no restore, foi "perdido" durante o reload.
|
||
toggle();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Watch nas mudanças de estado discreto (não em seconds enquanto roda — savedAt+delta dá conta)
|
||
watch([exists, minimized, running, pacienteId], () => saveState());
|
||
|
||
// ── Mount / Cleanup ────────────────────────────────────────────
|
||
onMounted(() => {
|
||
loadState();
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (timer) clearInterval(timer);
|
||
});
|
||
|
||
defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
||
</script>
|
||
|
||
<template>
|
||
<!-- Dialog centralizado.
|
||
Transition name dinâmico: 'minimize' quando user minimiza (anima
|
||
shrink + fly em direção ao dock no bottom-left), 'lift' default
|
||
pros outros casos (fechar, abrir). -->
|
||
<Transition :name="dialogTransitionName">
|
||
<div v-if="visible" class="mc-layer" @click.self="minimizar">
|
||
<div class="mc-panel">
|
||
<div class="flex items-center justify-between mb-5">
|
||
<div>
|
||
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">Cronômetro</div>
|
||
<div class="text-white text-lg font-light mt-1">{{ status }}</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5">
|
||
<button class="mc-glass-btn" title="Minimizar" @click="minimizar">
|
||
<i class="pi pi-window-minimize text-white/90 text-xs" />
|
||
</button>
|
||
<button class="mc-glass-btn" title="Fechar" @click="fechar">
|
||
<i class="pi pi-times text-white/90 text-sm" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Select de paciente / atividade -->
|
||
<div class="mb-6">
|
||
<label class="text-[0.65rem] uppercase tracking-widest text-white/50 mb-2 block">
|
||
Paciente / atividade
|
||
</label>
|
||
<div class="relative">
|
||
<select v-model="pacienteId" class="mc-select">
|
||
<option :value="null">— Atividade livre (sem paciente)</option>
|
||
<option v-for="p in pacientes" :key="p.id" :value="p.id">
|
||
{{ p.nome }}
|
||
</option>
|
||
</select>
|
||
<i class="pi pi-chevron-down mc-select-icon" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Display gigante + steppers manuais (+5 / -5) -->
|
||
<div class="mb-7">
|
||
<div class="flex items-center justify-center gap-4">
|
||
<div class="flex flex-col gap-1.5">
|
||
<button
|
||
class="mc-step-btn"
|
||
title="Adicionar 5 minutos"
|
||
@click="ajustarMinutos(5)"
|
||
>
|
||
+5
|
||
</button>
|
||
<button
|
||
class="mc-step-btn"
|
||
title="Remover 5 minutos"
|
||
:disabled="excedido"
|
||
@click="ajustarMinutos(-5)"
|
||
>
|
||
−5
|
||
</button>
|
||
</div>
|
||
<div class="mc-display" :class="{ 'is-excedido': excedido }">
|
||
{{ display }}
|
||
</div>
|
||
</div>
|
||
<div class="text-xs mt-2 text-center" :class="excedido ? 'text-red-400 font-medium' : 'text-white/50'">
|
||
{{ excedido ? 'tempo excedido' : 'tempo restante' }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Ações -->
|
||
<div class="flex gap-3">
|
||
<button class="mc-btn mc-btn--secondary flex-1" @click="ajustarMinutos(1)">
|
||
+1 minuto
|
||
</button>
|
||
<button
|
||
class="mc-btn flex-1"
|
||
:class="running ? 'mc-btn--danger' : 'mc-btn--primary'"
|
||
@click="toggle"
|
||
>
|
||
<i :class="running ? 'pi pi-stop-circle' : 'pi pi-play'" class="text-xs" />
|
||
{{ running ? 'Parar' : 'Começar' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- Chip minimizado — teleporta pro .melissa-dock (taskbar Win11
|
||
no bottom). Vive ao lado do ψ (à direita dele, dentro do flex
|
||
do dock). Transition envolve o Teleport (pattern oficial Vue):
|
||
o Teleport some/aparece como unidade, sem deixar comment
|
||
placeholder no target compartilhado — o que evitaria o bug
|
||
"emitsOptions: null" causado por múltiplos Teleports + v-if
|
||
interno apontando pro mesmo target. -->
|
||
<Transition name="chip-pop">
|
||
<Teleport v-if="exists && minimized" to=".melissa-dock">
|
||
<button
|
||
class="mc-chip"
|
||
title="Restaurar cronômetro"
|
||
@click="restaurar"
|
||
>
|
||
<i
|
||
class="pi pi-stopwatch text-sm"
|
||
:class="running ? 'text-emerald-300 mc-chip-pulse' : 'text-white/70'"
|
||
/>
|
||
<span class="mc-chip-time" :class="{ 'text-red-300': excedido }">
|
||
{{ display }}
|
||
</span>
|
||
<span class="mc-chip-name">{{ pacienteNome }}</span>
|
||
</button>
|
||
</Teleport>
|
||
</Transition>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* ─── Dialog ───────────────────────────────────────────────── */
|
||
.mc-layer {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 60;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2rem;
|
||
}
|
||
.mc-panel {
|
||
width: min(420px, 100%);
|
||
background: var(--m-bg-medium);
|
||
backdrop-filter: blur(32px) saturate(160%);
|
||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 22px;
|
||
padding: 1.75rem;
|
||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
}
|
||
|
||
.mc-glass-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--m-bg-soft);
|
||
backdrop-filter: blur(20px);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 9999px;
|
||
cursor: pointer;
|
||
transition: background-color 160ms ease;
|
||
}
|
||
.mc-glass-btn:hover {
|
||
background: var(--m-bg-soft-hover);
|
||
}
|
||
|
||
/* ─── Select customizado ───────────────────────────────────── */
|
||
.mc-select {
|
||
width: 100%;
|
||
background: var(--m-bg-soft);
|
||
border: 1px solid var(--m-border-strong);
|
||
color: var(--m-text);
|
||
padding: 11px 38px 11px 14px;
|
||
border-radius: 10px;
|
||
font-size: 0.95rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
cursor: pointer;
|
||
transition: border-color 140ms ease, background-color 140ms ease;
|
||
}
|
||
.mc-select:hover {
|
||
background: var(--m-bg-soft-hover);
|
||
}
|
||
.mc-select:focus {
|
||
border-color: var(--m-border-strong);
|
||
}
|
||
.mc-select option {
|
||
/* renderizado pelo OS — usa tokens semânticos pra acompanhar dark/light */
|
||
background: var(--p-content-background);
|
||
color: var(--m-text);
|
||
}
|
||
.mc-select-icon {
|
||
position: absolute;
|
||
right: 14px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--m-text-muted);
|
||
font-size: 0.7rem;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ─── Display gigante ──────────────────────────────────────── */
|
||
.mc-display {
|
||
font-size: 5rem;
|
||
font-weight: 200;
|
||
line-height: 1;
|
||
letter-spacing: -0.04em;
|
||
color: var(--m-text);
|
||
font-variant-numeric: tabular-nums;
|
||
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||
transition: color 200ms ease;
|
||
}
|
||
.mc-display.is-excedido {
|
||
color: #ef4444;
|
||
text-shadow: 0 4px 24px rgba(239, 68, 68, 0.45);
|
||
animation: mc-excedido-pulse 1.4s ease-in-out infinite;
|
||
}
|
||
@keyframes mc-excedido-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.65; }
|
||
}
|
||
|
||
/* ─── Steppers manuais (+5 / -5) ───────────────────────────── */
|
||
.mc-step-btn {
|
||
width: 44px;
|
||
height: 36px;
|
||
background: var(--m-bg-soft);
|
||
border: 1px solid var(--m-border-strong);
|
||
color: var(--m-text);
|
||
border-radius: 8px;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: background-color 140ms ease, border-color 140ms ease, transform 100ms ease;
|
||
}
|
||
.mc-step-btn:hover {
|
||
background: var(--m-bg-soft-hover);
|
||
border-color: var(--m-border-strong);
|
||
}
|
||
.mc-step-btn:active {
|
||
transform: scale(0.96);
|
||
}
|
||
.mc-step-btn:disabled {
|
||
background: var(--m-bg-soft);
|
||
border-color: var(--m-border);
|
||
color: var(--m-text-faint);
|
||
cursor: not-allowed;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ─── Botões de ação ───────────────────────────────────────── */
|
||
.mc-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: background-color 140ms ease, border-color 140ms ease;
|
||
}
|
||
.mc-btn--secondary {
|
||
background: var(--m-bg-soft);
|
||
border: 1px solid var(--m-border-strong);
|
||
color: var(--m-text);
|
||
}
|
||
.mc-btn--secondary:hover {
|
||
background: var(--m-bg-soft-hover);
|
||
border-color: var(--m-border-strong);
|
||
}
|
||
.mc-btn--primary {
|
||
background: var(--m-accent);
|
||
border: 1px solid var(--m-accent);
|
||
color: var(--p-primary-contrast-color, white);
|
||
}
|
||
.mc-btn--primary:hover {
|
||
background: color-mix(in srgb, var(--m-accent) 80%, white);
|
||
}
|
||
.mc-btn--danger {
|
||
background: rgba(239, 68, 68, 0.55);
|
||
border: 1px solid rgba(239, 68, 68, 0.7);
|
||
color: white;
|
||
}
|
||
.mc-btn--danger:hover {
|
||
background: rgba(239, 68, 68, 0.75);
|
||
}
|
||
|
||
/* ─── Chip minimizado (teleportado pro .melissa-dock) ──────
|
||
Vive como filho flex do dock — não usa position fixed. Dock
|
||
posiciona ele à esquerda (depois do ψ, via padding-left). */
|
||
.mc-chip {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 14px 8px 12px;
|
||
background: var(--m-bg-medium);
|
||
backdrop-filter: blur(24px) saturate(160%);
|
||
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 9999px;
|
||
color: var(--m-text);
|
||
cursor: pointer;
|
||
transition: background-color 200ms ease, transform 200ms ease, border-color 200ms ease;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
}
|
||
.mc-chip:hover {
|
||
background: var(--m-bg-medium);
|
||
transform: translateY(-2px);
|
||
border-color: var(--m-border-strong);
|
||
}
|
||
.mc-chip-time {
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 500;
|
||
font-size: 0.85rem;
|
||
}
|
||
.mc-chip-name {
|
||
font-size: 0.72rem;
|
||
color: var(--m-text-muted);
|
||
max-width: 140px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
padding-left: 6px;
|
||
border-left: 1px solid var(--m-border-strong);
|
||
}
|
||
.mc-chip-pulse {
|
||
animation: mc-pulse 1.6s ease-in-out infinite;
|
||
}
|
||
@keyframes mc-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
|
||
/* ─── Transições ───────────────────────────────────────────── */
|
||
.lift-enter-active,
|
||
.lift-leave-active {
|
||
transition: opacity 240ms ease, transform 280ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||
}
|
||
.lift-enter-from {
|
||
opacity: 0;
|
||
transform: scale(0.96) translateY(12px);
|
||
}
|
||
.lift-leave-to {
|
||
opacity: 0;
|
||
transform: scale(0.98) translateY(8px);
|
||
}
|
||
|
||
.chip-pop-enter-active,
|
||
.chip-pop-leave-active {
|
||
transition: opacity 200ms ease, transform 220ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||
}
|
||
.chip-pop-enter-from,
|
||
.chip-pop-leave-to {
|
||
opacity: 0;
|
||
transform: scale(0.85) translateY(-8px);
|
||
}
|
||
/* Chip aparecendo após minimize: leve atraso pra deixar o dialog
|
||
"voar" antes de aparecer no dock (sensação macOS) */
|
||
.chip-pop-enter-active {
|
||
transition-delay: 120ms;
|
||
}
|
||
|
||
/* ─── Animação "minimize" (macOS-style): dialog encolhe + voa pro
|
||
bottom-left em direção ao chip que vai aparecer no dock. Aplicada
|
||
só durante minimizar() — fechar normal usa lift. ────────────── */
|
||
.minimize-enter-active,
|
||
.minimize-leave-active {
|
||
transition: opacity 280ms ease, transform 340ms cubic-bezier(0.5, 0, 0.6, 1);
|
||
/* origem ~= posição do chip no dock (left:96px após ψ, bottom centro
|
||
da faixa de 76px = ~38px). Faz o scale "encolher em direção" lá. */
|
||
transform-origin: 96px calc(100% - 38px);
|
||
}
|
||
.minimize-enter-from {
|
||
opacity: 0;
|
||
transform: scale(0.05);
|
||
}
|
||
.minimize-leave-to {
|
||
opacity: 0;
|
||
transform: scale(0.05);
|
||
}
|
||
|
||
/* ─── Light mode tweaks ────────────────────────────────────
|
||
Texto já usa var(--m-text) (flipa automático), mas alguns
|
||
shadows escuros agressivos precisam ser suavizados. */
|
||
html:not(.app-dark) .mc-display {
|
||
text-shadow: none;
|
||
}
|
||
html:not(.app-dark) .mc-chip {
|
||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.06);
|
||
}
|
||
html:not(.app-dark) .mc-panel {
|
||
box-shadow: 0 12px 36px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.06);
|
||
}
|
||
</style>
|