feat(island): 对齐 ClassIsland 课表提醒并恢复开合动画

This commit is contained in:
LoveChengke committed 2026-10-17 16:43:36 +08:00
1 parent e84e43544a
commit 3379eb975c
15 files changed
+853 -65

No files matched your search

@@ -92,7 +92,7 @@ const fonts = {
<input type="number" min="1" max="30" v-model.number="node.settings.count" />
</label>
<label class="toggle-field">
<span>显示节次</span>
<span>显示课程时间</span>
<input type="checkbox" v-model="node.settings.showPeriod" />
</label>
</template>
@@ -163,9 +163,9 @@ const style = computed<CSSProperties>(() => {
</template>
<template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template>
<template v-else-if="node.kind === 'schedule'">
<div v-for="(s, i) in lessons" :key="s.id">
<small v-if="node.settings.showPeriod">第 {{ i + 1 }} 节 · {{ s.startTime }}</small>
<div v-for="s in lessons" :key="s.id" class="lesson-line">
<strong>{{ s.course?.name }}</strong>
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small>
</div>
<span v-if="!lessons.length">暂无课程</span>
</template>
@@ -199,8 +199,21 @@ const style = computed<CSSProperties>(() => {
}
.schedule > div {
display: flex;
flex-direction: column;
gap: 3px;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
}
.lesson-line strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lesson-time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.schedule > div + div {
margin-top: 8px;
@@ -150,14 +150,9 @@ const shellStyle = computed<CSSProperties>(() => ({
maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`,
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
color: '#ffffffeb',
fontSize: `${props.appearance.fontSize}px`,
'--custom-color':
props.appearance.style === 'glass'
? '#202024cd'
: props.appearance.style === 'tinted'
? '#0b0b0f'
: '#000000',
'--custom-radius': `${props.appearance.radius}px`,
fontSize: `${(props.appearance.fontSize / 13) * CI.bodyFontSize}px`,
'--custom-color': '#20202080',
'--custom-radius': `${scheme.value.radius}px`,
'--custom-row-gap': `${scheme.value.spacing}px`,
...(draft.value.residentEnabled
? {
@@ -7,12 +7,14 @@ import {
type IslandLayoutSettings,
type IslandState,
type ScheduleDto,
type IslandLessonReminder,
} from '@classhelper/shared';
import ComponentNode from './ComponentNode.vue';
import ComponentIcon from './ComponentIcon.vue';
import ClassicNotifications from './ClassicNotifications.vue';
import { squirclePath } from './squircle.js';
import { SpringValue } from './spring.js';
import LessonReminderContent from './LessonReminderContent.vue';
const props = defineProps<{
settings: IslandLayoutSettings;
@@ -23,6 +25,15 @@ const props = defineProps<{
}>();
const scheme = computed(() => props.settings.schemes.find((s) => s.id === props.settings.activeSchemeId)!);
const expanded = computed(() => props.state.mode === 'expanded');
const lesson = computed(() => props.state.lessonReminder ?? null);
const lastLesson = ref<IslandLessonReminder | null>(null);
watch(
lesson,
(value) => {
if (value) lastLesson.value = value;
},
{ immediate: true },
);
const messages = computed(() => {
const unique = new Map(
[props.state.active, ...props.state.queued].filter((n) => n !== null).map((n) => [n.id, n]),
@@ -36,11 +47,16 @@ const messages = computed(() => {
const shell = ref<HTMLElement>();
const compact = ref<HTMLElement>();
const details = ref<HTMLElement>();
const lessonSource = ref<HTMLElement>();
const now = ref(Date.now());
const visible = ref(!document.hidden);
const motion = computed(() => props.appearance.animations && visible.value && props.nativeVisible);
const available = ref({ width: 940, height: 620 });
const natural = ref({ compact: { width: 40, height: 40 }, details: { width: 280, height: 80 } });
const natural = ref({
compact: { width: 40, height: 40 },
details: { width: 280, height: 80 },
lesson: { width: 480, height: 40 },
});
const anchor = computed(() =>
props.appearance.position.endsWith('left')
? 'left'
@@ -59,9 +75,24 @@ function fitted(size: { width: number; height: number }) {
}
const compactFit = computed(() => fitted(natural.value.compact));
const detailsFit = computed(() => fitted(natural.value.details));
const fit = computed(() => (expanded.value ? detailsFit.value : compactFit.value));
const lessonFit = computed(() => fitted(natural.value.lesson));
const fit = computed(() =>
lesson.value ? lessonFit.value : expanded.value ? detailsFit.value : compactFit.value,
);
const morph = ref({ width: 40, height: 40, radius: 20 });
const springs = { width: new SpringValue(40), height: new SpringValue(40), radius: new SpringValue(20) };
const springs = {
width: new SpringValue(40),
height: new SpringValue(40),
radius: new SpringValue(20),
content: new SpringValue(0, { snapThreshold: 0.001 }),
lesson: new SpringValue(0, { snapThreshold: 0.001 }),
};
// 与经典岛一致:常态内容先退净,展开内容按进度平方进入;反向收起沿同一条曲线返回。
// 独立进度也保证两页尺寸相同时仍播放过渡,不能靠高度差推导。
const progress = ref(0);
const lessonProgress = ref(0);
const compactAlpha = computed(() => Math.max(0, 1 - progress.value * 1.5) * (1 - lessonProgress.value));
const detailsAlpha = computed(() => progress.value * progress.value * (1 - lessonProgress.value));
const path = computed(() => squirclePath(morph.value.width, morph.value.height, morph.value.radius));
const style = computed<CSSProperties>(() => ({
width: `${morph.value.width}px`,
@@ -80,9 +111,10 @@ const style = computed<CSSProperties>(() => ({
'--resident-scale': fit.value.scale,
'--custom-row-gap': `${scheme.value.spacing}px`,
'--resident-row-width': `${Math.max(60, available.value.width / scheme.value.scale - 58)}px`,
'--secondary': `${props.appearance.fontSize * 0.78}px`,
'--emphasized': `${props.appearance.fontSize * 1.1}px`,
'--large': `${props.appearance.fontSize * 1.3}px`,
'--secondary': `${(props.appearance.fontSize / 13) * 14}px`,
'--emphasized': `${(props.appearance.fontSize / 13) * 18}px`,
'--large': `${(props.appearance.fontSize / 13) * 20}px`,
'--ci-body-font': `${(props.appearance.fontSize / 13) * 16}px`,
'--resident-fade-duration': motion.value ? `${200 / props.appearance.speed}ms` : '0ms',
}));
let morphFrame = 0;
@@ -90,7 +122,7 @@ let morphFallback: ReturnType<typeof setTimeout> | undefined;
function animate() {
const size = fit.value;
const radius = Math.min(
expanded.value ? props.appearance.radius * 2.4 : props.appearance.radius,
lesson.value ? 8 : expanded.value ? props.appearance.radius : scheme.value.radius,
size.width / 2,
size.height / 2,
);
@@ -100,14 +132,21 @@ function animate() {
morphFrame = 0;
clearTimeout(morphFallback);
for (const key of ['width', 'height', 'radius'] as const) springs[key].snap(target[key]);
springs.content.snap(expanded.value ? 1 : 0);
springs.lesson.snap(lesson.value ? 1 : 0);
progress.value = springs.content.value;
lessonProgress.value = springs.lesson.value;
morph.value = target;
void nextTick(reportHit);
};
if (!motion.value || matchMedia('(prefers-reduced-motion: reduce)').matches) {
// 应用内动画开关与经典岛保持一致;系统减少动态效果不能再次覆盖用户已开启的动画。
if (!motion.value) {
snap();
return;
}
for (const key of ['width', 'height', 'radius'] as const) springs[key].setTarget(target[key]);
springs.content.setTarget(expanded.value ? 1 : 0);
springs.lesson.setTarget(lesson.value ? 1 : 0);
clearTimeout(morphFallback);
morphFallback = setTimeout(snap, 1200 / props.appearance.speed);
if (morphFrame) return;
@@ -120,6 +159,8 @@ function animate() {
.map((s) => s.step(dt))
.some(Boolean);
morph.value = { width: springs.width.value, height: springs.height.value, radius: springs.radius.value };
progress.value = springs.content.value;
lessonProgress.value = springs.lesson.value;
if (moving) morphFrame = requestAnimationFrame(frame);
else {
morphFrame = 0;
@@ -200,7 +241,11 @@ function measure() {
width: Math.max(40, element.offsetWidth),
height: Math.max(40, element.offsetHeight),
});
natural.value = { compact: read(compact.value), details: read(details.value) };
natural.value = {
compact: read(compact.value),
details: read(details.value),
lesson: lessonSource.value ? read(lessonSource.value) : natural.value.lesson,
};
void nextTick(() => {
window.island?.reportSize({ width: fit.value.width, height: fit.value.height });
trackHit();
@@ -221,9 +266,12 @@ function visibility() {
void nextTick(reportHit);
}
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
watch([expanded, scheme, () => props.state.listExpanded, messages], measure, { deep: true, flush: 'post' });
watch([expanded, scheme, lesson, () => props.state.listExpanded, messages], measure, {
deep: true,
flush: 'post',
});
watch(
[fit, motion, () => props.appearance.radius, () => props.appearance.speed],
[fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed],
() => {
animate();
trackHit();
@@ -240,6 +288,7 @@ onMounted(() => {
observer = new ResizeObserver(measure);
if (compact.value) observer.observe(compact.value);
if (details.value) observer.observe(details.value);
if (lessonSource.value) observer.observe(lessonSource.value);
clock = setInterval(() => (now.value = Date.now()), 1000);
window.addEventListener('resize', resize);
document.addEventListener('visibilitychange', visibility);
@@ -276,7 +325,8 @@ onUnmounted(() => {
pill: !expanded,
urgent: state.active?.priority === 'URGENT',
call: state.active?.kind === 'call',
'resident-separated': scheme.separated && !expanded,
'resident-separated': scheme.separated && !expanded && !lesson,
'resident-lesson': !!lesson,
}"
:style="style"
:role="expanded ? 'region' : 'button'"
@@ -306,7 +356,11 @@ onUnmounted(() => {
<span class="tint" aria-hidden="true" />
<div
class="resident-page resident-compact-page"
:style="{ '--resident-scale': compactFit.scale }"
:style="{
'--resident-scale': compactFit.scale,
opacity: compactAlpha,
visibility: compactAlpha > 0 ? 'visible' : 'hidden',
}"
:class="{ 'page-active': !expanded }"
:inert="expanded"
:aria-hidden="expanded"
@@ -360,7 +414,11 @@ onUnmounted(() => {
</div>
<div
class="resident-page resident-detail-page"
:style="{ '--resident-scale': detailsFit.scale }"
:style="{
'--resident-scale': detailsFit.scale,
opacity: detailsAlpha,
visibility: detailsAlpha > 0 ? 'visible' : 'hidden',
}"
:class="{ 'page-active': expanded }"
:inert="!expanded"
:aria-hidden="!expanded"
@@ -414,6 +472,26 @@ onUnmounted(() => {
</div>
</div>
</div>
<div
class="resident-page resident-lesson-page"
:style="{
'--resident-scale': lessonFit.scale,
opacity: lessonProgress,
visibility: lessonProgress > 0 ? 'visible' : 'hidden',
}"
:aria-hidden="!lesson"
:inert="!lesson"
>
<div ref="lessonSource" class="resident-source">
<LessonReminderContent
v-if="lastLesson"
:reminder="lastLesson"
:now="now"
:appearance="appearance"
:motion="motion"
/>
</div>
</div>
</section>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
const props = defineProps<{ embedded?: boolean; suspended?: boolean }>();
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import {
DEFAULT_ISLAND_APPEARANCE,
ISLAND_SHADOW_PAD,
@@ -17,6 +17,10 @@ import {
import { SpringValue } from './spring.js';
import { squirclePath } from './squircle.js';
import ClassicNotifications from './ClassicNotifications.vue';
import LessonReminderContent from './LessonReminderContent.vue';
const now = ref(Date.now());
let clock: ReturnType<typeof setInterval>;
onUnmounted(() => clearInterval(clock));
/**
* 灵动岛渲染进程 —— 视觉与排版参考 WinIsland(Rust/Direct2D 的 Windows 动态岛)。
@@ -184,6 +188,7 @@ const EXTRA = ISLAND_SIZE_DELTA;
function targetSize(): { width: number; height: number } {
const width = appearance.value.width;
const height = appearance.value.height;
if (state.value.lessonReminder) return { width: width + EXTRA.expanded.width, height: 40 };
if (mode.value === 'hidden') {
// 有"空闲细缝"时才收成细缝;否则保持胶囊尺寸——
// 窗口此时是隐藏的,没必要缩到细缝(否则下次弹出会从细缝"长大",多出入场动画)。
@@ -200,6 +205,7 @@ function targetSize(): { width: number; height: number } {
/** 圆角:胶囊取 h/2(满圆角),展开卡取 min(48*系数, w/2, h/2)(WinIsland expanded_island_radius) */
function targetRadius(size: { width: number; height: number }): number {
if (state.value.lessonReminder) return 8;
if (mode.value !== 'expanded') return Math.min(appearance.value.radius, size.height / 2);
const scaled = (48 * appearance.value.radius) / 20;
return Math.max(0, Math.min(scaled, size.width / 2, size.height / 2));
@@ -338,9 +344,11 @@ const effectiveRadius = computed(() =>
const cardPath = computed(() => squirclePath(morph.value.width, morph.value.height, effectiveRadius.value));
/** 胶囊内容透明度:进度到 2/3 时完全退净(WinIsland MINI_FADE_RATE = 1.5) */
const pillAlpha = computed(() => Math.max(0, Math.min(1, 1 - progress.value * 1.5)));
const pillAlpha = computed(() =>
state.value.lessonReminder ? 0 : Math.max(0, Math.min(1, 1 - progress.value * 1.5)),
);
/** 展开内容透明度:进度平方(WinIsland expanded_alpha = progress²) */
const expandedAlpha = computed(() => progress.value * progress.value);
const expandedAlpha = computed(() => (state.value.lessonReminder ? 0 : progress.value * progress.value));
/**
* 岛在**固定窗口**内的定位(照搬 WinIsland:窗口不动,岛在画布内居中/贴边形变)。
@@ -378,7 +386,9 @@ const islandStyle = computed(() => {
const islandClass = computed(() => ({
pill: mode.value === 'pill' || (mode.value === 'hidden' && !appearance.value.idleSliver),
expanded: mode.value === 'expanded',
'hidden-placeholder': mode.value === 'hidden' && !appearance.value.idleSliver,
'hidden-placeholder':
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
'ci-lesson-card': !!state.value.lessonReminder,
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
urgent: isUrgent.value,
call: isCall.value,
@@ -429,7 +439,8 @@ function reportHitRect(rect: DOMRect | null): void {
hitRectTrailingTimer = null;
lastHitRectAt = performance.now();
const node = document.querySelector('.island-card') as HTMLElement | null;
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
const fullyHidden =
state.value?.mode === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder;
const latest = node && !fullyHidden ? node.getBoundingClientRect() : null;
const latestPayload = latest
? { x: latest.left, y: latest.top, width: latest.width, height: latest.height }
@@ -489,7 +500,8 @@ function updateInteractive(clientX: number, clientY: number): void {
function refreshInteractive(): void {
if (props.suspended) return;
const node = document.querySelector('.island-card') as HTMLElement | null;
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
const fullyHidden =
state.value?.mode === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder;
reportHitRect(node && !fullyHidden ? node.getBoundingClientRect() : null);
if (fullyHidden) {
// 完全隐藏:本地复位即可(不下发关闭,窗口隐藏后由主进程轮询兜底关掉命中)
@@ -520,6 +532,7 @@ watch(
() => syncTargets(true),
);
onMounted(async () => {
clock = setInterval(() => (now.value = Date.now()), 1000);
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
/**
* 触摸屏上报:主进程据此把窗口改为**贴合岛体**并始终接收输入。
@@ -653,6 +666,14 @@ onMounted(async () => {
:style="{ opacity: expandedAlpha }"
class="layer"
/>
<LessonReminderContent
v-if="state.lessonReminder"
:reminder="state.lessonReminder"
:now="now"
:appearance="appearance"
:motion="appearance.animations && !props.suspended"
class="ci-fixed-lesson"
/>
</span>
</div>
</div>
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import type { IslandAppearance, IslandLessonReminder } from '@classhelper/shared';
const props = defineProps<{
reminder: IslandLessonReminder;
now: number;
appearance: IslandAppearance;
motion: boolean;
}>();
const root = ref<HTMLElement>();
const width = ref(480);
const mask = computed(() => props.now < props.reminder.maskUntil);
const title = computed(
() => ({ prepare: '即将上课', 'on-class': '上课', break: '课间休息' })[props.reminder.kind],
);
const leftSeconds = computed(() =>
Math.max(0, Math.ceil((props.reminder.lessonStartsAt - props.now) / 1000)),
);
const progress = computed(() =>
Math.max(
0,
Math.min(
1,
(props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
),
),
);
const course = computed(() =>
props.reminder.kind === 'break' ? props.reminder.nextCourseName : props.reminder.courseName,
);
const time = computed(() =>
props.reminder.kind === 'break'
? `${props.reminder.nextStartTime || ''}–${props.reminder.nextEndTime || ''}`
: `${props.reminder.startTime}–${props.reminder.endTime}`,
);
const regions = ref([0, 0, 0, 0, 0]);
let frame = 0;
// ClassIsland SlantedMaskControl:5 个斜切区域、30°偏移、两侧向中央打开,中央向两侧关闭。
const polygons = computed(() => {
const offset = Math.tan(Math.PI / 6) * 40;
const total = width.value + offset;
const edges = [0, 0.1, 0.3, 0.7, 0.9, 1];
return regions.value.map((p, i) => {
const center = (total * (edges[i]! + edges[i + 1]!)) / 2;
const half = ((total * (edges[i + 1]! - edges[i]!) + 0.5) * p) / 2;
return `${center - half},0 ${center + half},0 ${center + half - offset},40 ${center - half - offset},40`;
});
});
function animateMask() {
cancelAnimationFrame(frame);
const target = mask.value ? 1 : 0;
if (!props.motion) {
regions.value = [target, target, target, target, target];
return;
}
const from = [...regions.value];
const started = performance.now();
const delays = mask.value ? [0, 90, 120, 90, 0] : [120, 90, 0, 90, 120];
const update = (at: number) => {
const elapsed = (at - started) * props.appearance.speed;
regions.value = from.map((value, i) => {
const p = Math.max(0, Math.min(1, (elapsed - delays[i]!) / 360));
const eased = target ? 1 - (1 - p) ** 4 : p * p;
return value + (target - value) * eased;
});
if (elapsed < 480) frame = requestAnimationFrame(update);
};
frame = requestAnimationFrame(update);
}
watch(
[mask, () => props.reminder.id, () => props.motion, () => props.appearance.speed],
() => {
if (mask.value) regions.value = [0, 0, 0, 0, 0];
animateMask();
},
{ flush: 'post' },
);
let observer: ResizeObserver | undefined;
onMounted(() => {
observer = new ResizeObserver(() => {
if (root.value) width.value = root.value.offsetWidth;
});
if (root.value) observer.observe(root.value);
void nextTick(animateMask);
});
onUnmounted(() => {
observer?.disconnect();
cancelAnimationFrame(frame);
});
</script>
<template>
<div
ref="root"
class="ci-lesson"
:class="{ 'ci-mask-active': mask }"
:data-kind="reminder.kind"
:style="{
'--ci-speed': appearance.speed,
'--ci-duration': motion ? `${200 / appearance.speed}ms` : '0ms',
'--ci-title-duration': motion ? `${250 / appearance.speed}ms` : '0ms',
'--ci-scale-duration': motion ? `${750 / appearance.speed}ms` : '0ms',
'--ci-delay': motion ? `${260 / appearance.speed}ms` : '0ms',
}"
role="status"
aria-live="polite"
>
<div class="ci-lesson-info">
<span v-if="reminder.kind === 'prepare'" class="ci-countdown">
距上课还剩
<strong>{{ leftSeconds }}</strong>
秒
</span>
<span v-else class="ci-countdown">课间休息</span>
<span class="ci-lesson-course">
<span>下节课是:</span>
<strong>{{ course }}</strong>
<small>{{ time }}</small>
</span>
</div>
<div class="ci-lesson-progress" :style="{ transform: `scaleX(${progress})` }" />
<svg class="ci-mask" :viewBox="`0 0 ${width} 40`" preserveAspectRatio="none" aria-hidden="true">
<polygon v-for="(points, i) in polygons" :key="i" :points="points" />
</svg>
<div class="ci-mask-title" :class="{ 'ci-title-in': mask }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M6 9a6 6 0 0 1 12 0v4l2 3H4l2-3V9Zm4 10a2 2 0 0 0 4 0" />
</svg>
<strong>{{ title }}</strong>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
</div>
</div>
</template>
<style scoped>
.ci-lesson {
position: relative;
width: max-content;
min-width: 300px;
max-width: 800px;
height: 40px;
overflow: hidden;
color: #fff;
font-size: 16px;
line-height: 1;
border-radius: 8px;
background: #20202080;
}
.ci-lesson-info {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 0 28px;
white-space: nowrap;
transition: opacity var(--ci-duration) ease-in;
}
.ci-mask-active .ci-lesson-info {
opacity: 0;
}
.ci-countdown {
flex-shrink: 0;
}
.ci-countdown strong {
font-variant-numeric: tabular-nums;
}
.ci-lesson-course {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.ci-lesson-course strong {
font-size: 20px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.ci-lesson-course small {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.ci-mask {
position: absolute;
inset: 0;
width: 100%;
height: 40px;
fill: var(--island-accent, #60a5fa);
pointer-events: none;
}
.ci-mask-title {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
opacity: 0;
font-size: 18px;
transform: scale(1.1);
transition:
opacity var(--ci-duration) linear,
transform var(--ci-duration) cubic-bezier(0.5, 0, 0.75, 0);
pointer-events: none;
}
.ci-mask-title svg {
width: 20px;
height: 20px;
}
.ci-title-in {
animation:
ci-title-enter var(--ci-title-duration) var(--ci-delay) cubic-bezier(0.25, 1, 0.5, 1) both,
ci-title-scale var(--ci-scale-duration) var(--ci-delay) cubic-bezier(0.25, 1, 0.5, 1) both;
}
.ci-mask-title:not(.ci-title-in) {
animation: ci-title-leave var(--ci-duration) linear both;
}
@keyframes ci-title-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ci-title-scale {
from {
transform: scale(1.1);
}
to {
transform: scale(1);
}
}
@keyframes ci-title-leave {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(1.1);
}
}
.ci-lesson-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--island-accent, #60a5fa);
transform-origin: left;
transition: transform 50ms linear;
}
</style>
@@ -84,6 +84,16 @@ body {
.island-card.hidden-placeholder {
visibility: hidden;
}
.island-card.ci-lesson-card .shape path {
fill: transparent;
stroke: none;
}
.island-card .ci-fixed-lesson {
position: absolute;
inset: 0;
width: 100%;
min-width: 0;
}
/* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */
.island-card .shape {
@@ -44,18 +44,10 @@
left: 50%;
transform: translateX(-50%);
width: max-content;
opacity: 0;
pointer-events: none;
visibility: hidden;
transition:
opacity var(--resident-fade-duration) cubic-bezier(0.47, 0, 0.745, 0.715),
visibility 0s var(--resident-fade-duration);
}
.resident-card .resident-page.page-active {
opacity: 1;
pointer-events: auto;
visibility: visible;
transition-delay: 0s;
}
.resident-card .resident-source {
width: max-content;
@@ -83,6 +75,7 @@
gap: 6px;
padding: 0 12px;
min-height: max(40px, calc(var(--island-font) * 2.5));
font-size: var(--ci-body-font, 16px);
}
.resident-window .resident-card.resident-separated {
background: transparent;
@@ -152,7 +145,7 @@
color: #ffffff80;
}
.resident-card .resident-bell span {
animation: resident-indicator-in 250ms cubic-bezier(0.25, 1, 0.5, 1);
animation: resident-indicator-in var(--resident-fade-duration) cubic-bezier(0.25, 1, 0.5, 1);
}
.resident-card .resident-details {
min-width: 160px;
@@ -183,6 +176,7 @@
display: block;
}
.resident-card .resident-expanded-rows {
font-size: var(--ci-body-font, 16px);
border-top: 1px solid #ffffff1a;
padding-top: 8px;
}
@@ -202,15 +196,6 @@
filter: brightness(1.18);
}
}
@media (prefers-reduced-motion: reduce) {
.resident-window .resident-card,
.resident-card .resident-page {
transition-duration: 0ms;
}
.resident-card .resident-bell span {
animation: none;
}
}
@keyframes resident-indicator-in {
from {
opacity: 0;
@@ -253,3 +238,15 @@
.resident-card.resident-separated > .tint {
visibility: hidden;
}
/* ClassIsland Fluent MainWindowLine:半透明底、1px 边框,强调遮罩只在提醒时播放。 */
.resident-window .resident-card {
--wn-bg: #20202080;
--wn-border: #ffffff26;
}
.resident-window .resident-card > .tint {
display: none;
}
.resident-card.resident-lesson .shape path {
fill: transparent;
stroke: none;
}