Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/dancer-asset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# BusyWeek dancer asset

`src/assets/busyweek-dancer.png` is an original 4×4 alarm-clock mascot sprite sheet generated for BusyWeek with OpenAI's built-in image generation tool on 2026-07-17. It does not copy the character or sprite from `Huxpro/lynx-pretext`.

- Source dimensions after optimization: 768 × 768 RGBA PNG
- Shipped size: approximately 416 KB
- Animation cadence: 12 fps; one presentation is capped at four seconds
- Redistribution: project-owned generated asset, distributed under BusyWeek's ISC license

The solid chroma background was removed locally. The transparent sheet is translated inside a clipped viewport; no 60 fps video or per-frame asset decode is used.
45 changes: 45 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,50 @@
linear-direction: column;
width: 100%;
}
.dancer-timeline-layer {
position: absolute;
top: 58px;
left: 50%;
width: 160px;
height: 160px;
margin-left: -80px;
z-index: 4;
pointer-events: none;
}
.dancer-sprite-window {
position: relative;
width: 160px;
height: 160px;
overflow: hidden;
}
.dancer-sprite-sheet {
position: absolute;
top: 0;
left: 0;
width: 640px;
height: 640px;
transform-origin: top left;
}
.dancer-todo-text { width: 100%; flex-shrink: 0; }
.dancer-todo-band {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
height: 20px;
}
.dancer-todo-line {
font-size: 15px;
line-height: 20px;
color: #2b2f33;
white-space: nowrap;
overflow: hidden;
}
.dancer-todo-line--right { text-align: right; }
.dancer-todo-text.todo-text--done .dancer-todo-line {
color: #c2c9d0;
text-decoration: line-through;
}
.day-list {
position: relative;
width: 100%;
Expand Down Expand Up @@ -318,6 +362,7 @@
}
.todo-body:active .todo-text { opacity: 0.68; }
.todo-text--done { color: #c2c9d0; text-decoration: line-through; }
.todo-text--dancer-hidden { opacity: 0; }
.todo-input {
width: 100%;
height: 100%;
Expand Down
142 changes: 138 additions & 4 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from 'vue-lynx'
import {
clearTodoTextMeasurementCache,
type DancerCopyBand,
layoutDancerCopy,
measureTodoText,
supportsRendererLayoutCorrection,
} from '@busyweek/text-layout-backend'
Expand All @@ -25,7 +27,11 @@ import {
} from './globalEventBinding.js'
import { createStarterTimeline } from './starterTimeline.js'
import { loadTimeline, saveTimeline } from './store.js'
import { createTimelineMotionLayout } from './timelineMotion.js'
import {
DAY_GAP,
DAY_HEADER_HEIGHT,
createTimelineMotionLayout,
} from './timelineMotion.js'
import { keepTodoEditAboveKeyboard } from './todoKeyboardAvoidance.js'
import {
TODO_MIN_ROW_HEIGHT,
Expand All @@ -50,6 +56,15 @@ import {
} from './util.js'
import DatePickerSheet from './components/DatePickerSheet.vue'
import DayPickerSheet from './components/DayPickerSheet.vue'
import dancerSprite from './assets/busyweek-dancer.png'
import {
DANCER_FPS,
DANCER_LOOP_MS,
createTapSequenceArbiter,
dancerFrameAt,
dancerIntervalsForText,
spriteTransform,
} from './dancerEffect.js'

type AppState = 'LIST' | 'INPUT'
type TodoTextLayoutBinding = {
Expand Down Expand Up @@ -78,6 +93,13 @@ const composerSubmitLabel = computed(() =>
// cross-platform pickers (built from Lynx primitives — work on web + native)
const dayPickerOpen = ref(false)
const datePickerOpen = ref(false)
const dancerActive = ref(false)
const dancerFrame = ref(0)
const dancerPaused = ref(false)
const prefersReducedMotion = ref(false)
let dancerStartedAt = 0
let dancerTimer: ReturnType<typeof setInterval> | undefined
let dancerDismissTimer: ReturnType<typeof setTimeout> | undefined

// soft-keyboard height (device-independent px), used to lift the composer's
// bottom bar clear of the keyboard on native. The input element event works
Expand Down Expand Up @@ -193,6 +215,7 @@ function bindGlobalEvents() {
listeners: [
['keyboardstatuschanged', onKeyboardStatus],
[BUSYWEEK_TODO_LONG_PRESS_EVENT, onWebTodoLongPress],
['appstatuschanged', onAppStatusChanged],
],
})
} catch {
Expand All @@ -207,9 +230,15 @@ onMounted(async () => {
measureTodoWidthProbe()
const stored = await loadTimeline()
timeline.value = stored ?? createStarterTimeline(getTodayDate())
try {
const matcher = (globalThis as unknown as { matchMedia?: (query: string) => { matches: boolean } }).matchMedia?.('(prefers-reduced-motion: reduce)')
prefersReducedMotion.value = matcher?.matches === true
} catch { /* Native runtimes do not expose matchMedia. */ }
})

onUnmounted(() => {
stopDancer()
subtitleTapSequence.dispose()
removeGlobalEventListeners?.()
lastTodoLayoutHeights.clear()
clearTodoTextMeasurementCache()
Expand Down Expand Up @@ -314,6 +343,72 @@ const emptyHint = computed(() =>
? '打开右上角查看已完成'
: '点右下角 + 添加事项吧',
)
const dancerSpriteStyle = computed(() => ({ transform: spriteTransform(dancerFrame.value) }))
type DancerTodoLayout = {
bands: DancerCopyBand[]
intervals: readonly { left: number; right: number }[]
}
const dancerTodoLayouts = computed(() => {
const layouts: Record<string, DancerTodoLayout> = {}
if (!dancerActive.value) return layouts
for (const day of visibleDays.value) {
const dayLayout = motionLayout.value.days[day.key]
if (!dayLayout) continue
for (const todo of day.todos) {
const measurement = measureTodoText(todo.text, todoTextWidth.value)
const lineCount = measurement?.lineCount ?? 1
const rowTop = dayLayout.offset + DAY_GAP + DAY_HEADER_HEIGHT + (dayLayout.todoOffsets[todo.id] ?? 0)
const textTop = rowTop + 8
const geometry = dancerIntervalsForText(
textTop,
lineCount,
20,
dancerFrame.value,
todoTextWidth.value,
)
if (!geometry.affected) continue
layouts[todo.id] = {
intervals: geometry.intervals,
bands: layoutDancerCopy(todo.text, geometry.intervals, todoTextWidth.value),
}
}
}
return layouts
})
function dancerTodoLeftStyle(todoId: string, index: number) {
const left = dancerTodoLayouts.value[todoId]?.intervals[index]?.left ?? 1
return { width: `${left * 100}%` }
}
function dancerTodoRightStyle(todoId: string, index: number) {
const right = dancerTodoLayouts.value[todoId]?.intervals[index]?.right ?? 1
return { width: `${(1 - right) * 100}%` }
}

function stopDancer() {
dancerActive.value = false
if (dancerTimer) clearInterval(dancerTimer)
if (dancerDismissTimer) clearTimeout(dancerDismissTimer)
dancerTimer = undefined
dancerDismissTimer = undefined
}
function startDancer() {
if (dancerActive.value) { stopDancer(); return }
dancerActive.value = true
dancerStartedAt = Date.now()
dancerFrame.value = 0
if (!prefersReducedMotion.value) {
dancerTimer = setInterval(() => {
if (!dancerPaused.value) dancerFrame.value = dancerFrameAt(Date.now() - dancerStartedAt)
}, 1000 / DANCER_FPS)
}
dancerDismissTimer = setTimeout(stopDancer, DANCER_LOOP_MS)
}
function onAppStatusChanged(status: unknown) {
const value = typeof status === 'string' ? status : (status as { status?: unknown } | null)?.status
dancerPaused.value = value === 'background' || value === 'inactive' || value === 'hide'
}
const subtitleTapSequence = createTapSequenceArbiter(startDancer)
function onSubtitleTap() { subtitleTapSequence.tap() }

// --- helpers exposed to the template ---------------------------------------
function isToday(dateStr: string): boolean {
Expand Down Expand Up @@ -743,7 +838,10 @@ function removeTodo(dayKey: string, id: string) {
<view class="app-bar">
<view class="brand">
<text class="bw-text logo">BusyWeek!</text>
<text class="bw-text logo-accent">好忙啊</text>
<text
class="bw-text logo-accent"
@tap.stop="onSubtitleTap"
>好忙啊</text>
</view>
<view
class="completed-toggle"
Expand Down Expand Up @@ -775,6 +873,16 @@ function removeTodo(dayKey: string, id: string) {
:scroll-y="true"
>
<view class="timeline-content">
<view
v-if="dancerActive"
class="dancer-timeline-layer"
accessibility-label="BusyWeek 跳舞彩蛋"
user-interaction-enabled="false"
>
<view class="dancer-sprite-window" accessibility-element="false">
<image class="dancer-sprite-sheet" :src="dancerSprite" :style="dancerSpriteStyle" />
</view>
</view>
<!-- Hidden exact-geometry row: its body is the real text column width,
independent of screen width and responsive timeline sizing. -->
<view
Expand Down Expand Up @@ -865,19 +973,45 @@ function removeTodo(dayKey: string, id: string) {
@tap.stop="startEdit(todo)"
@longpress.stop="openTodoEditor(day.key, todo)"
>
<view
v-if="editingId !== todo.id && dancerTodoLayouts[todo.id]"
class="dancer-todo-text"
:class="{ 'todo-text--done': todo.done }"
>
<view
v-for="(band, bandIndex) in dancerTodoLayouts[todo.id].bands"
:key="bandIndex"
class="dancer-todo-band"
>
<text
class="bw-text dancer-todo-line"
:style="dancerTodoLeftStyle(todo.id, bandIndex)"
>{{ band.left?.text }}</text>
<text
class="bw-text dancer-todo-line dancer-todo-line--right"
:style="dancerTodoRightStyle(todo.id, bandIndex)"
>{{ band.right?.text }}</text>
</view>
</view>
<text
v-if="editingId !== todo.id && supportsRendererLayoutCorrection"
class="bw-text todo-text"
:key="getTodoTextLayoutBinding(todo.id).key"
:class="{ 'todo-text--done': todo.done }"
:class="{
'todo-text--done': todo.done,
'todo-text--dancer-hidden': dancerTodoLayouts[todo.id],
}"
@layout="getTodoTextLayoutBinding(todo.id).onLayout"
>{{ todo.text }}</text
>
<text
v-else-if="editingId !== todo.id"
class="bw-text todo-text"
:key="getTodoTextLayoutBinding(todo.id).key"
:class="{ 'todo-text--done': todo.done }"
:class="{
'todo-text--done': todo.done,
'todo-text--dancer-hidden': dancerTodoLayouts[todo.id],
}"
>{{ todo.text }}</text
>
<textarea
Expand Down
Binary file added src/assets/busyweek-dancer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
91 changes: 91 additions & 0 deletions src/dancerEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export const DANCER_FRAME_COUNT = 16
export const DANCER_FPS = 12
export const DANCER_LOOP_MS = 4_000
export const DANCER_TAP_WINDOW_MS = 600

export type DancerInterval = { left: number; right: number }
export const DANCER_TIMELINE_TOP = 58
export const DANCER_SIZE = 160

// Each frame has six horizontal silhouette bands. Values are normalized to
// the copy column, keeping the geometry deterministic on every viewport.
const BASE_PROFILES: readonly (readonly DancerInterval[])[] = [
[{left:.39,right:.61},{left:.31,right:.69},{left:.27,right:.73},{left:.29,right:.71},{left:.36,right:.64},{left:.42,right:.58}],
[{left:.42,right:.60},{left:.34,right:.66},{left:.29,right:.70},{left:.32,right:.69},{left:.40,right:.66},{left:.45,right:.72}],
[{left:.40,right:.60},{left:.32,right:.68},{left:.25,right:.75},{left:.29,right:.71},{left:.34,right:.66},{left:.39,right:.61}],
[{left:.38,right:.62},{left:.29,right:.71},{left:.25,right:.74},{left:.31,right:.69},{left:.40,right:.65},{left:.47,right:.59}],
]

export function dancerFrameAt(elapsedMs: number, reducedMotion = false): number {
if (reducedMotion || !Number.isFinite(elapsedMs) || elapsedMs <= 0) return 0
return Math.floor(elapsedMs / (1000 / DANCER_FPS)) % DANCER_FRAME_COUNT
}

export function dancerProfile(frame: number): readonly DancerInterval[] {
const safeFrame = Number.isFinite(frame) ? Math.max(0, Math.floor(frame)) : 0
const base = BASE_PROFILES[safeFrame % BASE_PROFILES.length]
const drift = ((safeFrame % 8) - 3.5) * .008
return base.map(({ left, right }) => ({ left: left + drift, right: right + drift }))
}

export function dancerIntervalsForText(
textTop: number,
lineCount: number,
lineHeight: number,
frame: number,
textWidth = 320,
): { affected: boolean; intervals: DancerInterval[] } {
const profile = dancerProfile(frame)
let affected = false
const intervals = Array.from({ length: Math.max(1, lineCount) }, (_, index) => {
const lineCenter = textTop + index * lineHeight + lineHeight / 2
const relativeY = lineCenter - DANCER_TIMELINE_TOP
if (relativeY < 0 || relativeY >= DANCER_SIZE) return { left: 1, right: 1 }
affected = true
const band = Math.min(
profile.length - 1,
Math.floor(relativeY / (DANCER_SIZE / profile.length)),
)
const interval = profile[band]
// Profiles are authored against the 320px timeline stage. Keep their
// silhouette width in pixels when the measured Todo body is narrower or
// wider, while preserving its center alignment with the dancer layer.
const scale = 320 / Math.max(1, textWidth)
return {
left: Math.max(0, .5 + (interval.left - .5) * scale),
right: Math.min(1, .5 + (interval.right - .5) * scale),
}
})
return { affected, intervals }
}

export function spriteTransform(frame: number): string {
const safe = ((Math.floor(frame) % DANCER_FRAME_COUNT) + DANCER_FRAME_COUNT) % DANCER_FRAME_COUNT
return `translate(${-25 * (safe % 4)}%, ${-25 * Math.floor(safe / 4)}%)`
}

export function createTapSequenceArbiter(
onWin: () => void,
requiredTaps = 2,
windowMs = DANCER_TAP_WINDOW_MS,
now: () => number = Date.now,
) {
let tapCount = 0
let lastTapAt = Number.NEGATIVE_INFINITY
return {
tap() {
const tappedAt = now()
tapCount = tappedAt - lastTapAt <= windowMs ? tapCount + 1 : 1
lastTapAt = tappedAt
if (tapCount < requiredTaps) return false
tapCount = 0
lastTapAt = Number.NEGATIVE_INFINITY
onWin()
return true
},
dispose() {
tapCount = 0
lastTapAt = Number.NEGATIVE_INFINITY
},
}
}
Loading