Browse Source

docs: add homepage fluid canvas design spec

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
beauty
npmrun 2 months ago
parent
commit
2586f5c540
  1. 1
      app/assets/css/main.css
  2. 368
      app/pages/discover/index.vue
  3. 367
      app/pages/index/index.vue
  4. 109
      docs/superpowers/specs/2026-05-20-homepage-fluid-canvas-design.md
  5. 9
      nuxt.config.ts

1
app/assets/css/main.css

@ -1,5 +1,4 @@
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Abril+Fatface&display=swap');
@theme {
--color-canvas: #faf9f5;

368
app/pages/discover/index.vue

@ -0,0 +1,368 @@
<script setup lang="ts">
interface CardItem {
id: number
type: 'text' | 'image' | 'image-text' | 'portfolio' | 'project'
image?: string
images?: string[]
title: string
description?: string
tags?: string[]
aspectRatio: number
}
const { capture, play } = useFlipAnimation()
const masonryEl = ref<HTMLElement | null>(null)
const allItems = ref<CardItem[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const initLoading = ref(true)
const sentinel = ref<HTMLElement | null>(null)
const columnCount = ref(4)
const columns = ref<CardItem[][]>([[], [], [], []])
const columnHeights = ref<number[]>([0, 0, 0, 0])
function getColumnWidth() {
const padding = 48
const maxWidth = 1400
const containerWidth = Math.min(window.innerWidth - padding, maxWidth)
const gap = 20
return (containerWidth - gap * (columnCount.value - 1)) / columnCount.value
}
function estimateCardHeight(item: CardItem) {
const colWidth = getColumnWidth()
if (item.type === 'text') {
const textWidth = colWidth - 48
const charsPerLine = Math.max(1, Math.floor(textWidth / 13.5))
const charCount = (item.description || '').length
const lines = Math.ceil(charCount / charsPerLine)
return 109 + lines * 24
}
return colWidth / item.aspectRatio
}
function addToShortestColumn(item: CardItem) {
let minIdx = 0
for (let i = 1; i < columnCount.value; i++) {
if (columnHeights.value[i] < columnHeights.value[minIdx]) minIdx = i
}
columns.value[minIdx].push(item)
columnHeights.value[minIdx] += estimateCardHeight(item)
}
function distributeAll() {
const n = columnCount.value
columns.value = Array.from({ length: n }, () => [])
columnHeights.value = new Array(n).fill(0)
for (const item of allItems.value) {
addToShortestColumn(item)
}
}
let resizeTimer: ReturnType<typeof setTimeout> | null = null
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
updateColumns()
}, 150)
}
function updateColumns() {
const w = window.innerWidth
let n: number
if (w < 640) n = 1
else if (w < 768) n = 2
else if (w < 1024) n = 3
else if (w < 1280) n = 4
else n = 5
if (n !== columnCount.value) {
if (masonryEl.value) capture(masonryEl.value, '.card-reveal')
columnCount.value = n
distributeAll()
nextTick(() => {
if (masonryEl.value) {
play(masonryEl.value, '.card-reveal', { duration: 420 })
}
})
}
}
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const data = await $fetch<{ items: CardItem[]; hasMore: boolean }>('/api/cards', {
query: { page: page.value, pageSize: 12 },
})
for (const item of data.items) {
allItems.value.push(item)
addToShortestColumn(item)
}
hasMore.value = data.hasMore
page.value++
} finally {
loading.value = false
initLoading.value = false
}
await nextTick()
tryLoadMoreIfSentinelVisible()
}
function tryLoadMoreIfSentinelVisible() {
if (!sentinel.value || loading.value || !hasMore.value) return
const rect = sentinel.value.getBoundingClientRect()
if (rect.top < window.innerHeight + 300) {
loadMore()
}
}
onMounted(() => {
updateColumns()
window.addEventListener('resize', onResize)
loadMore()
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
if (resizeTimer) clearTimeout(resizeTimer)
})
let observer: IntersectionObserver | null = null
watch(sentinel, (el) => {
observer?.disconnect()
if (!el) return
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore()
},
{ rootMargin: '300px' },
)
observer.observe(el)
})
</script>
<template>
<div class="home">
<header class="page-header">
<h1>发现</h1>
<p class="subtitle">灵感与美学的无声对话</p>
</header>
<div ref="masonryEl" class="masonry">
<div
v-for="(col, ci) in columns"
:key="ci"
class="masonry-col"
>
<template v-for="(item, ri) in col" :key="item.id">
<div
class="card-reveal"
:style="{ '--enter-delay': `${ci * 80 + ri * 60}ms` }"
>
<IndexWaterfallCard
v-if="item.type === 'image-text'"
:image="item.image!"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallTextCard
v-else-if="item.type === 'text'"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallImageCard
v-else-if="item.type === 'image'"
:image="item.image!"
:title="item.title"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallPortfolioCard
v-else-if="item.type === 'portfolio'"
:images="item.images!"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallProjectCard
v-else
:image="item.image!"
:title="item.title"
:description="item.description!"
:tags="item.tags!"
:aspect-ratio="item.aspectRatio"
/>
</div>
</template>
</div>
</div>
<div ref="sentinel" class="sentinel">
<template v-if="initLoading">
<div class="loading-bars">
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" />
</div>
</template>
<template v-else-if="loading">
<div class="loading-bars">
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" />
</div>
</template>
<span v-else-if="hasMore" class="sentinel-text">继续滚动查看更多</span>
<span v-else class="sentinel-text end"> 已经到底了 </span>
</div>
</div>
</template>
<style scoped>
.home {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
}
/* ── Header: cream canvas, coral type ── */
.page-header {
padding: clamp(64px, 10vw, 108px) 0 clamp(20px, 3vw, 32px);
animation: header-reveal 0.8s var(--ease-out-expo) both;
}
@keyframes header-reveal {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header h1 {
font-family: var(--font-display);
font-size: clamp(64px, 10vw, 132px);
font-weight: 400;
color: var(--color-primary);
margin: 0;
line-height: 0.88;
letter-spacing: -0.02em;
}
.subtitle {
color: var(--color-muted);
font-size: clamp(16px, 2vw, 20px);
font-weight: 400;
margin: clamp(14px, 2.5vw, 24px) 0 0;
letter-spacing: 0.06em;
animation: fade-slide-up 0.7s var(--ease-out-expo) both;
animation-delay: 200ms;
}
@keyframes fade-slide-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Masonry ── */
.masonry {
display: flex;
gap: 20px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Card reveal ── */
.card-reveal {
animation: card-enter 0.65s var(--ease-out-expo) both;
animation-delay: var(--enter-delay, 0ms);
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Sentinel ── */
.sentinel {
display: flex;
justify-content: center;
align-items: center;
padding: 56px 0 96px;
min-height: 60px;
}
.sentinel-text {
font-size: 14px;
color: var(--color-muted-soft);
}
.sentinel-text.end {
color: var(--color-hairline);
}
.loading-bars {
display: flex;
gap: 6px;
align-items: center;
}
.bar {
width: 5px;
height: 20px;
background: var(--color-primary);
border-radius: 3px;
opacity: 0.5;
animation: pulse 0.9s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scaleY(0.6); }
50% { opacity: 0.8; transform: scaleY(1); }
}
/* ── Responsive ── */
@media (max-width: 768px) {
.home {
padding: 0 16px;
}
.page-header {
margin: 16px 0 40px;
padding: 40px 24px;
}
.page-header h1 {
font-size: clamp(40px, 12vw, 64px);
}
}
</style>

367
app/pages/index/index.vue

@ -1,368 +1,5 @@
<script setup lang="ts">
interface CardItem {
id: number
type: 'text' | 'image' | 'image-text' | 'portfolio' | 'project'
image?: string
images?: string[]
title: string
description?: string
tags?: string[]
aspectRatio: number
}
const { capture, play } = useFlipAnimation()
const masonryEl = ref<HTMLElement | null>(null)
const allItems = ref<CardItem[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const initLoading = ref(true)
const sentinel = ref<HTMLElement | null>(null)
const columnCount = ref(4)
const columns = ref<CardItem[][]>([[], [], [], []])
const columnHeights = ref<number[]>([0, 0, 0, 0])
function getColumnWidth() {
const padding = 48
const maxWidth = 1400
const containerWidth = Math.min(window.innerWidth - padding, maxWidth)
const gap = 20
return (containerWidth - gap * (columnCount.value - 1)) / columnCount.value
}
function estimateCardHeight(item: CardItem) {
const colWidth = getColumnWidth()
if (item.type === 'text') {
const textWidth = colWidth - 48
const charsPerLine = Math.max(1, Math.floor(textWidth / 13.5))
const charCount = (item.description || '').length
const lines = Math.ceil(charCount / charsPerLine)
return 109 + lines * 24
}
return colWidth / item.aspectRatio
}
function addToShortestColumn(item: CardItem) {
let minIdx = 0
for (let i = 1; i < columnCount.value; i++) {
if (columnHeights.value[i] < columnHeights.value[minIdx]) minIdx = i
}
columns.value[minIdx].push(item)
columnHeights.value[minIdx] += estimateCardHeight(item)
}
function distributeAll() {
const n = columnCount.value
columns.value = Array.from({ length: n }, () => [])
columnHeights.value = new Array(n).fill(0)
for (const item of allItems.value) {
addToShortestColumn(item)
}
}
let resizeTimer: ReturnType<typeof setTimeout> | null = null
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
updateColumns()
}, 150)
}
function updateColumns() {
const w = window.innerWidth
let n: number
if (w < 640) n = 1
else if (w < 768) n = 2
else if (w < 1024) n = 3
else if (w < 1280) n = 4
else n = 5
if (n !== columnCount.value) {
if (masonryEl.value) capture(masonryEl.value, '.card-reveal')
columnCount.value = n
distributeAll()
nextTick(() => {
if (masonryEl.value) {
play(masonryEl.value, '.card-reveal', { duration: 420 })
}
})
}
}
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const data = await $fetch<{ items: CardItem[]; hasMore: boolean }>('/api/cards', {
query: { page: page.value, pageSize: 12 },
})
for (const item of data.items) {
allItems.value.push(item)
addToShortestColumn(item)
}
hasMore.value = data.hasMore
page.value++
} finally {
loading.value = false
initLoading.value = false
}
await nextTick()
tryLoadMoreIfSentinelVisible()
}
function tryLoadMoreIfSentinelVisible() {
if (!sentinel.value || loading.value || !hasMore.value) return
const rect = sentinel.value.getBoundingClientRect()
if (rect.top < window.innerHeight + 300) {
loadMore()
}
}
onMounted(() => {
updateColumns()
window.addEventListener('resize', onResize)
loadMore()
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
if (resizeTimer) clearTimeout(resizeTimer)
})
let observer: IntersectionObserver | null = null
watch(sentinel, (el) => {
observer?.disconnect()
if (!el) return
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore()
},
{ rootMargin: '300px' },
)
observer.observe(el)
})
</script>
<template>
<div class="home">
<header class="page-header">
<h1>发现</h1>
<p class="subtitle">灵感与美学的无声对话</p>
</header>
<div ref="masonryEl" class="masonry">
<div
v-for="(col, ci) in columns"
:key="ci"
class="masonry-col"
>
<template v-for="(item, ri) in col" :key="item.id">
<div
class="card-reveal"
:style="{ '--enter-delay': `${ci * 80 + ri * 60}ms` }"
>
<IndexWaterfallCard
v-if="item.type === 'image-text'"
:image="item.image!"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallTextCard
v-else-if="item.type === 'text'"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallImageCard
v-else-if="item.type === 'image'"
:image="item.image!"
:title="item.title"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallPortfolioCard
v-else-if="item.type === 'portfolio'"
:images="item.images!"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallProjectCard
v-else
:image="item.image!"
:title="item.title"
:description="item.description!"
:tags="item.tags!"
:aspect-ratio="item.aspectRatio"
/>
</div>
</template>
</div>
</div>
<div ref="sentinel" class="sentinel">
<template v-if="initLoading">
<div class="loading-bars">
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" />
</div>
</template>
<template v-else-if="loading">
<div class="loading-bars">
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" />
</div>
</template>
<span v-else-if="hasMore" class="sentinel-text">继续滚动查看更多</span>
<span v-else class="sentinel-text end"> 已经到底了 </span>
</div>
<div>
asda
</div>
</template>
<style scoped>
.home {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
}
/* ── Header: cream canvas, coral type ── */
.page-header {
padding: clamp(64px, 10vw, 108px) 0 clamp(20px, 3vw, 32px);
animation: header-reveal 0.8s var(--ease-out-expo) both;
}
@keyframes header-reveal {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header h1 {
font-family: var(--font-display);
font-size: clamp(64px, 10vw, 132px);
font-weight: 400;
color: var(--color-primary);
margin: 0;
line-height: 0.88;
letter-spacing: -0.02em;
}
.subtitle {
color: var(--color-muted);
font-size: clamp(16px, 2vw, 20px);
font-weight: 400;
margin: clamp(14px, 2.5vw, 24px) 0 0;
letter-spacing: 0.06em;
animation: fade-slide-up 0.7s var(--ease-out-expo) both;
animation-delay: 200ms;
}
@keyframes fade-slide-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Masonry ── */
.masonry {
display: flex;
gap: 20px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Card reveal ── */
.card-reveal {
animation: card-enter 0.65s var(--ease-out-expo) both;
animation-delay: var(--enter-delay, 0ms);
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Sentinel ── */
.sentinel {
display: flex;
justify-content: center;
align-items: center;
padding: 56px 0 96px;
min-height: 60px;
}
.sentinel-text {
font-size: 14px;
color: var(--color-muted-soft);
}
.sentinel-text.end {
color: var(--color-hairline);
}
.loading-bars {
display: flex;
gap: 6px;
align-items: center;
}
.bar {
width: 5px;
height: 20px;
background: var(--color-primary);
border-radius: 3px;
opacity: 0.5;
animation: pulse 0.9s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scaleY(0.6); }
50% { opacity: 0.8; transform: scaleY(1); }
}
/* ── Responsive ── */
@media (max-width: 768px) {
.home {
padding: 0 16px;
}
.page-header {
margin: 16px 0 40px;
padding: 40px 24px;
}
.page-header h1 {
font-size: clamp(40px, 12vw, 64px);
}
}
</style>

109
docs/superpowers/specs/2026-05-20-homepage-fluid-canvas-design.md

@ -0,0 +1,109 @@
# 首页流体画布设计
## 概述
首页定位为纯粹的视觉体验,不承载信息。全屏 WebGL 流体画布,鼠标/触摸交互产生扰动,中央展示 "Dash" 品牌文字。首屏即全部,无下方内容区。
## 布局
```
┌──────────────────────────────────────────┐
│ │
│ Nav (透明浮层,固定顶部) │
│ │
│ ┌─────────────┐ │
│ │ 流体画布 │ │
│ │ (全屏 Canvas) │ │
│ │ │ │
│ │ Dash │ │
│ │ (中央文字) │ │
│ │ │ │
│ └─────────────┘ │
│ │
└──────────────────────────────────────────┘
```
- Canvas 铺满整个视口 (100vw × 100vh),位于所有内容之下 (z-index: 0)
- 中央文字固定居中,z-index 高于 Canvas
- 无滚动内容,无 footer(首页专属)
## 流体画布
### 色彩
- 底色:cream `#faf9f5`,占画布大部分面积
- 主流动层:coral `#cc785c`,混入少量 amber `#e8a55a`
- 点缀暗部:dark navy `#181715`,偶尔出现如墨滴
- 所有颜色 smooth blending,无硬边界
### 交互
- 鼠标移动 → 光标周围流体扰动,模拟手指划过水面
- 扰动强度正比于鼠标速度:快速 = 大波纹,缓慢 = 细微涟漪
- 鼠标静止 → 流体缓慢自主流动,不完全静止
- 触摸设备 → 触摸点产生扰动
### 中央文字
- 内容:"Dash"
- 字体:Liu Jian Mao Cao(草书),字号约 120-160px
- 颜色:cream `#faf9f5`
- 位置:居中固定,始终在 Canvas 之上
### 技术方案
- WebGL + Three.js + custom fragment shader 实现流体模拟
- Canvas 分辨率适配 devicePixelRatio
- 移动端降级为简化粒子系统(~200 粒子),触摸滑动产生扰动
- 加载态:纯 cream 背景 + coral 色呼吸动画点,Canvas 就绪后淡入
## 导航栏 (TopNav)
### 首页行为
- 背景透明,去掉 border-bottom
- 文字颜色使用 `on-dark` 风格(cream 偏白),确保在流体背景上可读
- Logo 保持 Liu Jian Mao Cao 字体
- 离开首页(导航到其他页面)恢复正常 cream 底 + hairline border
### 实现方式
- 通过 route 判断是否在首页 (`route.path === '/'`)
- 首页:添加 `transparent` modifier class
- 非首页:默认样式
## 响应式
| 断点 | 行为 |
|------|------|
| 桌面 (>1024px) | 全 WebGL 流体,120-160px 字号 |
| 平板 (768-1024px) | WebGL 流体,100-120px 字号 |
| 移动 (<768px) | 粒子系统降级80-100px 字号 |
## 组件分解
### 1. FluidCanvas.vue
WebGL Canvas 组件,负责:
- Three.js 场景初始化与销毁
- Shader uniform 更新(鼠标位置、时间)
- 窗口 resize 适配
- 移动端检测与降级切换
### 2. HomeHero.vue
首页内容层,负责:
- 居中 "Dash" 文字
- 文字淡入动画(Canvas 就绪后触发)
### 3. TopNav 改动
- 新增 `transparent` prop 或 composable 判断首页
- 首页样式覆盖:透明背景、无边框、文字颜色调整为 `on-dark`
## 不在范围内
- 首页下方内容区域(明确不需要)
- 向下滚动提示(明确不需要)
- 音效
- 流体录制/截图功能

9
nuxt.config.ts

@ -3,6 +3,15 @@ import tailwindcss from '@tailwindcss/vite'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
app: {
head: {
link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Abril+Fatface&display=swap' },
],
},
},
css: ['~/assets/css/main.css'],
devtools: { enabled: true },
vite: {

Loading…
Cancel
Save