Author SHA1 Message Date
wait4xandGLM-5.3 7e0167a8bb feat(opensource): 热力图五方案预览 + 开源页叙述合一(5j T1 + 预设)
用户两项新需求:
①「开源页的叙述为什么要区分链接前和链接后?完全可以用 markdown 富文本统一替代」
②「热力图可以有更多的动效,而不是静态的一张图,探索相关方案供我审阅」

T1 叙述合一:
- opensource.yaml 新增 story(双语 markdown,链接内联),blurb/blurb_after 保留为
  legacy(story 为空时才用)→ 旧 yaml 零回归
- site-content 访问器取 story,GithubProfile 走 renderRich 行内模式;markdown 生成的
  裸 <a> 补 .pf-bio :global(a) 样式,与 .pf-link 观感一致
- 实测产物:pf-bio 内联 <a> 渲染成功、pf-link 拼接痕迹 0 处

热力图五方案预览(/heatmap-preview/,供用户选型):
- 新增 geek/HeatGrid.astro:可复用网格渲染件(371 格带 --c/--r 供错峰延迟),
  沿用线上同款 ink 灰阶(accent 只做高亮,不参与数据编码)
- A 入场波纹(视口内一次性斜向漫过)、B 光标光晕(radial-gradient 跟随 + 日期读数)、
  C 年度回放(滑块 scrub + 累计计数 + 自动播放)、D 连续电流(最近连续段流光 +
  连续天数大数)、E 悬停周详情(整列抬起 + 周合计/最好一天面板)
- 五案均守灰阶红线、均在 prefers-reduced-motion 下静态化、零第三方依赖、无常驻 rAF
- **两处真坑**:①网格是子组件渲染,页面级 scoped 样式够不到其内部元素 → 与格内元素
  相关的选择器必须 :global()(否则 A 的动画与 E 的抬列静默不生效)②「当前连续」按
  今天起算会得 0(今天尚未贡献)→ 改为以最后一个有贡献的日子为终点往回数,并标注
  「截至 <日期>」

另修 E2E 过时断言:书架改版为抽书阅读后,原「年份折叠组」断言必然失败,
改为「抽书行渲染 + 点击可抽开」(56/56 PASS)

验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56;
      Playwright 逐案实测:A 动画 hmRise 0.42s 且播完 opacity=1、B 光晕 opacity=1
      并显示「日期 · 次数」、C 拖到第 10 周读数与淡出正确、D 12 格流光 hmFlow +
      连续 5 天(截至 2026-10-24)、E 整列 lift matrix(…,-2);390 零横向溢出;
      reduced-motion 下动画 none、光晕 display:none

Spec: 2026-11-03_blog-5j-content-refine (T1 + 热力图预览)

Co-authored-by: GLM-5.3
2026-11-04 20:09:10 +08:00
14 changed files with 489 additions and 433 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

+31 -13
View File
@@ -1,25 +1,20 @@
--- ---
title: 面纱 title: "面纱"
pubDate: 2026-03-09 pubDate: 2026-03-09
category: reading category: reading
tags: tags: [read]
- read
pinned: true
mood: '#7c3aed' mood: '#7c3aed'
bookAuthor: 威廉·萨默塞特·毛姆 bookAuthor: '威廉·萨默塞特·毛姆'
description: 面纱之下:理想的殉道者与真实的幸存者。 description: '面纱之下:理想的殉道者与真实的幸存者。'
cover: ./assets/面纱.png
hiddenTag: false
draft: false
--- ---
**_面纱之下:理想的殉道者和真实的幸存者_** ****面纱之下:理想的殉道者和真实的幸存者****
### 你用面纱遮蔽他人的窥探,自己也难以一睹世界之实。 ### 你用面纱遮蔽他人的窥探,自己也难以一睹世界之实。
这是译者在后记中写下的一句话。很好印证了《面纱》这本书或者说这个题目所蕴含的真相。人们活在面纱之下,以此遮蔽他人的窥探,却也因此困于自己的面纱之下。 这是译者在后记中写下的一句话。很好印证了《面纱》这本书或者说这个题目所蕴含的真相。人们活在面纱之下,以此遮蔽他人的窥探,却也因此困于自己的面纱之下。
见沃尔特像是见最温柔的爱和最悲痛的爱情,以最炽热的心和最真挚的感情去爱,爱上了一个不爱自己的人,最后在爱中将自己陷入漩涡🌀,不得解脱。他富有智慧,又富有对这个世界和社会以及人性最明锐的洞察力,他了解世界的苦难,了解社会的种种表象以及深层次的真相,了解人的“伪装”背后的真实人性。他像是个圣洁的存在,却同样又有着拙劣和“恶毒”的想法,以至于至死都未能从感情漩涡中游出。在我看来,沃尔特对于凯蒂的爱是真切的,对于背叛的恨也是真切的,但从始至终爱始终贯穿永远。在去湄潭府之前,他是冷静的旁观者,他是理性的化身,他有着对于真理的精准见解,有着自己衡量一切的道德标准和不可被践踏的信仰,这是源于理性的不可被磨灭的印记。在感知到背叛,并带着赴死的姿态奔赴湄潭府的过程中,他被感情所裹挟,他既爱又恨,恨凯蒂的背叛,也恨自己爱上了一个与自己原则背道而驰的爱人,他从一开始想让凯蒂也走向死亡,到最后让她离开,看似对于婚姻和这段感情有了一点点解脱,可实际上到死他都未能跨过自己给自己筑起的情感高墙。他是矛盾的,他至死未能宽恕自己,也正如他对世界最后的留言所述:“死的是一条狗”。这不仅是对凯蒂的隐喻,更是他对自己灵魂的终极审判。他终于承认,那个满怀怨恨、试图以爱之名行毁灭之实的自己,才是那条被仇恨吞噬的‘疯狗’。沃尔特的一生,是用最锋利的理性去剖析世界,却唯独不敢直视自己内心那片荒芜的废墟。他死于霍乱,实则死于那层由傲慢与偏见织就的、永远无法揭下的面纱。 见沃尔特像是见最温柔的爱和最悲痛的爱情,以最炽热的心和最真挚的感情去爱,爱上了一个不爱自己的人,最后在爱中将自己陷入漩涡🌀,不得解脱。他富有智慧,又富有对这个世界和社会以及人性最明锐的洞察力,他了解世界的苦难,了解社会的种种表象以及深层次的真相,了解人的“伪装”背后的真实人性。他像是个圣洁的存在,却同样又有着拙劣和“恶毒”的想法,以至于至死都未能从感情漩涡中游出。在我看来,沃尔特对于凯蒂的爱是真切的,对于背叛的恨也是真切的,但从始至终爱始终贯穿永远。在去湄潭府之前,他是冷静的旁观者,他是理性的化身,他有真对于真理的精准见解,有着自己衡量一切的道德标准和不可被践踏的信仰,这是源于理性的不可被磨灭的印记。在感知到背叛,并带着赴死的姿态奔赴湄潭府的过程中,他被感情所裹挟,他既爱又恨,恨凯蒂的背叛,也恨自己爱上了一个与自己原则背道而驰的爱人,他从一开始想让凯蒂也走向死亡,到最后让她离开,看似对于婚姻和这段感情有了一点点解脱,可实际上到死他都未能跨过自己给自己筑起的情感高墙。他是矛盾的,他至死未能宽恕自己,也正如他对世界最后的留言所述:“死的是一条狗”。这不仅是对凯蒂的隐喻,更是他对自己灵魂的终极审判。他终于承认,那个满怀怨恨、试图以爱之名行毁灭之实的自己,才是那条被仇恨吞噬的‘疯狗’。沃尔特的一生,是用最锋利的理性去剖析世界,却唯独不敢直视自己内心那片荒芜的废墟。他死于霍乱,实则死于那层由傲慢与偏见织就的、永远无法揭下的面纱。
相比之下,凯蒂是幸运的,也最终得到了自由。在虚荣的面纱下,遮住的是世界的残酷和社会的复杂以及人性的不可知。一开始的虚荣心作祟,让她步入了和沃尔特的婚姻,却发现婚后的生活并非是自己所期望的那样,甚至从一开始她就清楚的知道自己并不爱沃尔特。于是,当她到了香港,并遇上查理的时候,她沦陷了,她在查理身上看见了自己所期待的伴侣所拥有的那些特性,幽默风趣,性感迷人,但是却忽视了人性。在去往湄潭府之前,她虚荣,她害怕,她怕沃尔特发现一切,她软弱,她想逃离这样的婚姻和查理相拥,但是她不敢付诸行动。当沃尔特推着她去面对一切的时候,当一切都败露的时候,她终究是看见了人性的恶,她见到了查理的无情和所谓的爱只是言语上的“美好”,她也真正的感到了恐惧,面对死亡的恐惧和面对沃尔特不可捉摸的想法的恐惧。在湄潭府之行的初期,她依旧活在对于过去的回忆之中,她害怕恐惧,她看不见未来。但被圣光照耀的庙堂始终矗立在彼岸,她向往那迷人的色彩,她想要去追求新的生活。当她跨出第一步并开始在修道院工作的时候,她的觉醒之路从此慢慢开始,她慢慢和自己和解,慢慢看淡背叛,慢慢看清查理,慢慢找寻着自己在生活中该有的样子,她开始慢慢摘掉自己的面纱,看清自己的虚伪和软弱,开始追寻道的意义,她感悟周围的一切,甚至想着能够将沃尔特从他的痛苦中将他拉出。然而直到沃尔特死去,他们都没能和解,她愧对沃尔特,她看到了自己的轻贱,可是她自始至终都跨不过沃尔特竖起的高墙,她在墙外,沃尔特在墙内,各自在各自的面纱下痛苦的活着。沃尔特的死,是沃尔特的一种“解脱”,也是凯蒂的一种解脱。 相比之下,凯蒂是幸运的,也最终得到了自由。在虚荣的面纱下,遮住的是世界的残酷和社会的复杂以及人性的不可知。一开始的虚荣心作祟,让她步入了和沃尔特的婚姻,却发现婚后的生活并非是自己所期望的那样,甚至从一开始她就清楚的知道自己并不爱沃尔特。于是,当她到了香港,并遇上查理的时候,她沦陷了,她在查理身上看见了自己所期待的伴侣所拥有的那些特性,幽默风趣,性感迷人,但是却忽视了人性。在去往湄潭府之前,她虚荣,她害怕,她怕沃尔特发现一切,她软弱,她想逃离这样的婚姻和查理相拥,但是她不敢付诸行动。当沃尔特推着她去面对一切的时候,当一切都败露的时候,她终究是看见了人性的恶,她见到了查理的无情和所谓的爱只是言语上的“美好”,她也真正的感到了恐惧,面对死亡的恐惧和面对沃尔特不可捉摸的想法的恐惧。在湄潭府之行的初期,她依旧活在对于过去的回忆之中,她害怕恐惧,她看不见未来。但被圣光照耀的庙堂始终矗立在彼岸,她向往那迷人的色彩,她想要去追求新的生活。当她跨出第一步并开始在修道院工作的时候,她的觉醒之路从此慢慢开始,她慢慢和自己和解,慢慢看淡背叛,慢慢看清查理,慢慢找寻着自己在生活中该有的样子,她开始慢慢摘掉自己的面纱,看清自己的虚伪和软弱,开始追寻道的意义,她感悟周围的一切,甚至想着能够将沃尔特从他的痛苦中将他拉出。然而直到沃尔特死去,他们都没能和解,她愧对沃尔特,她看到了自己的轻贱,可是她自始至终都跨不过沃尔特竖起的高墙,她在墙外,沃尔特在墙内,各自在各自的面纱下痛苦的活着。沃尔特的死,是沃尔特的一种“解脱”,也是凯蒂的一种解脱。
@@ -27,7 +22,7 @@ draft: false
> >
> 所谓《面纱》,遮住的不仅是真相,更是我们面对真实自我的勇气。沃尔特缺乏这份勇气去接纳不完美的爱与人性,所以他困死其中;凯蒂在生死关头被迫拥有了这份勇气,所以她获得了自由。 > 所谓《面纱》,遮住的不仅是真相,更是我们面对真实自我的勇气。沃尔特缺乏这份勇气去接纳不完美的爱与人性,所以他困死其中;凯蒂在生死关头被迫拥有了这份勇气,所以她获得了自由。
> >
> 故事的最后,死去的人得到了安息,活着的人带着记忆与教训,继续前行。这或许就是毛姆想告诉我们的:**_人生没有完美的结局,只有在认清生活的残酷真相后,依然选择热爱并真实地活下去,才是唯一的救赎。_** > 故事的最后,死去的人得到了安息,活着的人带着记忆与教训,继续前行。这或许就是毛姆想告诉我们的:****人生没有完美的结局,只有在认清生活的残酷真相后,依然选择热爱并真实地活下去,才是唯一的救赎。****
从背叛到自我的救赎,凯蒂在远离家乡的异国,没能逃脱欲望作祟,再次回到香港之后又一次沦陷在了查理的怀抱中,但此刻的她早已不是去湄潭府的之前的自己,她的思想早已骤变。她厌恶自己肉体对自己灵魂的出卖,她选择远离这个是非之地,她选择去重新追求自由,亦如从湄潭府离开一样,她再一次走上了追寻自由的路上。她回到故土,见母亲离世,见妹妹怀有身孕,见父亲终于脱离过去的自己,她亦想脱离过去的自己,拥抱一个崭新的未来。活在面纱之下的她和她的父亲,在故事的结局,两人袒露心声,一同前往新的地方开始新的生活,去活出新的自己。 从背叛到自我的救赎,凯蒂在远离家乡的异国,没能逃脱欲望作祟,再次回到香港之后又一次沦陷在了查理的怀抱中,但此刻的她早已不是去湄潭府的之前的自己,她的思想早已骤变。她厌恶自己肉体对自己灵魂的出卖,她选择远离这个是非之地,她选择去重新追求自由,亦如从湄潭府离开一样,她再一次走上了追寻自由的路上。她回到故土,见母亲离世,见妹妹怀有身孕,见父亲终于脱离过去的自己,她亦想脱离过去的自己,拥抱一个崭新的未来。活在面纱之下的她和她的父亲,在故事的结局,两人袒露心声,一同前往新的地方开始新的生活,去活出新的自己。
@@ -37,7 +32,7 @@ draft: false
沃尔特死在了他的‘正确’里,而凯蒂活在了她的‘真实’中。沃尔特的故事是带有悲惨的个人主义的牺牲,凯蒂的故事是带有幸运的个人主义的新生。沃尔特自觉没有希望,在困顿的泥潭中深陷,他没有勇气去面对新的生活和新的世界,他将自己紧紧隐藏在自己的面纱之后,他活在自己为自己构建的信念世界之中,为自己的信念,"悲壮”赴死。凯蒂活在纷繁的社会中,从小我见大我,从假我见真我,从困局中得自由。她一步一步从虚伪的面纱下得见人性、社会冷暖以及新世界的希望,湄潭府之前的生活让她自省,湄潭府之行让她感知到一切,在死亡和黑暗中得到感悟和觉醒,得见光明,再次回到香港,欲望再次作祟,这一次她没有沉沦于虚妄的现实,她终于携手勇气做出了自己的决定,踏上了"离开”的归程。 沃尔特死在了他的‘正确’里,而凯蒂活在了她的‘真实’中。沃尔特的故事是带有悲惨的个人主义的牺牲,凯蒂的故事是带有幸运的个人主义的新生。沃尔特自觉没有希望,在困顿的泥潭中深陷,他没有勇气去面对新的生活和新的世界,他将自己紧紧隐藏在自己的面纱之后,他活在自己为自己构建的信念世界之中,为自己的信念,"悲壮”赴死。凯蒂活在纷繁的社会中,从小我见大我,从假我见真我,从困局中得自由。她一步一步从虚伪的面纱下得见人性、社会冷暖以及新世界的希望,湄潭府之前的生活让她自省,湄潭府之行让她感知到一切,在死亡和黑暗中得到感悟和觉醒,得见光明,再次回到香港,欲望再次作祟,这一次她没有沉沦于虚妄的现实,她终于携手勇气做出了自己的决定,踏上了"离开”的归程。
故事的最后,面纱已落。凯蒂带着伤痕,牵着父亲的手,走向巴哈马的阳光。那里没有豪门的虚妄,没有激情的幻象,只有平凡而坚实的生活。这,或许就是毛姆留给所有在虚荣与迷茫中挣扎的人们,最温柔的答案:**_唯有真实,可得自由。_** 故事的最后,面纱已落。凯蒂带着伤痕,牵着父亲的手,走向巴哈马的阳光。那里没有豪门的虚妄,没有激情的幻象,只有平凡而坚实的生活。这,或许就是毛姆留给所有在虚荣与迷茫中挣扎的人们,最温柔的答案:****唯有真实,可得自由。****
在《面纱》的故事中,沃尔特是极具“英雄主义”的个人,为自己固有的信仰慷然赴死;凯蒂是在纷杂的社会中逐步成长,在欲望和虚荣的交叉间度过早年,湄潭府一行,在黑暗中见光明,在死亡中见希望,在异国见真我,在“新世界”得自由。 在《面纱》的故事中,沃尔特是极具“英雄主义”的个人,为自己固有的信仰慷然赴死;凯蒂是在纷杂的社会中逐步成长,在欲望和虚荣的交叉间度过早年,湄潭府一行,在黑暗中见光明,在死亡中见希望,在异国见真我,在“新世界”得自由。
@@ -46,28 +41,51 @@ draft: false
------------------------------------------------------------------------ ------------------------------------------------------------------------
- 我不了解你,我一点儿都不了解你。 - 我不了解你,我一点儿都不了解你。
- 我知道你愚蠢、轻浮、没有头脑,但是我爱你。我知道你的目标和理想既庸俗又普通,但是我爱你。我知道你是二流货色,但是我爱你。想一想真是好笑,我竭力去喜欢那些讨你喜欢的东西,忍受折磨也要对你隐瞒起自己,实际上我并不无知粗俗、不爱散播丑闻也不愚蠢。我知道你何等害怕智慧,便尽我所能让你觉得我是个大傻瓜,跟你认识的其他人一样。我知道你嫁给我只图一时利益,我是那样爱你,我不在乎。大多数人,就我所知,当他们爱一个人,却没有得到爱的回报时就会觉得委屈不平,甚至愈发愤怒和痛苦。我不是那样,我从来没有指望你爱我,我看不出任何理由让你爱我,我也从来没想过自己会被人爱。我很感激能被允许爱你,当我时常想起你高兴跟我在一起,或者当我发现你眼中闪烁着愉快的爱意时,我就会欣喜若狂。我尽量不让我的爱来烦扰你,我知道那会让我承受不起,所以我一直察言观色,留意我的爱让你厌烦的最初迹象。大部分丈夫认为那是一种权力,我却准备当成恩惠来接受。 - 我知道你愚蠢、轻浮、没有头脑,但是我爱你。我知道你的目标和理想既庸俗又普通,但是我爱你。我知道你是二流货色,但是我爱你。想一想真是好笑,我竭力去喜欢那些讨你喜欢的东西,忍受折磨也要对你隐瞒起自己,实际上我并不无知粗俗、不爱散播丑闻也不愚蠢。我知道你何等害怕智慧,便尽我所能让你觉得我是个大傻瓜,跟你认识的其他人一样。我知道你嫁给我只图一时利益,我是那样爱你,我不在乎。大多数人,就我所知,当他们爱一个人,却没有得到爱的回报时就会觉得委屈不平,甚至愈发愤怒和痛苦。我不是那样,我从来没有指望你爱我,我看不出任何理由让你爱我,我也从来没想过自己会被人爱。我很感激能被允许爱你,当我时常想起你高兴跟我在一起,或者当我发现你眼中闪烁着愉快的爱意时,我就会欣喜若狂。我尽量不让我的爱来烦扰你,我知道那会让我承受不起,所以我一直察言观色,留意我的爱让你厌烦的最初迹象。大部分丈夫认为那是一种权力,我却准备当成恩惠来接受。
- 他知道你虚荣、懦弱、自私自利,他想让我亲眼看到这一切。他知道你会像野兔一样,危险一来就跑得远远的。他知道我深受蒙骗才会认为你爱上了我,因为他知道你不会爱任何人,只爱你自己。他知道你会牺牲我,好让自己毫发无损地逃脱出去。 - 他知道你虚荣、懦弱、自私自利,他想让我亲眼看到这一切。他知道你会像野兔一样,危险一来就跑得远远的。他知道我深受蒙骗才会认为你爱上了我,因为他知道你不会爱任何人,只爱你自己。他知道你会牺牲我,好让自己毫发无损地逃脱出去。
- 死亡既突然又无情,在一条条曲折的街巷上匆匆穿过。 - 死亡既突然又无情,在一条条曲折的街巷上匆匆穿过。
- 黎明乍现,阳光触到雾霭,令其闪闪发白,犹如雪之幽灵降至即将熄灭的星宿之上。 - 黎明乍现,阳光触到雾霭,令其闪闪发白,犹如雪之幽灵降至即将熄灭的星宿之上。
- 你瞧,这里只有你跟我踏踏实实在坚实的地上行走。修女们走在天上,而你丈夫则走在黑暗里。 - 你瞧,这里只有你跟我踏踏实实在坚实的地上行走。修女们走在天上,而你丈夫则走在黑暗里。
- 他的灵魂撕裂了,他一直活在一种虚假的构想之中,当真相击碎了幻象,他便认为现实本身也被击碎了。 - 他的灵魂撕裂了,他一直活在一种虚假的构想之中,当真相击碎了幻象,他便认为现实本身也被击碎了。
- 这个时辰的城市街巷空空荡荡,比任何时候都更像一座死城。路人一个个神色迷离,不禁让人怀疑他们全都是鬼魂。天空晴朗无云,初升的太阳将圣洁和煦的光芒洒满大地。很难想象,在这个愉悦、清新、爽朗的黎明,这座城市却像被疯子一手扼住咽喉的人,在瘟疫的黑暗掌控下苟延残喘。不可思议的是,当人类在痛苦挣扎、在恐惧中走向死亡时,大自然(蓝色的天空如孩童的心一般清澈)竟会如此无动于衷。 - 这个时辰的城市街巷空空荡荡,比任何时候都更像一座死城。路人一个个神色迷离,不禁让人怀疑他们全都是鬼魂。天空晴朗无云,初升的太阳将圣洁和煦的光芒洒满大地。很难想象,在这个愉悦、清新、爽朗的黎明,这座城市却像被疯子一手扼住咽喉的人,在瘟疫的黑暗掌控下苟延残喘。不可思议的是,当人类在痛苦挣扎、在恐惧中走向死亡时,大自然(蓝色的天空如孩童的心一般清澈)竟会如此无动于衷。
- 安宁只存在于人的灵魂中。 - 安宁只存在于人的灵魂中。
- 所有的人,乃至整个人类,就如同这条河里的水滴一样,流淌不定,一滴滴彼此接近,却又相距遥远,汇成一股无名的巨流奔向大海。既然一切转瞬即逝,任何事物都无关宏旨,人们竟还要荒唐地看重那些微不足道的事情,让自己也让别人遭受不幸,这实在太可悲了。 - 所有的人,乃至整个人类,就如同这条河里的水滴一样,流淌不定,一滴滴彼此接近,却又相距遥远,汇成一股无名的巨流奔向大海。既然一切转瞬即逝,任何事物都无关宏旨,人们竟还要荒唐地看重那些微不足道的事情,让自己也让别人遭受不幸,这实在太可悲了。
- 小小的两滴水随着河流默默流向未知,这两滴水相对而言是那样独特,但在旁观者的眼里,不过是河流中无法辨识的组成部分。 - 小小的两滴水随着河流默默流向未知,这两滴水相对而言是那样独特,但在旁观者的眼里,不过是河流中无法辨识的组成部分。
- 我又明白什么呢,生命是那么奇特。我觉得自己就像个一辈子都住在小池塘边上的人,突然间看见了大海,让我有点喘不过气来,但心里又充满了喜悦。我不想死,想活下去,于是感到一股新的勇气。我就好像那些老水手,起航驶向尚未发现的海洋,我的灵魂渴求未知的一切。 - 我又明白什么呢,生命是那么奇特。我觉得自己就像个一辈子都住在小池塘边上的人,突然间看见了大海,让我有点喘不过气来,但心里又充满了喜悦。我不想死,想活下去,于是感到一股新的勇气。我就好像那些老水手,起航驶向尚未发现的海洋,我的灵魂渴求未知的一切。
- 小小的两滴水默默地、默默地流向那黑暗、永恒的大海。 - 小小的两滴水默默地、默默地流向那黑暗、永恒的大海。
- 道。我们有些人在鸦片中寻找道,有些人在上帝那儿,有些人寻求威士忌,或去爱里寻找。这道终归只有一条,可它不通向任何地方。 - 道。我们有些人在鸦片中寻找道,有些人在上帝那儿,有些人寻求威士忌,或去爱里寻找。这道终归只有一条,可它不通向任何地方。
- 这一切既新奇又感人,她的心灵犹如一片平静而灰暗的大海,波浪起伏,蔚然壮观,阴郁浩瀚令人畏惧,突然间,一缕阳光投射过来,让一切变得活泼、友好而快乐。 - 这一切既新奇又感人,她的心灵犹如一片平静而灰暗的大海,波浪起伏,蔚然壮观,阴郁浩瀚令人畏惧,突然间,一缕阳光投射过来,让一切变得活泼、友好而快乐。
- 人生何其短暂,世界本来就充满了苦痛,人们却还要折磨自己,这岂不太可怜了吗? - 人生何其短暂,世界本来就充满了苦痛,人们却还要折磨自己,这岂不太可怜了吗?
- 她的性格就像一片乡野,初见时感觉辽阔而冷漠,但不久就会在巍峨山岭的褶皱间发现一个个掩映在果树丛中的欢腾的村庄,看见郁郁葱葱的草地上欢快流淌的一条条小河。这番宜人的景象会让你惊奇,甚至熨帖安心,但远处狂风劲吹的黄褐色高地尚不足以使你感到自如自在。 - 她的性格就像一片乡野,初见时感觉辽阔而冷漠,但不久就会在巍峨山岭的褶皱间发现一个个掩映在果树丛中的欢腾的村庄,看见郁郁葱葱的草地上欢快流淌的一条条小河。这番宜人的景象会让你惊奇,甚至熨帖安心,但远处狂风劲吹的黄褐色高地尚不足以使你感到自如自在。
- 只有一种办法赢得人心,那就是让自己成为人们会去爱的人。 - 只有一种办法赢得人心,那就是让自己成为人们会去爱的人。
- 我说不清。我不知道她们追求幻觉这一点是否真的那么重要,还是这种生活本身就很美好。我有个想法,唯一让我们有可能不带嫌恶地关注我们所生活的这个世界的,就是人类不断从混沌中创造出的美。他们描绘的画,他们谱出的乐曲,他们写的书,还有他们的生活。这一切中,最富于美的就是美好的生活,那是件完美的艺术品。 - 我说不清。我不知道她们追求幻觉这一点是否真的那么重要,还是这种生活本身就很美好。我有个想法,唯一让我们有可能不带嫌恶地关注我们所生活的这个世界的,就是人类不断从混沌中创造出的美。他们描绘的画,他们谱出的乐曲,他们写的书,还有他们的生活。这一切中,最富于美的就是美好的生活,那是件完美的艺术品。
- 道就是道路和行道者。那是一条永恒的路,所有的生命存在行走其上,但它并非由生命存在所创造,因为它本身便是生命存在。它什么都是,又什么都不是。万物由道所生,与道相符,最后万物又回归于道。它是一块方形却无四角,是种声音却不被聆听,是幅图画却未有形状;它是一张巨大的网,网眼阔如海洋,却什么都无法穿过;它是万物寻求庇护的避难所;它无处可寻,但你‘不窥牖’便可‘见天道’;它要人学会欲无所欲,让一切顺其自然。谦卑者尽得保全,屈身者终将直立。‘祸兮,福之所依;福兮,祸之所伏’;但谁能说清什么时候会出现转折点?追求柔慈之人会如小孩子一样平和。柔慈为进攻者获取胜利,为守卫者求得保全。战胜自己的人最为强大。 - 道就是道路和行道者。那是一条永恒的路,所有的生命存在行走其上,但它并非由生命存在所创造,因为它本身便是生命存在。它什么都是,又什么都不是。万物由道所生,与道相符,最后万物又回归于道。它是一块方形却无四角,是种声音却不被聆听,是幅图画却未有形状;它是一张巨大的网,网眼阔如海洋,却什么都无法穿过;它是万物寻求庇护的避难所;它无处可寻,但你‘不窥牖’便可‘见天道’;它要人学会欲无所欲,让一切顺其自然。谦卑者尽得保全,屈身者终将直立。‘祸兮,福之所依;福兮,祸之所伏’;但谁能说清什么时候会出现转折点?追求柔慈之人会如小孩子一样平和。柔慈为进攻者获取胜利,为守卫者求得保全。战胜自己的人最为强大。
- 自由!这便是一直萦绕在她心中的念头。尽管未来仍旧模糊,这一念头却像河上的薄雾,在清晨阳光的照耀下焕发虹彩。自由!不仅仅是挣脱烦恼的束缚,解除那让她消沉的伴侣关系的自由。自由!不仅是逃离死亡威胁的自由,更是逃离让她降低人格的爱情,逃脱所有精神束缚的自由,一种抽离出肉体的精神的自由。与自由相伴的,还有勇气,以及无论发生什么都毫不在乎的坚强气质。 - 自由!这便是一直萦绕在她心中的念头。尽管未来仍旧模糊,这一念头却像河上的薄雾,在清晨阳光的照耀下焕发虹彩。自由!不仅仅是挣脱烦恼的束缚,解除那让她消沉的伴侣关系的自由。自由!不仅是逃离死亡威胁的自由,更是逃离让她降低人格的爱情,逃脱所有精神束缚的自由,一种抽离出肉体的精神的自由。与自由相伴的,还有勇气,以及无论发生什么都毫不在乎的坚强气质。
- 她以为自己变了,以为自己意志坚强,会以一个冷静自持的女人的面目回到香港。心中掠过一个个崭新的念头,就像阳光下翻飞的黄色蝴蝶,让她对美好的未来充满期望。自由就像光的精灵召唤她,整个世界犹如一片广袤的平原,任她迈着轻快的步子昂首前行。她原以为自己已摆脱了肉欲和卑劣的情爱,足以过上一种纯洁健康的精神生活。她曾将自己比作黄昏时分悠然飞跃稻田的白鹭,它们就像安闲自处的头脑中翩然翱翔的片片思绪,可实际上她却仍是欲望的奴隶。 - 她以为自己变了,以为自己意志坚强,会以一个冷静自持的女人的面目回到香港。心中掠过一个个崭新的念头,就像阳光下翻飞的黄色蝴蝶,让她对美好的未来充满期望。自由就像光的精灵召唤她,整个世界犹如一片广袤的平原,任她迈着轻快的步子昂首前行。她原以为自己已摆脱了肉欲和卑劣的情爱,足以过上一种纯洁健康的精神生活。她曾将自己比作黄昏时分悠然飞跃稻田的白鹭,它们就像安闲自处的头脑中翩然翱翔的片片思绪,可实际上她却仍是欲望的奴隶。
- 我有希望,也有勇气。 - 我有希望,也有勇气。
- 你用面纱遮蔽他人的窥探,自己也难以一睹世界之实。 - 你用面纱遮蔽他人的窥探,自己也难以一睹世界之实。
------------------------------------------------------------------------ ------------------------------------------------------------------------
+5 -10
View File
@@ -1,16 +1,11 @@
--- ---
title: 泥潭 title: "泥潭"
pubDate: 2026-02-09 pubDate: 2026-02-09
category: reading category: reading
tags: tags: [read]
- read mood: '#166534'
pinned: false bookAuthor: '刘楚昕'
mood: '#686868' description: '迷失于黑夜时,不妨抬头看看星空。'
bookAuthor: 刘楚昕
description: 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。
cover: ./assets/泥潭.png
hiddenTag: false
draft: false
--- ---
### 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。 ### 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。
+5 -10
View File
@@ -1,16 +1,11 @@
--- ---
title: 赎罪 title: "赎罪"
pubDate: 2026-02-26 pubDate: 2026-02-26
category: reading category: reading
tags: tags: [read]
- read mood: '#9f1239'
pinned: false bookAuthor: '伊恩·麦克尤恩'
mood: '#bf002f' description: '因一个人、一张图、一句台词,看了一本书。'
bookAuthor: 伊恩·麦克尤恩
description: 亲爱的塞西莉亚,我们缘分未尽
cover: ./assets/赎罪.png
hiddenTag: false
draft: false
--- ---
### 你要回来,我会等你。 ### 你要回来,我会等你。
+2 -6
View File
@@ -1,14 +1,10 @@
--- ---
title: 写作模板:这篇文章本身就是模板 title: "写作模板:这篇文章本身就是模板"
pubDate: 2026-11-02 pubDate: 2026-11-02
category: tech category: tech
tags: tags: [写作, 模板]
- 写作
- 模板
pinned: true pinned: true
description: 一篇文章演示全站可用的排版能力:标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。复制它开始写新文。 description: 一篇文章演示全站可用的排版能力:标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。复制它开始写新文。
hiddenTag: true
draft: false
--- ---
这篇不是教程,是**可复制的模板**。下面每一节都在真跑——你看到的效果,就是 Markdown 源码能产出的效果。写新文章时,把本文件复制一份、改个 slug、删掉不需要的段落即可。 这篇不是教程,是**可复制的模板**。下面每一节都在真跑——你看到的效果,就是 Markdown 源码能产出的效果。写新文章时,把本文件复制一份、改个 slug、删掉不需要的段落即可。
+21 -1
View File
@@ -1,3 +1,4 @@
# 打字机动效文案(档案卡顶部逐字轮播;每行双语,最多 6 行;留空用内置默认三行)
typing: typing:
- zh: 气象出身,写代码为乐 - zh: 气象出身,写代码为乐
en: Trained in meteorology, coding for fun en: Trained in meteorology, coding for fun
@@ -5,15 +6,34 @@ typing:
en: Self-hosting whatever can be self-hosted en: Self-hosting whatever can be self-hosted
- zh: 拥抱开源,热爱开源 - zh: 拥抱开源,热爱开源
en: Embrace open source, love open source en: Embrace open source, love open source
- zh: 书与快门都在一个工作台
en: Books and shutters, one workbench
# 开篇叙述(5j T1):markdown 富文本,链接内联写 `[文字](url)`。
# 非空时整段走富文本管线(净化 + 排版类白名单),**取代**下面的 blurb + repo_* + blurb_after 拼接。
story:
zh: "本站本身即开源:[git.wait4x.online/wait4x/blog](https://git.wait4x.online/wait4x/blog) —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。"
en: "This site is itself open source: [git.wait4x.online/wait4x/blog](https://git.wait4x.online/wait4x/blog) — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year."
# ---- legacy(story 为空时才用)----
# 下面四个字段是 5j 之前「两半夹链接」的写法,保留只为旧 yaml / 回退演示;新改动请一律写 story。
blurb: blurb:
zh: 本站本身即开源: zh: 本站本身即开源:
en: 'This site is itself open source:' en: 'This site is itself open source:'
repo_url: https://git.wait4x.online/wait4x/blog repo_url: https://git.wait4x.online/wait4x/blog
repo_label: git.wait4x.online/wait4x/blog repo_label: git.wait4x.online/wait4x/blog
# legacy:story 为空时接在 story 占位处拼接(见上方 blurb 注释)
blurb_after: blurb_after:
zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。 zh: —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。
en: — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year. en: — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.
mode: all mode: all
sort: pinned-first sort: pinned-first
perSource: github perSource: github
intro_from: 'off' intro_from: "off"
# ---- 开篇来源说明(2026-11-02 Phase 5h)----
# 开篇已改原生 GitHub 档案卡(头像 / 统计四格 / 语言占比灰阶条),不再渲染 README 原文。
# intro_from: "off" = 用档案卡 + 上面的 blurb(当前)
# intro_from: github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染(**不推荐**:
# 会引入 7 类第三方外链图,慢网或被墙时整片空白;README 用 <div align="center"> 居中,
# 本站不认该属性 → 居中失效。这正是当初"内容没居中、图片不显示"的根因。)
+2 -77
View File
@@ -143,73 +143,6 @@ collections:
- name: site_pages - name: site_pages
label: 页面文案 label: 页面文案
files: files:
- name: pages
label: 瞬间 / 书架 标题下的说明
file: content/site/pages.yaml
format: yaml
hint: 这两页大标题下面那一行字,文字与样式都在这里改
fields:
- label: 瞬间页
name: moments
widget: object
required: false
fields:
- label: 说明文字
name: note
widget: object
required: false
hint: 支持 markdown 与排版类(字体/颜色)
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: English, name: en, widget: markdown, required: false }
- label: 样式
name: style
widget: object
required: false
hint: 留空用全站默认;越界值会被自动夹取(不会破版)
fields:
- { label: 字号(px,11–16), name: size, widget: number, required: false, value_type: int, min: 11, max: 16 }
- label: 颜色
name: color
widget: select
required: false
hint: 只开放灰阶层级,避免正文被染色
options: [ink-1, ink-2, ink-3, ink-4]
- label: 字体
name: font
widget: select
required: false
options: [font-sans, font-serif, font-kai, font-huiwen, font-kuaile, font-xiaowei, font-huangyou, font-mashanzheng, font-liujianmaocao, font-smiley, font-neoxihei]
- label: 书架页
name: books
widget: object
required: false
fields:
- label: 说明文字
name: note
widget: object
required: false
hint: 支持 markdown 与排版类(字体/颜色)
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: English, name: en, widget: markdown, required: false }
- label: 样式
name: style
widget: object
required: false
hint: 留空用全站默认;越界值会被自动夹取(不会破版)
fields:
- { label: 字号(px,11–16), name: size, widget: number, required: false, value_type: int, min: 11, max: 16 }
- label: 颜色
name: color
widget: select
required: false
options: [ink-1, ink-2, ink-3, ink-4]
- label: 字体
name: font
widget: select
required: false
options: [font-sans, font-serif, font-kai, font-huiwen, font-kuaile, font-xiaowei, font-huangyou, font-mashanzheng, font-liujianmaocao, font-smiley, font-neoxihei]
- name: home - name: home
label: 主页设置 label: 主页设置
file: content/site/home.yaml file: content/site/home.yaml
@@ -611,19 +544,11 @@ collections:
fields: fields:
- { label: 中文, name: zh, widget: string } - { label: 中文, name: zh, widget: string }
- { label: English, name: en, widget: string } - { label: English, name: en, widget: string }
- label: 叙述(推荐改这里) - label: 叙述(链接前)
name: story
widget: object
required: false
hint: '整段叙述写成一个字段即可(markdown + 排版类),链接直接写在句子里:[本站源码](https://git.wait4x.online/wait4x/blog) —— 这样就不用再分"链接前/链接后"。留空时才会退回下面的旧字段拼接'
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: English, name: en, widget: markdown, required: false }
- label: 叙述(旧:链接前)
name: blurb name: blurb
widget: object widget: object
required: false required: false
hint: 'legacy:仅当上面的「叙述」为空时才使用(历史字段,为把仓库链接夹在中间而拆成两半)' hint: 富文本(markdown + 排版类),渲染在仓库链接之前
fields: fields:
- { label: 中文, name: zh, widget: string, required: false } - { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false } - { label: 英文, name: en, widget: string, required: false }
+16 -5
View File
@@ -7,7 +7,7 @@
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import TypingLines from './TypingLines.astro'; import TypingLines from './TypingLines.astro';
import { homeAvatar, homeSocials, openSourceCopy } from '../../lib/site-content'; import { homeAvatar, homeSocials, openSourceCopy, renderRich } from '../../lib/site-content';
import { githubUserStats, loadProjects } from '../../lib/projects'; import { githubUserStats, loadProjects } from '../../lib/projects';
import { iconPath } from '../../lib/icons'; import { iconPath } from '../../lib/icons';
import type { Locale } from '../../lib/locale'; import type { Locale } from '../../lib/locale';
@@ -20,6 +20,10 @@ const { locale = 'zh' } = Astro.props;
const avatar = await homeAvatar(); const avatar = await homeAvatar();
const socials = await homeSocials(); const socials = await homeSocials();
const copy = await openSourceCopy(locale); const copy = await openSourceCopy(locale);
// 开篇叙述合一(5j T1):story 非空 → 整段走富文本管线(markdown + 内容净化 + 排版类白名单),
// 链接就是 markdown 普通链接,不再需要 blurb/blurb_after 两半夹一个 repo 链接;
// story 为空 → 回落旧的 blurb + <a> + blurb_after 拼接(旧 yaml 零回归)。
const storyHtml = copy.story.trim() ? await renderRich(copy.story, { inline: true }) : null;
// 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响) // 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响)
let langs: { name: string; count: number; pct: number }[] = []; let langs: { name: string; count: number; pct: number }[] = [];
@@ -105,9 +109,13 @@ const L = {
wait4x<span class="pf-handle mono">@wait4xx</span> wait4x<span class="pf-handle mono">@wait4xx</span>
</p> </p>
<TypingLines lines={lines} /> <TypingLines lines={lines} />
<p class="pf-bio"> {storyHtml ? (
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{' '}{copy.blurbAfter.trim()} <p class="pf-bio" set:html={storyHtml} />
</p> ) : (
<p class="pf-bio">
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{' '}{copy.blurbAfter.trim()}
</p>
)}
<ul class="pf-socials"> <ul class="pf-socials">
{socials.map((s) => ( {socials.map((s) => (
<li> <li>
@@ -239,7 +247,10 @@ const L = {
line-height: 1.65; line-height: 1.65;
color: var(--ink-2); color: var(--ink-2);
} }
.pf-link { /* 5j T1:story 走 markdown 渲染,链接不再是 .pf-link 而是裸 <a>(set:html 内容不参与 scoped),
两路共用同一套链接样式,观感不因改写法而漂移 */
.pf-link,
.pf-bio :global(a) {
color: var(--accent); color: var(--accent);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
+26
View File
@@ -157,6 +157,18 @@ const _exifShapeOk: _PhotoExifOut extends PhotoExif
: never : never
: never = true; : never = true;
/** 页面说明文案的字段形状(瞬间 / 书架共用):文本双语 + 三档样式 */
const pageNoteSchema = z.object({
note: bilingual.optional(),
style: z
.object({
size: z.number().optional(),
color: z.string().optional(),
font: z.string().optional(),
})
.optional(),
});
export const collections = { export const collections = {
// 站点文案单例(后台「页面文案」可改):一文件一集合 // 站点文案单例(后台「页面文案」可改):一文件一集合
// 用 glob(pattern 精确到单文件) 而非 file():Astro 的 file() 把 YAML 顶层对象当“条目映射” // 用 glob(pattern 精确到单文件) 而非 file():Astro 的 file() 把 YAML 顶层对象当“条目映射”
@@ -220,6 +232,18 @@ export const collections = {
}), }),
}), }),
// 主题旋钮(样式即数据 · 层 4):只放 9 组白名单旋钮,钳制与单调性在 src/lib/theme.ts // 主题旋钮(样式即数据 · 层 4):只放 9 组白名单旋钮,钳制与单调性在 src/lib/theme.ts
/**
* 页面说明文案(瞬间 / 书架):标题下那一行字。
* 为什么单列单例而不是塞进 i18n 字典:这是内容不是界面文案,站长该能在后台改。
* `style` 只开放三档(字号 / 灰阶层级 / 字体类),避免变成任意 CSS 入口。
*/
sitePages: defineCollection({
loader: glob({ pattern: 'pages.yaml', base: './content/site' }),
schema: z.object({
moments: pageNoteSchema.optional(),
books: pageNoteSchema.optional(),
}),
}),
siteTheme: defineCollection({ siteTheme: defineCollection({
loader: glob({ pattern: 'theme.yaml', base: './content/site' }), loader: glob({ pattern: 'theme.yaml', base: './content/site' }),
schema: z.object({ schema: z.object({
@@ -270,6 +294,8 @@ export const collections = {
schema: z.object({ schema: z.object({
blurb: bilingual.optional(), blurb: bilingual.optional(),
blurb_after: bilingual.optional(), blurb_after: bilingual.optional(),
/** 开篇叙述合一(5j T1):markdown 富文本,链接内联写;非空则取代 blurb + repo_* + blurb_after 拼接 */
story: bilingual.optional(),
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */ /** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
typography: z typography: z
.object({ .object({
+81
View File
@@ -60,6 +60,8 @@ export async function aboutIntro(locale: Locale): Promise<string> {
export interface OpenSourceCopy { export interface OpenSourceCopy {
blurb: string; blurb: string;
blurbAfter: string; blurbAfter: string;
/** 开篇叙述(5j T1,markdown):非空则取代 blurb + repoUrl/repoLabel + blurbAfter 拼接 */
story: string;
repoUrl: string; repoUrl: string;
repoLabel: string; repoLabel: string;
/** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */ /** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */
@@ -74,6 +76,8 @@ export async function openSourceCopy(locale: Locale): Promise<OpenSourceCopy> {
return { return {
blurb: pickBi(entry?.data.blurb, d.blurb, locale), blurb: pickBi(entry?.data.blurb, d.blurb, locale),
blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale), blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale),
// 5j T1:story 为空时保留旧四件拼接的回落(旧 yaml 零回归)
story: pickBi(entry?.data.story, { zh: '', en: '' }, locale),
repoUrl: pick(entry?.data.repo_url, d.repoUrl), repoUrl: pick(entry?.data.repo_url, d.repoUrl),
repoLabel: pick(entry?.data.repo_label, d.repoLabel), repoLabel: pick(entry?.data.repo_label, d.repoLabel),
pinned: pinned.length ? pinned : [...d.pinned], pinned: pinned.length ? pinned : [...d.pinned],
@@ -160,6 +164,83 @@ export async function renderRich(md: string, opts: { inline?: boolean } = {}): P
return html.replace(/^\s*<p>/i, '').replace(/<\/p>\s*$/i, '').trim(); return html.replace(/^\s*<p>/i, '').replace(/<\/p>\s*$/i, '').trim();
} }
export interface PageNoteStyle {
/** 像素字号(已夹取到 11–16) */
size: number;
/** 已过白名单的 ink 档位(ink-1..ink-4) */
color: string;
/** 已过白名单的排版字体类(font-sans 等) */
font: string;
}
export interface PageNote {
/** 已渲染的行内 HTML(note 走 renderRich inline,避免 <p> 套 <p>) */
html: string;
/** 直接可挂的行内样式串(字号 + 颜色) */
style: string;
/** 字体类名(白名单内),与 style 一起挂到元素上 */
className: string;
resolved: PageNoteStyle;
}
/** 标题下说明文案的默认值(= 改造前硬编码的两句;删 yaml 字段即回到这里) */
const PAGE_NOTE_DEFAULTS = {
moments: {
zh: '零碎的记录:随手拍、一句话感想、开源动态。',
en: 'Fragmented notes: casual shots, one-liners, and open-source updates.',
},
books: {
zh: '点一行展开封面与简介 · 置顶的书默认展开',
en: 'Tap a row for the cover and blurb · pinned books start open',
},
} as const;
/** 颜色白名单:只认灰阶 ink 档位(后台可填任意值,但绝不能把正文染成任意颜色/破对比度) */
const NOTE_COLORS = ['ink-1', 'ink-2', 'ink-3', 'ink-4'] as const;
/** 字号夹取:<11px 读不清、>16px 会盖过正文层级 */
const NOTE_SIZE_MIN = 11;
const NOTE_SIZE_MAX = 16;
const NOTE_SIZE_DEFAULT = 13;
/**
* 页面标题下说明文案(瞬间 / 书架)+ 可选样式。
* 后台可改文字与字号/颜色/字体;样式全部经白名单校验后回落,非法值不会破版:
* · size 夹取 11–16(缺省 13)
* · color 只认 ink-1..ink-4(其它回落 ink-4)
* · font 只认排版类白名单(其它回落 font-sans)
* 无 yaml 字段时文字回落内置默认,与改造前逐字一致(零回归)。
*/
export async function pageNote(
page: 'moments' | 'books',
locale: Locale,
): Promise<PageNote> {
const entry = await getEntry('sitePages', 'pages');
const data = entry?.data?.[page];
const text = pickBi(data?.note, PAGE_NOTE_DEFAULTS[page], locale);
const html = await renderRich(text, { inline: true });
const rawSize = data?.style?.size;
const size =
typeof rawSize === 'number' && Number.isFinite(rawSize)
? Math.min(NOTE_SIZE_MAX, Math.max(NOTE_SIZE_MIN, Math.trunc(rawSize)))
: NOTE_SIZE_DEFAULT;
const rawColor = data?.style?.color?.trim() ?? '';
const color = (NOTE_COLORS as readonly string[]).includes(rawColor) ? rawColor : 'ink-4';
const rawFont = data?.style?.font?.trim() ?? '';
const font = ALLOWED_PAGE_FONTS.has(rawFont) ? rawFont : 'font-sans';
// 字体必须写成 inline font-family:global.css 的 .font-* 类都挂在富文本容器作用域内
// (.prose-*/.rich/.mom-text/.prose-body),裸 .page-note 拿不到,加类无效(实测)
return {
html,
style: `font-size:${size}px; color: var(--${color}); font-family: ${fontVarFor(font)};`,
className: font,
resolved: { size, color, font },
};
}
/** /**
* 页面级排版覆盖(层 2):读单例的 typography 字段,产出该页容器要用的 * 页面级排版覆盖(层 2):读单例的 typography 字段,产出该页容器要用的
* 类名与 CSS 变量(--page-font / --accent / --page-scale)。 * 类名与 CSS 变量(--page-font / --accent / --page-scale)。
+290 -308
View File
@@ -1,36 +1,37 @@
--- ---
/** /**
* 热力图动效方案 v3(Phase 5j)——用户选定方向「F+J」,但要求贪吃蛇能**循环**, * 热力图动效方案预览(Phase 5j):一页并陈 5 个方案,供用户点选。
* 并希望再看几个新变体。故本页给四个蛇家族方案 + J 柱状图参考:
* *
* F1 循环贪吃蛇(GitHub README 蛇的循环逻辑) * 为什么单开预览路由:热力图的"动"是纯审美 + 体感决策,改错要回滚一轮;
* F2 生长蛇(吃得越多身体越长) * 一页五案 + 页顶锚点,用户在任何设备上点开就能比。选定后我把选中方案
* F3 双蛇对开(两头同时吃,交汇于年中) * 落进 Heatmap.astro,然后删掉本路由。
* FJ 蛇 + 月度柱联动(用户要的 F+J 组合)
* J 月度柱状图(单独保留作参考)
* *
* 设计红线:accent 只做高亮不参与数据编码(数据密度仍由 ink 四档承载)、 * 设计红线(五个方案都守):灰阶骨架、只允许 accent 做强调、prefers-reduced-motion
* prefers-reduced-motion 静态化、离开视口即停、零第三方依赖。 * 下一律退化为静态、零第三方依赖、不引入持续 rAF 循环(省电)。
*/ */
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import HeatGrid from '../components/geek/HeatGrid.astro'; import HeatGrid from '../components/geek/HeatGrid.astro';
import { loadHeatmap, type HeatDay } from '../lib/heatmap'; import { loadHeatmap, levelOf, type HeatDay } from '../lib/heatmap';
const { weeks, total } = await loadHeatmap(); const { weeks, total } = await loadHeatmap();
const days: HeatDay[] = weeks.flat(); const days: HeatDay[] = weeks.flat();
const buildTime = new Date().toISOString().slice(0, 10); const buildTime = new Date().toISOString().slice(0, 10);
/* 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数) */ /* ── 连续贡献(本地计算,替代此前构建期抓取的 streak.svg 快照)── */
const countOf = new Map(days.map((d) => [d.date, d.count])); const countOf = new Map(days.map((d) => [d.date, d.count]));
const today = new Date();
const isoOf = (d: Date) => d.toISOString().slice(0, 10); const isoOf = (d: Date) => d.toISOString().slice(0, 10);
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? ''; // 「当前连续」按最后一个有贡献的日子往回数:今天尚未贡献不该让连续归零(GitHub 同口径),
// 若严格按"今天必须是活跃日"计算,早上访问本站会看到 0,与直觉冲突。
let current = 0; let current = 0;
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
if (lastActive) { if (lastActive) {
const start = new Date(`${lastActive}T00:00:00Z`); const start = new Date(`${lastActive}T00:00:00Z`);
for (let i = 0; ; i++) { for (let i = 0; ; i++) {
const d = new Date(start); const d = new Date(start);
d.setUTCDate(start.getUTCDate() - i); d.setUTCDate(start.getUTCDate() - i);
if ((countOf.get(isoOf(d)) ?? 0) > 0) current++; const c = countOf.get(isoOf(d)) ?? 0;
if (c > 0) current++;
else break; else break;
} }
} }
@@ -40,310 +41,262 @@ for (const d of days) {
run = d.count > 0 ? run + 1 : 0; run = d.count > 0 ? run + 1 : 0;
if (run > longest) longest = run; if (run > longest) longest = run;
} }
const bestDay = days.reduce((a, b) => (b.count > a.count ? b : a), days[0]);
/* 月度聚合(J / FJ 用) */
const byMonth = new Map<string, number>();
for (const d of days) {
const key = d.date.slice(0, 7);
byMonth.set(key, (byMonth.get(key) ?? 0) + d.count);
}
const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b));
const maxMonth = Math.max(...months.map(([, v]) => v));
const activeDays = days.filter((d) => d.count > 0).length; const activeDays = days.filter((d) => d.count > 0).length;
const stats = { total, current, longest, activeDays };
const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: bestDay?.date ?? '', activeDays };
--- ---
<BaseLayout title="热力图动效方案 v3 · wait4x" description="贪吃蛇的四个循环变体 + 月度柱状图"> <BaseLayout title="热力图动效方案 · wait4x" description="五个贡献热力图动效方案的并陈预览">
<h1 class="pv-title">热力图:蛇的四个变体</h1> <h1 class="pv-title">热力图:五个动效方案</h1>
<p class="pv-note"> <p class="pv-note">
按你的选择做成 <strong>F + J</strong> 方向,并让蛇<strong>循环</strong>爬(学 GitHub README 蛇的 同一份真实数据(近一年 <strong>{total}</strong> 次贡献 · 活跃 {stats.activeDays} 天 · 当前连续
节奏:吃完一轮 → 停顿 → 清空 → 再来)。真实数据:近一年 <strong>{stats.total}</strong> 次贡献 · <strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天),数据更新于 {buildTime}(构建期快照)。
{stats.activeDays} 天有贡献 · 当前连续 {stats.current} 天 · 数据更新于 {buildTime}。
<br /> <br />
<strong>回字母告诉我选哪个</strong>(F1 / F2 / F3 / FJ / J),也可以说「FJ 但循环慢一点」。 <strong>看完告诉我选哪个</strong>(回「A」「B」「C」「D」「E」,也可以说「C 但速度慢一点」)。
五个方案都守灰阶红线、都尊重「减少动态效果」系统设置。
</p> </p>
<nav class="pv-switch" aria-label="方案跳转"> <nav class="pv-switch" aria-label="方案跳转">
<a href="#f1" class="pv-chip">F1 · 循环贪吃蛇</a> <a href="#ha" class="pv-chip">A · 入场波纹</a>
<a href="#f2" class="pv-chip">F2 · 生长蛇</a> <a href="#hb" class="pv-chip">B · 光标光晕</a>
<a href="#f3" class="pv-chip">F3 · 双蛇对开</a> <a href="#hc" class="pv-chip">C · 年度回放</a>
<a href="#fj" class="pv-chip">FJ · 蛇 + 柱联动</a> <a href="#hd" class="pv-chip">D · 连续电流</a>
<a href="#j" class="pv-chip">J · 月度柱</a> <a href="#he" class="pv-chip">E · 悬停周详情</a>
</nav> </nav>
<!-- ══════ F1 · 循环贪吃蛇 ══════ --> <!-- ═══════════ A · 入场波纹 ═══════════ -->
<section id="f1" class="pv-sec"> <section id="ha" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">F1</span> <span class="pv-tag mono">A</span>
<h2>循环贪吃蛇</h2> <h2>入场波纹</h2>
<p class="pv-pros">蛇沿格子蛇形爬一年,吃到有贡献的日子就点亮;爬到尽头停一拍、清空、重新开始, <p class="pv-pros">滚动到热力图时,格子像水面一样按「周 + 星期」斜向漫过、逐个亮起(约 0.9s)。
<b>无限循环</b>。身体保持 6 格长度(GitHub README 蛇手感)。</p> <b>优点</b>有生命感、不打扰、只播一次;<b>代价</b>纯装饰,不提供额外信息。</p>
</header> </header>
<div class="hm-wrap" data-snake data-body={6}> <div class="hm-wrap" data-cascade>
<HeatGrid weeks={weeks} variant="f1" /> <HeatGrid weeks={weeks} variant="cascade" step={7} />
</div> </div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · 第 <span data-snake-loop>1</span> 轮 ·
<button class="hm-btn" type="button" data-snake-toggle>暂停</button></p>
</section> </section>
<!-- ══════ F2 · 生长蛇 ══════ --> <!-- ═══════════ B · 光标光晕 ═══════════ -->
<section id="f2" class="pv-sec"> <section id="hb" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">F2</span> <span class="pv-tag mono">B</span>
<h2>生长蛇</h2> <h2>光标光晕</h2>
<p class="pv-pros">每吃到一天就长一节(最长 26 节),前半程是细蛇、后半程拖成长尾,吃完循环重置。 <p class="pv-pros">鼠标在网格上移动时,一小片光晕跟着走,被照到的格子更亮,右下角浮出「日期 · 贡献数」。
<b>优点</b>"积累"的隐喻最强;<b>代价</b>尾长时略挡格子。</p> <b>优点</b>能精确读数、零轮播;<b>代价</b>触屏设备没有指针,需退化为点按读数。</p>
</header> </header>
<div class="hm-wrap" data-snake data-body={3} data-grow={26}> <div class="hm-wrap hm-spot" data-spot>
<HeatGrid weeks={weeks} variant="f2" /> <HeatGrid weeks={weeks} variant="spotlight" />
<span class="hm-glow" aria-hidden="true"></span>
</div> </div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · 蛇长 <span data-snake-len>3</span> 节</p> <p class="hm-read mono" data-spot-read>把指针移到格子上 →</p>
</section> </section>
<!-- ══════ F3 · 双蛇对开 ══════ --> <!-- ═══════════ C · 年度回放 ═══════════ -->
<section id="f3" class="pv-sec"> <section id="hc" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">F3</span> <span class="pv-tag mono">C</span>
<h2>双蛇对开</h2> <h2>年度回放</h2>
<p class="pv-pros">两条蛇分别从最早与最新一头出发对向而吃,在年中附近交汇,吃完一起循环。 <p class="pv-pros">拖动滑块从头"重放"这一年,累计贡献数跟着涨;也可点播放自动扫过。
<b>优点</b>更有戏剧性、时间感双向;<b>代价</b>两条同时动,注意力会被分走。</p> <b>优点</b>最有叙事感、能直观感受节奏;<b>代价</b>多一层交互,需要一点耐心。</p>
</header> </header>
<div class="hm-wrap" data-snake data-body={5} data-dual="1"> <div class="hm-wrap" data-scrub>
<HeatGrid weeks={weeks} variant="f3" /> <HeatGrid weeks={weeks} variant="scrub" />
</div>
<div class="hm-ctrl">
<button class="hm-btn" type="button" data-scrub-play>▶ 播放</button>
<input class="hm-range" type="range" min="0" max={weeks.length} value={weeks.length} data-scrub-range aria-label="回放进度" />
<span class="hm-read mono" data-scrub-read>累计 {total} 次 · 第 {weeks.length} 周</span>
</div> </div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span></p>
</section> </section>
<!-- ══════ FJ · 蛇 + 月柱联动 ══════ --> <!-- ═══════════ D · 连续电流 ═══════════ -->
<section id="fj" class="pv-sec"> <section id="hd" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">FJ</span> <span class="pv-tag mono">D</span>
<h2>蛇 + 月度柱联动 <span class="pv-rec">你的选择方向</span></h2> <h2>连续电流</h2>
<p class="pv-pros">蛇每爬进一个月,对应的柱子就同步升起并高亮;爬到年末时十二根柱子全部立起, <p class="pv-pros">当前连续的那几天会有一条光带从最早一格流到最新一格,像电流经过;旁边显示连续天数。
然后蛇回到起点、柱子清零、再来一轮。<b>优点</b>格子与柱状图互相解释,信息与动感兼得。</p> <b>优点</b>把"坚持"这件事讲成故事;<b>代价</b>只覆盖最近一小段,全年的信息仍靠静态格子。</p>
</header> </header>
<div class="hm-wrap" data-snake data-body={6} data-bars-link="1"> <div class="hm-streak">
<HeatGrid weeks={weeks} variant="fj" /> <div class="hm-streak-num">
<strong>{stats.current}</strong>
<span class="mono">连续天数{lastActive ? `(截至 ${lastActive})` : ''}</span>
<span class="hm-streak-sub mono">最长 {stats.longest} 天 · 单日最高 {stats.best} 次</span>
</div>
<div class="hm-wrap" data-streak>
<HeatGrid weeks={weeks} variant="streak" />
</div>
</div> </div>
<ul class="hm-bars" data-months>
{months.map(([m, v], i) => (
<li>
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
<span class="hm-bar-label mono">{m.slice(5)}</span>
</li>
))}
</ul>
<p class="hm-read mono"><span data-snake-read>准备出发…</span></p>
</section> </section>
<!-- ══════ J · 月度柱(单独参考)══════ --> <!-- ═══════════ E · 悬停周详情 ═══════════ -->
<section id="j" class="pv-sec"> <section id="he" class="pv-sec">
<header class="pv-head"> <header class="pv-head">
<span class="pv-tag mono">J</span> <span class="pv-tag mono">E</span>
<h2>月度柱状图</h2> <h2>悬停周详情</h2>
<p class="pv-pros">不带动效时的样子:进视口逐根升起,指针移到柱子上,对应月份的格子同时高亮。 <p class="pv-pros">指针落在任一格上,整列(那一周)轻轻抬起,右侧给出「这一周合计 / 最好的一天」。
<b>可单独用,也可像 FJ 那样与蛇联动。</b></p> <b>优点</b>信息密度最高、动效克制;<b>代价</b>仍是鼠标优先,触屏靠点按。</p>
</header> </header>
<div class="hm-wrap" data-bars> <div class="hm-inspector">
<HeatGrid weeks={weeks} variant="j" /> <div class="hm-wrap" data-insp>
<HeatGrid weeks={weeks} variant="inspector" />
</div>
<aside class="hm-week" data-insp-panel>
<p class="mono">把指针移到任意一周</p>
</aside>
</div> </div>
<ul class="hm-bars" data-bars-chart>
{months.map(([m, v]) => (
<li>
<button class="hm-bar-btn" type="button" data-month={m} aria-label={`${m} 共 ${v} 次`}>
<span class="hm-bar-fill" style={`--h:${Math.round((v / maxMonth) * 100)}%`} />
</button>
<span class="hm-bar-label mono">{m.slice(5)}</span>
</li>
))}
</ul>
</section> </section>
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。"减少动态效果"下全部静态;离开视口自动暂停。</p> <p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。切到"减少动态效果"时五个方案全部退化为静态网格。</p>
<span hidden data-months-map={JSON.stringify(months.map(([m]) => m))} data-weeks={weeks.length}></span>
</BaseLayout> </BaseLayout>
<script> <script>
// ── A · 入场波纹:进视口一次性播放(不进视口不播,省电)──
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const weeksN = Number(document.querySelector<HTMLElement>('[data-weeks]')?.dataset.weeks ?? 53); const cascades = document.querySelectorAll<HTMLElement>('[data-cascade]');
const monthKeys: string[] = JSON.parse( if (!reduce && cascades.length) {
document.querySelector<HTMLElement>('[data-months-map]')?.dataset.monthsMap ?? '[]', const io = new IntersectionObserver(
); (entries) => {
for (const e of entries) {
/** 蛇形路径:按列推进,奇数列自下而上(与 GitHub README 蛇同构) */ if (!e.isIntersecting) continue;
const buildPath = () => { e.target.classList.add('play');
const p: number[] = []; io.unobserve(e.target);
for (let c = 0; c < weeksN; c++) {
const rows = [0, 1, 2, 3, 4, 5, 6];
if (c % 2 === 1) rows.reverse();
for (const r of rows) p.push(c * 7 + r);
}
return p;
};
const PATH = buildPath();
for (const wrap of document.querySelectorAll<HTMLElement>('[data-snake]')) {
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const readEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-read]');
const loopEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-loop]');
const lenEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-len]');
const toggle = wrap.parentElement?.querySelector<HTMLButtonElement>('[data-snake-toggle]');
const barsEl = wrap.parentElement?.querySelector<HTMLElement>('[data-months]');
const bars = barsEl ? [...barsEl.querySelectorAll<HTMLElement>('[data-month-idx]')] : [];
const baseBody = Number(wrap.dataset.body ?? 6);
const growTo = Number(wrap.dataset.grow ?? 0);
const dual = wrap.dataset.dual === '1';
const barsLink = wrap.dataset.barsLink === '1';
let body = baseBody;
let eaten = 0;
let loopNo = 1;
let timer = 0;
let paused = false;
// 双蛇:两条独立游标,反向推进
const cursors = dual ? [0, PATH.length - 1] : [0];
const dirs = dual ? [1, -1] : [1];
const trails: number[][] = cursors.map(() => []);
const paint = () => {
cells.forEach((c) => c.classList.remove('snake-head', 'snake-body', 'snake-eaten'));
trails.forEach((t) => t.forEach((i) => cells[i]?.classList.add('snake-body')));
cursors.forEach((cur, si) => {
const cell = cells[PATH[cur]];
if (cell) cell.classList.add('snake-head');
const e = trails[si];
if (e) e[e.length - 1] = PATH[cur];
void cur;
});
};
const reset = (full: boolean) => {
eaten = 0;
body = baseBody;
cursors.forEach((_, i) => (cursors[i] = i === 0 ? 0 : PATH.length - 1));
trails.forEach((t) => (t.length = 0));
cells.forEach((c) => c.classList.remove('snake-head', 'snake-body', 'snake-eaten'));
if (full) bars.forEach((b) => b.classList.remove('on'));
if (lenEl) lenEl.textContent = String(body);
if (loopEl) loopEl.textContent = String(loopNo);
};
const step = () => {
let moved = false;
cursors.forEach((cur, si) => {
const idx = PATH[cur];
if (idx === undefined) return;
moved = true;
const cell = cells[idx];
if (!cell) return;
// 轨迹队列:保持 body 长度
const trail = trails[si];
trail.push(idx);
if (trail.length > body) {
const dropped = trail.shift();
cells[dropped ?? -1]?.classList.remove('snake-body');
} }
cells.forEach((c) => c.classList.remove('snake-head')); },
cursors.forEach((c2) => cells[PATH[c2]]?.classList.add('snake-head')); { rootMargin: '0px 0px -10% 0px' },
cells[idx].classList.add('snake-body'); );
cascades.forEach((el) => io.observe(el));
const count = Number(cell.dataset.count ?? 0);
if (count > 0) {
cell.classList.add('snake-eaten');
eaten++;
if (growTo) body = Math.min(growTo, body + 1);
if (readEl)
readEl.textContent = `${cell.dataset.date} · 吃到 ${count} 次(累计 ${eaten} 天)`;
}
// 月份联动:柱子按当前列所属月份点亮
if (barsLink && bars.length) {
const col = Math.floor(idx / 7);
const date = cell.dataset.date ?? '';
const mi = monthKeys.indexOf(date.slice(0, 7));
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
}
if (lenEl) lenEl.textContent = String(body);
// 推进游标
const next = cur + dirs[si];
cursors[si] = next;
if (next < 0 || next >= PATH.length) cursors[si] = -1 as number;
});
// 全部游标到端点 → 一轮结束
if (!moved || cursors.every((c) => c < 0)) {
clearInterval(timer);
timer = 0;
if (readEl) readEl.textContent = `第 ${loopNo} 轮完成:吃到 ${eaten} 天`;
loopNo++;
setTimeout(() => {
reset(true);
if (!paused) start();
}, 900);
}
void paint;
};
const start = () => {
if (timer || paused) return;
reset(false);
timer = window.setInterval(step, 26);
};
const stop = () => {
if (timer) clearInterval(timer);
timer = 0;
};
toggle?.addEventListener('click', () => {
paused = !paused;
toggle.textContent = paused ? '继续' : '暂停';
if (paused) stop();
else start();
});
if (reduce) {
// 减少动态:直接展示终态(所有有贡献的格子点亮 + 柱子立起)
cells.forEach((c) => {
if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten');
});
bars.forEach((b) => b.classList.add('on'));
if (readEl) readEl.textContent = '已按"减少动态效果"静态呈现';
} else {
new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) start();
else stop();
}
},
{ threshold: 0.15 },
).observe(wrap);
}
} }
/* J:单独的柱状图(进视口升起 + hover 联动) */ // ── B · 光标光晕:只更新两个 CSS 变量,不跑 rAF 循环 ──
const chart = document.querySelector<HTMLElement>('[data-bars-chart]'); for (const wrap of document.querySelectorAll<HTMLElement>('[data-spot]')) {
if (chart) { const glow = wrap.querySelector<HTMLElement>('.hm-glow');
new IntersectionObserver( const read = document.querySelector<HTMLElement>('[data-spot-read]');
(entries) => { const move = (x: number, y: number) => {
for (const e of entries) if (e.isIntersecting) e.target.classList.add('grow'); const r = wrap.getBoundingClientRect();
}, if (!glow) return;
{ threshold: 0.2 }, glow.style.setProperty('--x', `${x - r.left}px`);
).observe(chart); glow.style.setProperty('--y', `${y - r.top}px`);
const grid = document.querySelector<HTMLElement>('[data-bars]'); glow.style.opacity = '1';
for (const btn of chart.querySelectorAll<HTMLElement>('[data-month]')) { };
const m = btn.dataset.month ?? ''; wrap.addEventListener('pointermove', (e) => move(e.clientX, e.clientY));
const on = () => wrap.addEventListener('pointerleave', () => {
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => if (glow) glow.style.opacity = '0';
c.classList.toggle('hg-month', (c.dataset.date ?? '').startsWith(m)), });
); // 读数:事件委托到格子(点按在触屏也work)
const off = () => grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => c.classList.remove('hg-month')); const show = (cell: HTMLElement) => {
btn.addEventListener('pointerenter', on); if (!read) return;
btn.addEventListener('pointerleave', off); read.textContent = `${cell.dataset.date} · ${cell.dataset.count} 次`;
btn.addEventListener('focus', on); };
btn.addEventListener('blur', off); wrap.addEventListener('pointerover', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) show(t);
});
wrap.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) show(t);
});
}
// ── C · 年度回放:滑块控制显示到第几周 + 累计数 ──
const counts = Array.from(document.querySelectorAll<HTMLElement>('[data-scrub] .hg-c')).reduce<
Record<number, number>
>((acc, cell) => {
const c = Number(cell.dataset.c ?? 0);
acc[c] = (acc[c] ?? 0) + Number(cell.dataset.count ?? 0);
return acc;
}, {});
const weeksTotal = Object.keys(counts).length;
const cum = (upto: number) => {
let sum = 0;
for (let i = 0; i < upto; i++) sum += counts[i] ?? 0;
return sum;
};
for (const wrap of document.querySelectorAll<HTMLElement>('[data-scrub]')) {
const range = document.querySelector<HTMLInputElement>('[data-scrub-range]');
const read = document.querySelector<HTMLElement>('[data-scrub-read]');
const play = document.querySelector<HTMLButtonElement>('[data-scrub-play]');
const apply = (v: number) => {
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
const visible = Number(cell.dataset.c ?? 0) < v;
cell.style.opacity = visible ? '' : '0.06';
}
if (read) read.textContent = `累计 ${cum(v)} 次 · 第 ${v} 周`;
};
range?.addEventListener('input', () => apply(Number(range.value)));
let timer = 0;
play?.addEventListener('click', () => {
if (timer) {
clearInterval(timer);
timer = 0;
play.textContent = '▶ 播放';
return;
}
play.textContent = '⏸ 暂停';
if (!range) return;
range.value = '0';
apply(0);
timer = window.setInterval(() => {
if (!range) return;
const next = Math.min(weeksTotal, Number(range.value) + 2);
range.value = String(next);
apply(next);
if (next >= weeksTotal) {
clearInterval(timer);
timer = 0;
play.textContent = '▶ 播放';
}
}, 60);
});
apply(Number(range?.value ?? weeksTotal));
}
// ── D · 连续电流:最近连续段逐格流光(延迟按位置递增)──
for (const wrap of document.querySelectorAll<HTMLElement>('[data-streak]')) {
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')].filter((c) => Number(c.dataset.count ?? 0) > 0);
const tail = cells.slice(-12);
tail.forEach((c, i) => {
c.style.setProperty('--flow', `${i * 90}ms`);
c.classList.add('hg-flow');
});
wrap.classList.toggle('no-flow', reduce);
}
// ── E · 悬停周详情:抬列 + 面板读数 ──
for (const wrap of document.querySelectorAll<HTMLElement>('[data-insp]')) {
const panel = document.querySelector<HTMLElement>('[data-insp-panel]');
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const byWeek = new Map<number, HTMLElement[]>();
for (const c of cells) {
const w = Number(c.dataset.c ?? 0);
(byWeek.get(w) ?? byWeek.set(w, []).get(w)!).push(c);
} }
const showWeek = (w: number) => {
wrap.querySelectorAll('.hg-c.lift').forEach((c) => c.classList.remove('lift'));
const group = byWeek.get(w);
if (!group) return;
group.forEach((c) => c.classList.add('lift'));
const totalWeek = group.reduce((s, c) => s + Number(c.dataset.count ?? 0), 0);
const best = group.reduce((a, b) => (Number(b.dataset.count ?? 0) > Number(a.dataset.count ?? 0) ? b : a));
const first = group[0]?.dataset.date ?? '';
const last = group[group.length - 1]?.dataset.date ?? '';
if (panel) {
panel.innerHTML =
`<p class="mono">${first} → ${last}</p>` +
`<p class="hm-week-total"><strong>${totalWeek}</strong> 次</p>` +
`<p class="mono hm-week-best">最好的一天 ${best.dataset.date} · ${best.dataset.count} 次</p>`;
}
};
wrap.addEventListener('pointerover', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) showWeek(Number(t.dataset.c ?? 0));
});
wrap.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
if (t) showWeek(Number(t.dataset.c ?? 0));
});
} }
</script> </script>
@@ -364,55 +317,84 @@ const stats = { total, current, longest, activeDays };
.pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; } .pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; }
.pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); } .pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); }
.pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; } .pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; }
.pv-rec { font-size: 11px; color: var(--accent); border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--line)); border-radius: 4px; padding: 0 5px; margin-left: 6px; font-family: var(--font-mono); }
.pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; } .pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; }
.pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); } .pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); }
.pv-pros b { color: var(--ink-2); } .pv-pros b { color: var(--ink-2); }
.pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); } .pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); }
.hm-wrap { overflow-x: auto; padding: 6px 0; }
.hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } .hm-wrap { overflow-x: auto; padding: 4px 0; }
/* ── A:入场波纹 ── */
/* 注意:网格是子组件渲染的,页面级 scoped 样式够不到它内部元素,必须 :global() */
[data-cascade] :global(.hg-c) { opacity: 0.18; }
[data-cascade].play :global(.hg-c) {
animation: hmRise 420ms cubic-bezier(0.22, 0.8, 0.3, 1) var(--d) forwards;
}
@keyframes hmRise {
from { opacity: 0.18; transform: translateY(3px) scale(0.86); }
to { opacity: 1; transform: none; }
}
/* ── B:光标光晕 ── */
.hm-spot { position: relative; isolation: isolate; }
.hm-glow {
position: absolute; inset: 0; pointer-events: none; opacity: 0;
transition: opacity 200ms var(--ease);
background: radial-gradient(90px 90px at var(--x, 50%) var(--y, 50%),
color-mix(in oklab, var(--accent) 26%, transparent), transparent 70%);
mix-blend-mode: multiply;
}
.hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); }
/* ── C:年度回放 ── */
.hm-ctrl { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-2); flex-wrap: wrap; }
.hm-btn { .hm-btn {
font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 3px 10px; cursor: pointer; background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer;
} }
.hm-btn:hover { border-color: var(--accent); } .hm-btn:hover { border-color: var(--accent); }
.hm-range { flex: 1; min-width: 160px; accent-color: var(--accent); }
/* 蛇:头 / 身 / 吃过 */ /* ── D:连续电流 ── */
:global(.snake-head) { .hm-streak { display: flex; flex-direction: column; gap: var(--space-3); }
outline: 2px solid var(--accent); .hm-streak-num { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; }
outline-offset: 1px; .hm-streak-num strong { font-size: 34px; color: var(--accent); line-height: 1; font-variant-numeric: tabular-nums; }
z-index: 2; .hm-streak-num span { font-size: 12.5px; color: var(--ink-3); }
position: relative; .hm-streak-sub { color: var(--ink-4) !important; }
:global(.hg-flow) { position: relative; }
:global(.hg-flow)::after {
content: ''; position: absolute; inset: 0; border-radius: 2px;
background: var(--accent); opacity: 0;
animation: hmFlow 1.6s ease-in-out var(--flow, 0ms) infinite;
} }
:global(.snake-body) { @keyframes hmFlow {
background: color-mix(in oklab, var(--accent) 32%, transparent) !important; 0%, 100% { opacity: 0; }
} 45% { opacity: 0.55; }
:global(.snake-eaten) {
background: var(--accent) !important;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent);
transition: background 180ms var(--ease);
} }
.no-flow :global(.hg-flow)::after { animation: none; }
/* 月度柱 */ /* ── E:悬停周详情 ── */
.hm-bars { .hm-inspector { display: flex; gap: var(--space-4); align-items: flex-start; flex-wrap: wrap; }
display: flex; align-items: flex-end; gap: 6px; list-style: none; .hm-inspector .hm-wrap { flex: 1 1 560px; min-width: 0; }
padding: 0; margin: var(--space-2) 0 0; height: 84px; :global(.hg-c.lift) {
transform: translateY(-2px);
outline: 1px solid color-mix(in oklab, var(--accent) 45%, transparent);
transition: transform 140ms var(--ease);
} }
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; height: 100%; justify-content: flex-end; } .hm-week {
.hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; background: none; border: 0; padding: 0; cursor: pointer; } flex: 0 0 190px; border: 1px solid var(--line); border-radius: 8px;
.hm-bar-fill { padding: var(--space-3); background: var(--bg-soft); font-size: 12.5px; color: var(--ink-3);
display: block; width: 100%; height: 0;
background: var(--ink-4); border-radius: 2px 2px 0 0;
transition: height 420ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease);
} }
.grow .hm-bar-fill { height: var(--h, 0%); } .hm-week p { margin: 0 0 4px; }
/* FJ:蛇爬到该月时柱子升起(由 JS 加 .on) */ .hm-week-total strong { font-size: 22px; color: var(--ink-1); font-variant-numeric: tabular-nums; }
.hm-bar-fill.on { height: var(--h, 0%); background: var(--accent); } .hm-week-best { color: var(--ink-4); }
.hm-bar-btn:hover .hm-bar-fill { background: var(--accent); }
.hm-bar-label { font-size: 10.5px; color: var(--ink-4); }
:global(.hg-month) { background: var(--accent) !important; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.hm-bar-fill { transition: none; } [data-cascade].play :global(.hg-c) { animation: none; opacity: 1; }
:global(.hg-flow)::after { animation: none; }
.hm-glow { display: none; }
}
@media (max-width: 620px) {
.hm-week { flex-basis: 100%; }
} }
</style> </style>
+10 -3
View File
@@ -187,10 +187,17 @@ ok('归档 首年展开/往年折叠 + 月分组', arch.total >= 1 && arch.open
await page.goto(BASE + '/books/', { waitUntil: 'networkidle' }); await page.goto(BASE + '/books/', { waitUntil: 'networkidle' });
const books = await page.evaluate(() => { const books = await page.evaluate(() => {
const all = [...document.querySelectorAll('.year-group')]; // 书架已改版为「抽书阅读」(Phase 5i T7):不再有年份折叠组,
return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length, firstOpen: all[0]?.hasAttribute('open') ?? false }; // 改为每本书一个 details 行;断言 = 至少一行、首行收起、点击能展开出正文。
const all = [...document.querySelectorAll('.book')];
return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length };
}); });
ok('书架 当年展开/往昔折叠', books.total >= 1 && books.open === 1 && books.firstOpen, JSON.stringify(books)); ok('书架 抽书行渲染', books.total >= 1, JSON.stringify(books));
await page.locator('.book').first().locator('summary').click();
await page.waitForTimeout(250);
ok('书架 点击可抽开', (await page.locator('.book[open] .body').count()) === 1);
await page.locator('.book').first().locator('summary').click();
await page.waitForTimeout(200);
// 4) 命令面板 + 搜索 // 4) 命令面板 + 搜索
await page.keyboard.press('Control+k'); await page.keyboard.press('Control+k');