From 012830de4a494eef77386d6d0f2844468884b3d2 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Wed, 27 May 2026 14:47:29 +0800 Subject: [PATCH 1/6] 1 --- .../training/hooks/useMetronomeBg.ts | 213 ------- .../pages/components/walker-canvas.vue | 383 +++++++++++++ TUICallKit-Vue3/training/pages/metronome.vue | 532 +++++------------- 3 files changed, 514 insertions(+), 614 deletions(-) delete mode 100644 TUICallKit-Vue3/training/hooks/useMetronomeBg.ts create mode 100644 TUICallKit-Vue3/training/pages/components/walker-canvas.vue diff --git a/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts b/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts deleted file mode 100644 index 48682ddf..00000000 --- a/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { ref, onUnmounted } from 'vue' - -/** - * 节拍器后台播放专版 - 基于 BackgroundAudioManager - * - * 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制: - * - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效 - * - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API - * - * 取舍: - * - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复 - * - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍) - * 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环 - * - * 副作用: - * - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停) - * - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages) - * - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受 - */ - -export type LoopId = 'slow' | 'normal' | 'brisk' - -export interface LoopPreset { - id: LoopId - bpm: number - accent: number - src: string - label: string -} - -/** - * 重要:微信 BackgroundAudioManager.src 只接受 http/https 网络流,不能是包内资源 - * 所以必须把音频上传到 CDN(腾讯云 COS),并在小程序后台加 downloadFile 合法域名 - * - * 当前线上文件(2026-05-26 上传到 cos.ap-guangzhou): - * - loop_80bpm_2.mp3 循环音轨 慢走档 - * - loop_110bpm_2.mp3 循环音轨 健走档 - * - loop_130bpm_2.mp3 循环音轨 快走档 - * - * 历史源文件(本地 training/static/audio/*.mp3) 已删除以减小包体积, - * 如果以后要重新合成,先从 COS 下回来再用 ffmpeg 处理 - */ -export const LOOP_PRESETS: readonly LoopPreset[] = [ - { - id: 'slow', - bpm: 80, - accent: 2, - src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3', - label: '慢走 80 BPM', - }, - { - id: 'normal', - bpm: 110, - accent: 2, - src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3', - label: '健走 110 BPM', - }, - { - id: 'brisk', - bpm: 130, - accent: 2, - src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3', - label: '快走 130 BPM', - }, -] - -/** - * 锁屏控制条封面 - * 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里 - * 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名 - */ -const COVER_URL = '' - -export function useMetronomeBg() { - const isPlaying = ref(false) - const currentLoop = ref(null) - - /* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */ - let bgm: any = null - /* 当前期望的 src,onEnded 时用它重新赋值实现循环 */ - let desiredSrc = '' - - /* 懒初始化 BackgroundAudioManager + 绑定事件 - BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */ - const ensureBgm = () => { - if (bgm) return bgm - - const m = uni.getBackgroundAudioManager() - - /* 必填 metadata,缺一会报错或不显示锁屏控制条 */ - m.title = '节拍器' - m.epname = '甄养堂 · 健走' - m.singer = '健走配速' - if (COVER_URL) m.coverImgUrl = COVER_URL - m.webUrl = '' - - m.onPlay(() => { - isPlaying.value = true - }) - m.onPause(() => { - /* 用户从锁屏控制条点暂停,同步 UI 状态 */ - isPlaying.value = false - }) - m.onStop(() => { - isPlaying.value = false - currentLoop.value = null - }) - - /* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放 - BgAudio 没有原生 loop 属性,只能用这招 */ - m.onEnded(() => { - if (desiredSrc && isPlaying.value) { - try { - m.src = desiredSrc - } catch (_) {} - } - }) - - m.onError((err) => { - console.error('[BgAudio] error:', err) - isPlaying.value = false - uni.showToast({ - title: '音频播放失败,请重试', - icon: 'none', - duration: 2000, - }) - }) - - bgm = m - return m - } - - /** - * 切到指定档位并开始播放 - * 如果已经在播同一档位 → 切到 pause/play 状态 - * 如果在播别的档位 → 切换 src(有 200~500ms 延迟) - */ - const playLoop = (id: LoopId) => { - const preset = LOOP_PRESETS.find((p) => p.id === id) - if (!preset) return - - const m = ensureBgm() - - /* 同档位再点一下 = 暂停 */ - if (currentLoop.value === id && isPlaying.value) { - m.pause() - return - } - - /* 切换档位或从暂停恢复 */ - currentLoop.value = id - desiredSrc = preset.src - - /* 重设 title 让锁屏控制条显示当前档位 */ - m.title = `节拍器 · ${preset.label}` - - /* 赋值 src 会自动播放(微信 API 设计如此) */ - m.src = preset.src - /* isPlaying 由 onPlay 回调置 true */ - } - - const pause = () => { - if (bgm && isPlaying.value) { - bgm.pause() - } - } - - const resume = () => { - if (bgm && !isPlaying.value && desiredSrc) { - /* 从暂停态恢复:直接 play 即可 */ - try { - bgm.play() - } catch (_) { - /* 部分基础库 play() 不可用时,重赋 src */ - bgm.src = desiredSrc - } - } - } - - /** - * 完全停止 + 清掉锁屏控制条 - * 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例 - */ - const stop = () => { - if (bgm) { - try { - bgm.stop() - } catch (_) {} - } - currentLoop.value = null - desiredSrc = '' - isPlaying.value = false - } - - /* hook 卸载时不主动 stop,因为用户离开节拍器页时 - 仍希望音乐持续(走在路上拿出手机切别的页面应该不停) - 真正停止的责任在 metronome.vue 的退出按钮里 */ - onUnmounted(() => { - /* 仅解绑回调? BgAudio 是全局单例,我们的回调还在, - 不解会导致内存中保留无用引用,但回调里都判断了 isPlaying, - 且新页面再 ensureBgm 时会覆盖回调,可接受 */ - }) - - return { - isPlaying, - currentLoop, - playLoop, - pause, - resume, - stop, - LOOP_PRESETS, - } -} diff --git a/TUICallKit-Vue3/training/pages/components/walker-canvas.vue b/TUICallKit-Vue3/training/pages/components/walker-canvas.vue new file mode 100644 index 00000000..ce94a184 --- /dev/null +++ b/TUICallKit-Vue3/training/pages/components/walker-canvas.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/TUICallKit-Vue3/training/pages/metronome.vue b/TUICallKit-Vue3/training/pages/metronome.vue index e62df55e..0feaead4 100644 --- a/TUICallKit-Vue3/training/pages/metronome.vue +++ b/TUICallKit-Vue3/training/pages/metronome.vue @@ -1,47 +1,13 @@ @@ -359,166 +217,19 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); } /* ============================================================ - * 节拍器主舞台 + * 统一运动律动视区 (Canvas) * ============================================================ */ -.stage { - position: relative; - width: 480rpx; - height: 480rpx; - margin-top: 12rpx; - display: flex; - align-items: center; - justify-content: center; - - &:active .core { - transform: scale(0.97); - } -} - -.ring { - position: absolute; - width: 100%; - height: 100%; - border-radius: 50%; - border: 3rpx solid rgba(16, 185, 129, 0.12); - pointer-events: none; - - &.playing { - animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite; - } - &.r2.playing { - animation-delay: calc(var(--beat-duration, 750ms) * -0.5); - } -} -@keyframes ring-pulse { - 0% { - transform: scale(0.94); - opacity: 0.85; - border-color: rgba(16, 185, 129, 0.4); - } - 100% { - transform: scale(1.18); - opacity: 0; - border-color: rgba(16, 185, 129, 0); - } -} - -.core { - position: relative; - width: 380rpx; - height: 380rpx; - border-radius: 50%; - background: linear-gradient(140deg, #34d399 0%, $brand-deep 100%); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-shadow: - inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18), - inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18), - 0 16rpx 40rpx rgba(16, 185, 129, 0.32); - transition: transform 0.12s, background 0.18s, box-shadow 0.18s; - - &.playing { - animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite; - } - - .bpm-num { - font-size: 168rpx; - font-weight: 800; - color: #fff; - line-height: 1; - letter-spacing: -2rpx; - font-variant-numeric: tabular-nums; - text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.16); - } - - .bpm-label { - font-size: 22rpx; - color: rgba(255, 255, 255, 0.7); - letter-spacing: 6rpx; - margin-top: 10rpx; - font-weight: 600; - } - - .ctrl { - margin-top: 22rpx; - height: 64rpx; - display: flex; - align-items: center; - justify-content: center; - } -} -@keyframes core-beat { - 0%, - 100% { - transform: scale(1); - } - 50% { - transform: scale(1.04); - } -} - -/* 播放/暂停 icon */ -.icon-play { - width: 0; - height: 0; - border-left: 52rpx solid #fff; - border-top: 32rpx solid transparent; - border-bottom: 32rpx solid transparent; - margin-left: 12rpx; - filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18)); -} -.icon-pause { - display: flex; - gap: 16rpx; - height: 60rpx; - - .bar { - width: 16rpx; - height: 100%; - background: #fff; - border-radius: 4rpx; - box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18); - } -} - -/* ============================================================ - * 跑步小人 - * ============================================================ */ -.walker { - width: 100%; - height: 240rpx; +.stage-canvas-wrapper { + width: 100vw; + margin-left: -28rpx; + margin-right: -28rpx; + flex-shrink: 0; /* 防止被压缩 */ + height: 600rpx; /* 固定高度,不随内容变化 */ display: flex; justify-content: center; align-items: center; -} - -.walker-box { + margin-bottom: 24rpx; position: relative; - width: 240rpx; - height: 240rpx; - background: #e8ecf2; - border-radius: 24rpx; - overflow: hidden; - box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06); -} - -.runner-video { - width: 100%; - height: 100%; - background: #e8ecf2; -} - -.pip-mask { - position: absolute; - top: 0; - right: 0; - width: 80rpx; - height: 56rpx; - background-color: #dde2eb; - border-bottom-left-radius: 18rpx; - border-top-right-radius: 24rpx; } /* ============================================================ @@ -535,19 +246,14 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); background: $card-bg; border: 2rpx solid $card-border; border-radius: $radius-card; - padding: 18rpx 8rpx 16rpx; + padding: 26rpx 8rpx; display: flex; flex-direction: column; align-items: center; - gap: 4rpx; + gap: 8rpx; box-shadow: $shadow-sm; transition: all 0.18s; - .preset-icon { - font-size: 40rpx; - line-height: 1; - margin-bottom: 4rpx; - } .preset-name { font-size: 28rpx; font-weight: 700; @@ -591,9 +297,32 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); } /* ============================================================ - * 自定义节奏面板(仅 customExpanded 时显示) + * 自定义节奏区域(标题栏 + 折叠内容) * ============================================================ */ -.custom-panel { +.custom-section { + width: 100%; + margin-top: 12rpx; +} + +.custom-header { + width: 100%; + padding: 16rpx 6rpx; + display: flex; + align-items: center; + justify-content: center; + + .custom-title { + font-size: 22rpx; + color: $text-3; + letter-spacing: 0.5rpx; + + &:active { + color: $text-2; + } + } +} + +.custom-content { width: 100%; background: $card-bg; border: 2rpx solid rgba(245, 158, 11, 0.25); @@ -603,6 +332,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); display: flex; flex-direction: column; gap: 16rpx; + margin-top: 8rpx; } .custom-warn { @@ -705,7 +435,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); } /* ============================================================ - * 底部行(自定义入口 + 锁屏提示,横向排布) + * 底部行(已废弃,保留样式以防引用) * ============================================================ */ .bottom-row { width: 100%; From 261f8315de5e38d0cd61a32a10a58b03ab68f5f3 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Wed, 27 May 2026 16:52:41 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(training):=20=E6=8F=A1=E5=8A=9B?= =?UTF-8?q?=E7=8E=AF=E8=AE=AD=E7=BB=83=E8=B6=A3=E5=91=B3=E5=8C=96=20-=20?= =?UTF-8?q?=E6=8D=8F=E7=A2=8E=E7=89=B9=E6=95=88=20+=20=E5=8D=A1=E8=B7=AF?= =?UTF-8?q?=E9=87=8C=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 ### 1. Canvas 粒子捏碎特效 - 新建 crush-canvas.vue 组件(透明叠加层) - 4 种捏碎物品:鸡蛋🥚、核桃🥜、易拉罐🥫、气球🎈 - 粒子物理模拟:径向扩散 + 重力 + 空气阻力 + 生命值衰减 - 性能优化:固定 60fps 时间步长,粒子边界清理 ### 2. 随机触发逻辑 - 平均每 8-12 次随机触发捏碎特效 - 在握力环压缩峰值(scale 0.65)时触发 - 随机选择 4 种物品之一 - 资源清理:训练暂停/结束时清理所有定时器 ### 3. 卡路里统计与完成页面 - 标准 MET 公式计算卡路里(MET = 3.5,默认 60kg) - 5 种食物自动匹配(苹果、鸡蛋、巧克力、香蕉、米饭) - 完成页面显示:组数、次数、时长、卡路里、食物对比 - 样式设计:浅色清爽风格,绿色渐变卡片 ### 4. 预留接口 - useTTS.ts:预留 MiMo TTS 语音鼓励接口 ## 技术实现 - Canvas 粒子系统:20-30 个粒子,物理模拟流畅 - 类型安全:完整的 TypeScript 类型定义 - 性能优化:固定时间步长、边界清理、资源管理 - 响应式布局:适配不同屏幕尺寸 ## 相关任务 Task: .trellis/tasks/05-27-grip-ring-gamification PRD: prd.md Research: research/calorie-calculation.md Co-Authored-By: Claude Opus 4.7 --- TUICallKit-Vue3/training/hooks/useTTS.ts | 32 +++ .../training/hooks/useTrainingSession.ts | 79 +++++- .../pages/components/crush-canvas.vue | 233 ++++++++++++++++++ .../pages/components/exercise-anim.vue | 29 ++- TUICallKit-Vue3/training/pages/index.vue | 121 ++++++++- TUICallKit-Vue3/training/utils/calorie.ts | 115 +++++++++ 6 files changed, 601 insertions(+), 8 deletions(-) create mode 100644 TUICallKit-Vue3/training/hooks/useTTS.ts create mode 100644 TUICallKit-Vue3/training/pages/components/crush-canvas.vue create mode 100644 TUICallKit-Vue3/training/utils/calorie.ts diff --git a/TUICallKit-Vue3/training/hooks/useTTS.ts b/TUICallKit-Vue3/training/hooks/useTTS.ts new file mode 100644 index 00000000..8ebbb07e --- /dev/null +++ b/TUICallKit-Vue3/training/hooks/useTTS.ts @@ -0,0 +1,32 @@ +/** + * TTS 语音鼓励 Hook + * 用于在捏碎特效触发时播放语音鼓励 + * + * 当前状态:预留接口,待集成 MiMo TTS + * TODO: 集成 MiMo TTS SDK + */ + +export function useTTS() { + /** + * 播放语音鼓励 + * 随机播放:"加油"、"太棒了"、"继续"、"很好"等 + * + * 实现计划: + * 1. 引入 MiMo TTS SDK + * 2. 配置语音库(鼓励短语列表) + * 3. 随机选择短语并播放 + */ + const playEncouragement = () => { + // TODO: 集成 MiMo TTS + // 示例实现: + // const phrases = ['加油', '太棒了', '继续', '很好', '坚持'] + // const phrase = phrases[Math.floor(Math.random() * phrases.length)] + // mimoTTS.speak(phrase) + + console.log('[TTS] 播放语音鼓励(待实现 MiMo TTS 集成)') + } + + return { + playEncouragement + } +} diff --git a/TUICallKit-Vue3/training/hooks/useTrainingSession.ts b/TUICallKit-Vue3/training/hooks/useTrainingSession.ts index f33499b8..acdc5df0 100644 --- a/TUICallKit-Vue3/training/hooks/useTrainingSession.ts +++ b/TUICallKit-Vue3/training/hooks/useTrainingSession.ts @@ -1,7 +1,11 @@ import { ref, computed, onUnmounted } from 'vue' +import { calculateCalories, getFoodComparison } from '../utils/calorie' +import type { FoodComparison } from '../utils/calorie' export type SessionPhase = 'idle' | 'training' | 'resting' | 'done' +export type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon' + export interface TrainingSessionConfig { sets: number reps: number @@ -13,9 +17,35 @@ export interface TrainingSessionConfig { onExitRest?: () => void onDone?: () => void onCountdownTick?: (remainingSec: number) => void + onCrushTrigger?: (itemType: CrushItemType) => void } -export function useTrainingSession() { +export interface TrainingStats { + totalSets: number + totalReps: number + durationSeconds: number + calories: number + foodComparison: FoodComparison +} + +// 捏碎特效配置 +const CRUSH_TRIGGER_INTERVAL_MIN = 8 // 最小触发间隔 +const CRUSH_TRIGGER_INTERVAL_MAX = 12 // 最大触发间隔 +const CRUSH_ITEMS: CrushItemType[] = ['egg', 'walnut', 'can', 'balloon'] + +// 随机触发判断(平均每 8-12 次触发一次) +function shouldTriggerCrush(): boolean { + // 使用随机间隔的平均值:(8 + 12) / 2 = 10 + const avgInterval = (CRUSH_TRIGGER_INTERVAL_MIN + CRUSH_TRIGGER_INTERVAL_MAX) / 2 + return Math.random() < (1 / avgInterval) +} + +// 随机选择物品 +function randomCrushItem(): CrushItemType { + return CRUSH_ITEMS[Math.floor(Math.random() * CRUSH_ITEMS.length)] +} + +export function useTrainingSession(bpm?: number) { const phase = ref('idle') const currentSet = ref(0) const currentRep = ref(0) @@ -24,6 +54,9 @@ export function useTrainingSession() { let config: TrainingSessionConfig | null = null let restTimer: ReturnType | null = null + let crushTimers: ReturnType[] = [] // 存储所有捏碎特效的 setTimeout + let currentBpm = bpm ?? 80 + let startTime = 0 // 训练开始时间戳 const totalReps = computed(() => config?.reps ?? 0) const totalSets = computed(() => config?.sets ?? 0) @@ -33,6 +66,10 @@ export function useTrainingSession() { const isResting = computed(() => phase.value === 'resting') const isDone = computed(() => phase.value === 'done') + const setBpm = (newBpm: number) => { + currentBpm = newBpm + } + const start = (cfg: TrainingSessionConfig) => { config = cfg phase.value = 'training' @@ -40,6 +77,27 @@ export function useTrainingSession() { currentRep.value = 0 beatCounter.value = 0 restRemainingSec.value = 0 + startTime = Date.now() // 记录开始时间 + } + + // 获取训练统计数据 + const getStats = (): TrainingStats => { + const durationSeconds = Math.floor((Date.now() - startTime) / 1000) + const actualTotalReps = (config?.sets ?? 0) * (config?.reps ?? 0) + + const calories = calculateCalories({ + totalReps: actualTotalReps, + bpm: currentBpm, + userWeight: 60, // 默认 60kg + }) + + return { + totalSets: config?.sets ?? 0, + totalReps: actualTotalReps, + durationSeconds, + calories, + foodComparison: getFoodComparison(calories), + } } const onBeat = () => { @@ -52,6 +110,17 @@ export function useTrainingSession() { currentRep.value++ config.onRepComplete?.(currentRep.value, totalReps.value) + // 随机触发捏碎特效 + if (shouldTriggerCrush() && config.onCrushTrigger) { + const item = randomCrushItem() + // 延迟到动画峰值(50%)触发 + const repDurationMs = (60000 / currentBpm) * beatsPerRep.value + const timer = setTimeout(() => { + config?.onCrushTrigger?.(item) + }, repDurationMs / 2) + crushTimers.push(timer) + } + if (currentRep.value >= totalReps.value) { config.onSetComplete?.(currentSet.value, totalSets.value) @@ -104,6 +173,10 @@ export function useTrainingSession() { clearInterval(restTimer) restTimer = null } + // 清理所有待触发的捏碎特效 + crushTimers.forEach(timer => clearTimeout(timer)) + crushTimers = [] + phase.value = 'idle' currentSet.value = 0 currentRep.value = 0 @@ -113,6 +186,8 @@ export function useTrainingSession() { onUnmounted(() => { if (restTimer) clearInterval(restTimer) + crushTimers.forEach(timer => clearTimeout(timer)) + crushTimers = [] }) return { @@ -129,5 +204,7 @@ export function useTrainingSession() { stop, skipRest, onBeat, + setBpm, + getStats, } } diff --git a/TUICallKit-Vue3/training/pages/components/crush-canvas.vue b/TUICallKit-Vue3/training/pages/components/crush-canvas.vue new file mode 100644 index 00000000..2681af98 --- /dev/null +++ b/TUICallKit-Vue3/training/pages/components/crush-canvas.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/TUICallKit-Vue3/training/pages/components/exercise-anim.vue b/TUICallKit-Vue3/training/pages/components/exercise-anim.vue index c53b4d77..00487b4a 100644 --- a/TUICallKit-Vue3/training/pages/components/exercise-anim.vue +++ b/TUICallKit-Vue3/training/pages/components/exercise-anim.vue @@ -24,9 +24,12 @@ - - - {{ icon }} + + + + {{ icon }} + + @@ -38,8 +41,9 @@ ,import './Card.css',Low, +15,Styling,Use scoped styles by default,Astro scopes styles to component automatically, (scoped),,,Medium, +17,Styling,Integrate Tailwind properly,Use @astrojs/tailwind integration,Official Tailwind integration,Manual Tailwind setup,npx astro add tailwind,Manual PostCSS config,Low,https://docs.astro.build/en/guides/integrations-guide/tailwind/ +18,Styling,Use CSS variables for theming,Define tokens in :root,CSS custom properties for themes,Hardcoded colors everywhere,:root { --primary: #3b82f6; },color: #3b82f6; everywhere,Medium, +19,Data,Fetch in frontmatter,Data fetching in component frontmatter,Top-level await in frontmatter,useEffect for initial data,const data = await fetch(url),client-side fetch on mount,High,https://docs.astro.build/en/guides/data-fetching/ +20,Data,Use Astro.glob for local files,Import multiple local files,Astro.glob for markdown/data files,Manual imports for each file,const posts = await Astro.glob('./posts/*.md'),"import post1; import post2;",Medium, +21,Data,Prefer content collections over glob,Type-safe collections for structured content,getCollection() for blog/docs,Astro.glob for structured content,await getCollection('blog'),await Astro.glob('./blog/*.md'),High, +22,Data,Use environment variables correctly,Import.meta.env for env vars,PUBLIC_ prefix for client vars,Expose secrets to client,import.meta.env.PUBLIC_API_URL,import.meta.env.SECRET in client,High,https://docs.astro.build/en/guides/environment-variables/ +23,Performance,Preload critical assets,Use link preload for important resources,Preload fonts above-fold images,No preload hints,"",No preload for critical assets,Medium, +24,Performance,Optimize images with astro:assets,Built-in image optimization, component for optimization, for local images,"import { Image } from 'astro:assets';","",High,https://docs.astro.build/en/guides/images/ +25,Performance,Use picture for responsive images,Multiple formats and sizes, for art direction,Single image size for all screens, with multiple sources, with single size,Medium, +26,Performance,Lazy load below-fold content,Defer loading non-critical content,loading=lazy for images client:visible for components,Load everything immediately,"",No lazy loading,Medium, +27,Performance,Minimize client directives,Each directive adds JS bundle,Audit client: usage regularly,Sprinkle client:load everywhere,Only interactive components hydrated,Every component with client:load,High, +28,ViewTransitions,Enable View Transitions,Smooth page transitions, in head,Full page reloads,"import { ViewTransitions } from 'astro:transitions';",No transition API,Medium,https://docs.astro.build/en/guides/view-transitions/ +29,ViewTransitions,Use transition:name,Named elements for morphing,transition:name for persistent elements,Unnamed transitions,"
",
without name,Low, +30,ViewTransitions,Handle transition:persist,Keep state across navigations,transition:persist for media players,Re-initialize on every navigation,"