calendar: replace fade transitions with zoom slider flow

This commit is contained in:
Ruslan Bakiev
2026-02-22 15:18:54 +07:00
parent 25a5e83f95
commit fedc76c6f5

View File

@@ -2072,6 +2072,7 @@ onBeforeUnmount(() => {
clearInterval(lifecycleClock); clearInterval(lifecycleClock);
lifecycleClock = null; lifecycleClock = null;
} }
clearCalendarZoomOverlay();
}); });
const calendarView = ref<CalendarView>("year"); const calendarView = ref<CalendarView>("year");
@@ -2115,32 +2116,154 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [
{ value: "agenda", label: "Agenda" }, { value: "agenda", label: "Agenda" },
]; ];
type CalendarTransitionDirection = "in" | "out" | "side"; type CalendarHierarchyView = "year" | "month" | "week" | "day";
type CalendarRect = { left: number; top: number; width: number; height: number };
const calendarTransitionDirection = ref<CalendarTransitionDirection>("side"); const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarHoveredMonthIndex = ref<number | null>(null); const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref(""); const calendarHoveredWeekStartKey = ref("");
const calendarHoveredDayKey = ref(""); const calendarHoveredDayKey = ref("");
const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
active: false,
left: 0,
top: 0,
width: 0,
height: 0,
});
let calendarWheelLockUntil = 0; let calendarWheelLockUntil = 0;
let calendarZoomOverlayTimer: ReturnType<typeof setTimeout> | null = null;
const CALENDAR_ZOOM_DURATION_MS = 180;
const calendarZoomStops: Array<{ view: CalendarHierarchyView; label: string }> = [
{ view: "year", label: "Year" },
{ view: "month", label: "Month" },
{ view: "week", label: "Week" },
{ view: "day", label: "Day" },
];
const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
const calendarSceneKey = computed(() => `${calendarView.value}-${calendarRouteToken(calendarView.value)}`); const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
const calendarTransitionName = computed(() => { calendarView.value === "agenda" ? "month" : calendarView.value,
if (calendarTransitionDirection.value === "in") return "calendar-zoom-in"; );
if (calendarTransitionDirection.value === "out") return "calendar-zoom-out"; const calendarZoomLevelIndex = computed(() =>
return "calendar-zoom-side"; Math.max(0, calendarZoomStops.findIndex((stop) => stop.view === normalizedCalendarView.value)),
}); );
const calendarZoomOverlayStyle = computed(() => ({
left: `${calendarZoomOverlay.value.left}px`,
top: `${calendarZoomOverlay.value.top}px`,
width: `${calendarZoomOverlay.value.width}px`,
height: `${calendarZoomOverlay.value.height}px`,
}));
const canCalendarZoomIn = computed(() => calendarView.value !== "day"); function clearCalendarZoomOverlay() {
const canCalendarZoomOut = computed(() => calendarView.value !== "year"); if (calendarZoomOverlayTimer) {
const calendarZoomDepthLabel = computed(() => { clearTimeout(calendarZoomOverlayTimer);
if (calendarView.value === "day") return "Day"; calendarZoomOverlayTimer = null;
if (calendarView.value === "week") return "Week"; }
if (calendarView.value === "month" || calendarView.value === "agenda") return "Month"; }
return "Months";
});
function setCalendarTransition(direction: CalendarTransitionDirection) { function queryCalendarElement(selector: string) {
calendarTransitionDirection.value = direction; return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
}
function getCalendarViewportRect(): CalendarRect | null {
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
if (!wrapRect) return null;
return {
left: 0,
top: 0,
width: Math.max(24, wrapRect.width),
height: Math.max(24, wrapRect.height),
};
}
function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null {
if (!element) return null;
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
if (!wrapRect) return null;
const rect = element.getBoundingClientRect();
const left = Math.max(0, rect.left - wrapRect.left);
const top = Math.max(0, rect.top - wrapRect.top);
const width = Math.max(24, Math.min(rect.width, wrapRect.width - left));
const height = Math.max(24, Math.min(rect.height, wrapRect.height - top));
return { left, top, width, height };
}
function weekRowStartForDate(key: string) {
const date = new Date(`${key}T00:00:00`);
date.setDate(date.getDate() - date.getDay());
return dayKey(date);
}
function primeCalendarRect(rect: CalendarRect) {
clearCalendarZoomOverlay();
calendarZoomOverlay.value = {
active: true,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}
function morphCalendarRect(toRect: CalendarRect) {
requestAnimationFrame(() => {
calendarZoomOverlay.value = {
active: true,
left: toRect.left,
top: toRect.top,
width: toRect.width,
height: toRect.height,
};
});
calendarZoomOverlayTimer = setTimeout(() => {
calendarZoomOverlay.value = {
...calendarZoomOverlay.value,
active: false,
};
}, CALENDAR_ZOOM_DURATION_MS + 40);
}
function waitCalendarZoom() {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS);
});
}
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: () => void) {
const fromRect = getElementRectInCalendar(sourceElement);
const viewportRect = getCalendarViewportRect();
if (!fromRect || !viewportRect) {
apply();
return;
}
primeCalendarRect(fromRect);
apply();
await nextTick();
morphCalendarRect(viewportRect);
await waitCalendarZoom();
}
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) {
const viewportRect = getCalendarViewportRect();
if (!viewportRect) {
apply();
return;
}
primeCalendarRect(viewportRect);
apply();
await nextTick();
const targetRect = getElementRectInCalendar(resolveTarget());
if (!targetRect) {
calendarZoomOverlay.value = {
...calendarZoomOverlay.value,
active: false,
};
return;
}
morphCalendarRect(targetRect);
await waitCalendarZoom();
} }
function resolveMonthAnchor(event?: WheelEvent) { function resolveMonthAnchor(event?: WheelEvent) {
@@ -2171,41 +2294,78 @@ function resolveDayAnchor(event?: WheelEvent) {
return selectedDateKey.value; return selectedDateKey.value;
} }
function zoomInCalendar(event?: Event) { async function zoomInCalendar(event?: Event) {
const wheelEvent = event instanceof WheelEvent ? event : undefined; const wheelEvent = event instanceof WheelEvent ? event : undefined;
if (calendarView.value === "year") { if (calendarView.value === "year") {
openYearMonth(resolveMonthAnchor(wheelEvent), "in"); const monthIndex = resolveMonthAnchor(wheelEvent);
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
openYearMonth(monthIndex);
});
return; return;
} }
if (calendarView.value === "month" || calendarView.value === "agenda") { if (calendarView.value === "month" || calendarView.value === "agenda") {
openWeekView(resolveWeekAnchor(wheelEvent), "in"); const anchorDayKey = resolveWeekAnchor(wheelEvent);
const rowStartKey = weekRowStartForDate(anchorDayKey);
await animateCalendarZoomIn(
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`),
() => {
openWeekView(anchorDayKey);
},
);
return; return;
} }
if (calendarView.value === "week") { if (calendarView.value === "week") {
openDayView(resolveDayAnchor(wheelEvent), "in"); const dayAnchor = resolveDayAnchor(wheelEvent);
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`), () => {
openDayView(dayAnchor);
});
} }
} }
function zoomOutCalendar() { async function zoomToMonth(monthIndex: number) {
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
openYearMonth(monthIndex);
});
}
async function zoomOutCalendar() {
focusedCalendarEventId.value = ""; focusedCalendarEventId.value = "";
if (calendarView.value === "day") { if (calendarView.value === "day") {
setCalendarTransition("out"); const targetDayKey = selectedDateKey.value;
calendarView.value = "week"; await animateCalendarZoomOut(
() => {
calendarView.value = "week";
},
() => queryCalendarElement(`[data-calendar-day-key="${targetDayKey}"]`),
);
return; return;
} }
if (calendarView.value === "week") { if (calendarView.value === "week") {
setCalendarTransition("out"); const targetRowKey = weekRowStartForDate(selectedDateKey.value);
calendarView.value = "month"; await animateCalendarZoomOut(
() => {
calendarView.value = "month";
},
() =>
queryCalendarElement(`[data-calendar-week-start-key="${targetRowKey}"]`) ??
queryCalendarElement(`[data-calendar-day-key="${selectedDateKey.value}"]`),
);
return; return;
} }
if (calendarView.value === "month" || calendarView.value === "agenda") { if (calendarView.value === "month" || calendarView.value === "agenda") {
setCalendarTransition("out"); const targetMonthIndex = calendarCursor.value.getMonth();
calendarView.value = "year"; await animateCalendarZoomOut(
() => {
calendarView.value = "year";
},
() => queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
);
} }
} }
@@ -2216,11 +2376,36 @@ function onCalendarHierarchyWheel(event: WheelEvent) {
calendarWheelLockUntil = now + 240; calendarWheelLockUntil = now + 240;
if (event.deltaY < 0) { if (event.deltaY < 0) {
zoomInCalendar(event); void zoomInCalendar(event);
return; return;
} }
zoomOutCalendar(); void zoomOutCalendar();
}
async function setCalendarZoomLevel(targetView: CalendarHierarchyView) {
let currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
const targetIndex = calendarZoomOrder.indexOf(targetView);
if (currentIndex < 0 || targetIndex < 0 || currentIndex === targetIndex) return;
while (currentIndex !== targetIndex) {
if (targetIndex > currentIndex) {
await zoomInCalendar();
} else {
await zoomOutCalendar();
}
currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
}
}
function onCalendarZoomSliderInput(event: Event) {
const value = Number((event.target as HTMLInputElement | null)?.value ?? NaN);
if (!Number.isFinite(value)) return;
const sliderStep = Math.max(0, Math.min(3, Math.round(value)));
const targetIndex = 3 - sliderStep;
const targetView = calendarZoomOrder[targetIndex];
if (!targetView) return;
void setCalendarZoomLevel(targetView);
} }
const monthCells = computed(() => { const monthCells = computed(() => {
@@ -2338,7 +2523,6 @@ const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value))
function shiftCalendar(step: number) { function shiftCalendar(step: number) {
focusedCalendarEventId.value = ""; focusedCalendarEventId.value = "";
setCalendarTransition("side");
if (calendarView.value === "year") { if (calendarView.value === "year") {
const next = new Date(calendarCursor.value); const next = new Date(calendarCursor.value);
next.setFullYear(next.getFullYear() + step); next.setFullYear(next.getFullYear() + step);
@@ -2366,7 +2550,6 @@ function shiftCalendar(step: number) {
function setToday() { function setToday() {
focusedCalendarEventId.value = ""; focusedCalendarEventId.value = "";
setCalendarTransition("side");
const now = new Date(); const now = new Date();
selectedDateKey.value = dayKey(now); selectedDateKey.value = dayKey(now);
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1); calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -2379,24 +2562,21 @@ function pickDate(key: string) {
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1); calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
} }
function openDayView(key: string, direction: CalendarTransitionDirection = "in") { function openDayView(key: string) {
pickDate(key); pickDate(key);
setCalendarTransition(direction);
calendarView.value = "day"; calendarView.value = "day";
} }
function openWeekView(key: string, direction: CalendarTransitionDirection = "in") { function openWeekView(key: string) {
pickDate(key); pickDate(key);
setCalendarTransition(direction);
calendarView.value = "week"; calendarView.value = "week";
} }
function openYearMonth(monthIndex: number, direction: CalendarTransitionDirection = "in") { function openYearMonth(monthIndex: number) {
focusedCalendarEventId.value = ""; focusedCalendarEventId.value = "";
const year = calendarCursor.value.getFullYear(); const year = calendarCursor.value.getFullYear();
calendarCursor.value = new Date(year, monthIndex, 1); calendarCursor.value = new Date(year, monthIndex, 1);
selectedDateKey.value = dayKey(new Date(year, monthIndex, 1)); selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
setCalendarTransition(direction);
calendarView.value = "month"; calendarView.value = "month";
} }
@@ -4072,7 +4252,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
v-if="contextPickerEnabled" v-if="contextPickerEnabled"
class="context-scope-label" class="context-scope-label"
>{{ contextScopeLabel('calendar') }}</span> >{{ contextScopeLabel('calendar') }}</span>
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2"> <div class="grid grid-cols-[auto_1fr] items-center gap-2">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button class="btn btn-xs" @click="setToday">Today</button> <button class="btn btn-xs" @click="setToday">Today</button>
</div> </div>
@@ -4081,14 +4261,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
{{ calendarPeriodLabel }} {{ calendarPeriodLabel }}
</div> </div>
<div class="justify-self-end flex items-center gap-2">
<div class="hidden items-center gap-2 rounded-lg border border-base-300 bg-base-100 px-2 py-1 sm:flex">
<span class="text-[10px] font-semibold uppercase tracking-wide text-base-content/60">Wheel to zoom</span>
<span class="badge badge-ghost badge-xs min-w-[3.2rem] justify-center">{{ calendarZoomDepthLabel }}</span>
</div>
<button class="btn btn-xs btn-ghost" :disabled="!canCalendarZoomOut" title="Zoom out" @click.stop="zoomOutCalendar"></button>
<button class="btn btn-xs btn-ghost" :disabled="!canCalendarZoomIn" title="Zoom in" @click.stop="zoomInCalendar">+</button>
</div>
</div> </div>
<article <article
@@ -4103,7 +4275,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p> <p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
</article> </article>
<div class="calendar-content-wrap min-h-0 flex-1"> <div ref="calendarContentWrapRef" class="calendar-content-wrap min-h-0 flex-1">
<button <button
class="calendar-side-nav calendar-side-nav-left" class="calendar-side-nav calendar-side-nav-left"
type="button" type="button"
@@ -4112,24 +4284,43 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
> >
<span></span> <span></span>
</button> </button>
<button <button
class="calendar-side-nav calendar-side-nav-right" class="calendar-side-nav calendar-side-nav-right"
type="button" type="button"
title="Next period" title="Next period"
@click="shiftCalendar(1)" @click="shiftCalendar(1)"
> >
<span></span> <span></span>
</button> </button>
<div <div class="calendar-zoom-slider-shell" @click.stop>
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1" <input
@wheel.prevent="onCalendarHierarchyWheel" class="calendar-zoom-slider"
> type="range"
<Transition :name="calendarTransitionName" mode="out-in"> min="0"
<div max="3"
:key="calendarSceneKey" step="1"
class="calendar-scene" :value="3 - calendarZoomLevelIndex"
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''" aria-label="Calendar zoom level"
@input="onCalendarZoomSliderInput"
> >
<div class="calendar-zoom-slider-labels" aria-hidden="true">
<span
v-for="item in calendarZoomStops"
:key="`calendar-zoom-label-${item.view}`"
:class="item.view === normalizedCalendarView ? 'calendar-zoom-slider-label-active' : ''"
>
{{ item.label }}
</span>
</div>
</div>
<div
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
@wheel.prevent="onCalendarHierarchyWheel"
>
<div
:class="normalizedCalendarView === 'day' ? 'calendar-scene cursor-zoom-out' : 'calendar-scene cursor-zoom-in'"
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''"
>
<div v-if="calendarView === 'month'" class="space-y-1"> <div v-if="calendarView === 'month'" class="space-y-1">
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60"> <div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
<span>Sun</span> <span>Sun</span>
@@ -4149,17 +4340,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:data-calendar-week-start-key="row.startKey" :data-calendar-week-start-key="row.startKey"
@mouseenter="calendarHoveredWeekStartKey = row.startKey" @mouseenter="calendarHoveredWeekStartKey = row.startKey"
> >
<button
type="button"
class="calendar-hover-jump calendar-hover-jump-row"
title="Expand week vertically"
aria-label="Expand week vertically"
@click.stop="openWeekView(row.startKey)"
>
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10 2.5v15M6.2 6.4 10 2.5l3.8 3.9M6.2 13.6 10 17.5l3.8-3.9" />
</svg>
</button>
<div class="grid grid-cols-7 gap-1"> <div class="grid grid-cols-7 gap-1">
<button <button
v-for="cell in row.cells" v-for="cell in row.cells"
@@ -4175,17 +4355,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
@click="pickDate(cell.key)" @click="pickDate(cell.key)"
> >
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p> <p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<button
type="button"
class="calendar-hover-jump"
title="Expand to day"
aria-label="Expand to day"
@click.stop="openDayView(cell.key)"
>
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 13L3 17M13 7l4-4M3 13V17h4M17 7V3h-4" />
</svg>
</button>
<button <button
v-for="event in monthCellEvents(cell.events)" v-for="event in monthCellEvents(cell.events)"
:key="event.id" :key="event.id"
@@ -4214,17 +4383,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
> >
<div class="mb-2 flex items-start justify-between gap-2"> <div class="mb-2 flex items-start justify-between gap-2">
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p> <p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
<button
type="button"
class="calendar-hover-jump calendar-hover-jump-week"
title="Expand day line"
aria-label="Expand day line"
@click.stop="openDayView(day.key)"
>
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10 3v14M6 7l4-4 4 4M6 13l4 4 4-4" />
</svg>
</button>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<button <button
@@ -4265,19 +4423,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in" class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in"
:data-calendar-month-index="item.monthIndex" :data-calendar-month-index="item.monthIndex"
@mouseenter="calendarHoveredMonthIndex = item.monthIndex" @mouseenter="calendarHoveredMonthIndex = item.monthIndex"
@click="openYearMonth(item.monthIndex)" @click="zoomToMonth(item.monthIndex)"
> >
<button
type="button"
class="calendar-hover-jump calendar-hover-jump-month"
title="Expand month diagonally"
aria-label="Expand month diagonally"
@click.stop="openYearMonth(item.monthIndex)"
>
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 13L3 17M13 7l4-4M3 13V17h4M17 7V3h-4" />
</svg>
</button>
<p class="font-medium">{{ item.label }}</p> <p class="font-medium">{{ item.label }}</p>
<p class="text-xs text-base-content/60">{{ item.count }} events</p> <p class="text-xs text-base-content/60">{{ item.count }} events</p>
<button <button
@@ -4304,11 +4451,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p> <p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button> </button>
</div> </div>
</div> </div>
</Transition> </div>
</div> <div
</div> v-if="calendarZoomOverlay.active"
</section> class="calendar-zoom-overlay"
:style="calendarZoomOverlayStyle"
/>
</div>
</section>
<section v-else-if="selectedTab === 'communications' && false" class="space-y-3"> <section v-else-if="selectedTab === 'communications' && false" class="space-y-3">
<div class="mb-1 flex justify-end"> <div class="mb-1 flex justify-end">
@@ -5374,7 +5525,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
.calendar-content-wrap { .calendar-content-wrap {
position: relative; position: relative;
padding-left: 40px; padding-left: 40px;
padding-right: 40px; padding-right: 128px;
} }
.calendar-content-scroll { .calendar-content-scroll {
@@ -5386,7 +5537,16 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
min-height: 100%; min-height: 100%;
min-width: 100%; min-width: 100%;
transform-origin: center center; transform-origin: center center;
will-change: transform, opacity; }
.calendar-scene.cursor-zoom-in,
.calendar-scene.cursor-zoom-in * {
cursor: zoom-in;
}
.calendar-scene.cursor-zoom-out,
.calendar-scene.cursor-zoom-out * {
cursor: zoom-out;
} }
.calendar-week-grid { .calendar-week-grid {
@@ -5424,117 +5584,102 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
} }
.calendar-side-nav-right { .calendar-side-nav-right {
right: 4px; right: 56px;
} }
.calendar-hover-jump { .calendar-zoom-slider-shell {
position: absolute; position: absolute;
top: 4px; top: 50%;
right: 4px; right: 8px;
z-index: 3; z-index: 5;
display: inline-flex; transform: translateY(-50%);
display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 8px;
min-width: 26px; border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
width: 26px; border-radius: 12px;
height: 26px; background: color-mix(in oklab, var(--color-base-100) 90%, transparent);
padding: 0; padding: 8px 7px;
border-radius: 8px; }
border: 1px solid color-mix(in oklab, var(--color-primary) 35%, transparent);
background: color-mix(in oklab, var(--color-base-100) 86%, transparent); .calendar-zoom-slider {
color: color-mix(in oklab, var(--color-base-content) 78%, transparent); width: 124px;
opacity: 0; height: 16px;
pointer-events: none; margin: 0;
transition: opacity 120ms ease, transform 120ms ease, background-color 120ms ease; transform: rotate(-90deg);
transform: translateY(-2px); transform-origin: center;
accent-color: color-mix(in oklab, var(--color-primary) 82%, transparent);
cursor: pointer; cursor: pointer;
} }
.group:hover > .calendar-hover-jump, .calendar-zoom-slider:focus-visible {
.group:focus-within > .calendar-hover-jump { outline: 2px solid color-mix(in oklab, var(--color-primary) 52%, transparent);
opacity: 1; outline-offset: 2px;
pointer-events: auto;
transform: translateY(0);
} }
.calendar-hover-jump:focus-visible { .calendar-zoom-slider::-webkit-slider-runnable-track {
opacity: 1; height: 4px;
pointer-events: auto; border-radius: 999px;
transform: translateY(0); background: color-mix(in oklab, var(--color-base-content) 26%, transparent);
outline: 2px solid color-mix(in oklab, var(--color-primary) 58%, transparent);
outline-offset: 1px;
} }
.calendar-hover-jump:hover { .calendar-zoom-slider::-webkit-slider-thumb {
background: color-mix(in oklab, var(--color-primary) 12%, var(--color-base-100)); -webkit-appearance: none;
margin-top: -5px;
width: 14px;
height: 14px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--color-primary) 42%, transparent);
background: color-mix(in oklab, var(--color-primary) 86%, var(--color-base-100));
} }
.calendar-hover-jump-week, .calendar-zoom-slider::-moz-range-track {
.calendar-hover-jump-row { height: 4px;
top: 8px; border-radius: 999px;
right: 10px; background: color-mix(in oklab, var(--color-base-content) 26%, transparent);
} }
.calendar-hover-jump-month { .calendar-zoom-slider::-moz-range-thumb {
top: 8px; width: 14px;
right: 8px; height: 14px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--color-primary) 42%, transparent);
background: color-mix(in oklab, var(--color-primary) 86%, var(--color-base-100));
} }
.calendar-hover-jump-row { .calendar-zoom-slider-labels {
top: 50%; display: flex;
right: -14px; flex-direction: column;
transform: translate(6px, -50%); justify-content: space-between;
height: 124px;
font-size: 10px;
line-height: 1;
color: color-mix(in oklab, var(--color-base-content) 56%, transparent);
} }
.group:hover > .calendar-hover-jump-row, .calendar-zoom-slider-label-active {
.group:focus-within > .calendar-hover-jump-row, color: color-mix(in oklab, var(--color-primary) 88%, var(--color-base-content));
.calendar-hover-jump-row:focus-visible { font-weight: 700;
transform: translate(0, -50%);
} }
.calendar-zoom-in-enter-active, .calendar-zoom-overlay {
.calendar-zoom-in-leave-active, position: absolute;
.calendar-zoom-out-enter-active, z-index: 6;
.calendar-zoom-out-leave-active, border: 2px solid color-mix(in oklab, var(--color-primary) 60%, transparent);
.calendar-zoom-side-enter-active, border-radius: 12px;
.calendar-zoom-side-leave-active { background: color-mix(in oklab, var(--color-primary) 12%, transparent);
transition: transform 230ms cubic-bezier(0.2, 0.86, 0.2, 1), opacity 230ms ease; pointer-events: none;
} transition:
left 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
.calendar-zoom-in-enter-from { top 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
opacity: 0; width 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
transform: scale(0.92) translateY(8px); height 180ms cubic-bezier(0.2, 0.85, 0.25, 1);
}
.calendar-zoom-in-leave-to {
opacity: 0;
transform: scale(1.07) translateY(-8px);
}
.calendar-zoom-out-enter-from {
opacity: 0;
transform: scale(1.07) translateY(-8px);
}
.calendar-zoom-out-leave-to {
opacity: 0;
transform: scale(0.92) translateY(8px);
}
.calendar-zoom-side-enter-from {
opacity: 0;
transform: translateY(8px);
}
.calendar-zoom-side-leave-to {
opacity: 0;
transform: translateY(-8px);
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.calendar-content-wrap { .calendar-content-wrap {
padding-left: 32px; padding-left: 32px;
padding-right: 32px; padding-right: 104px;
} }
.calendar-week-grid { .calendar-week-grid {
@@ -5546,6 +5691,25 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
.calendar-side-nav-right {
right: 44px;
}
.calendar-zoom-slider-shell {
right: 4px;
padding: 7px 6px;
gap: 6px;
}
.calendar-zoom-slider {
width: 102px;
}
.calendar-zoom-slider-labels {
height: 102px;
font-size: 9px;
}
} }
.pilot-shell { .pilot-shell {