hexo-theme-stellar 2.0.0-rc.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0
4
+
5
+ > 发布日期:2026-09-30
6
+
7
+ ### 新功能
8
+
9
+ - 配置、Collection YAML 与页面 Front Matter 采用声明式严格 Schema,字段类型、枚举与未知字段在构建前由 Doctor 报告来源与字段路径。
10
+ - Post、Topic、Wiki、Notebook、Note 与索引页共用同一内容模型与构建管线;`listed/searchable` 分别控制聚合展示与搜索收录,Article 与 Notebook 的列表、横幅、许可、分享、标签和排序默认值收敛到内容模型。
11
+ - 页面外壳统一为 Topbar、Leftbar、Rightbar Region,Brand、Menu、Widget、Footer 和 13 个 Profile 使用结构化配置与明确的覆盖语义;Leftbar 菜单支持 1–5 列,Tree、Linklist、Related 与右栏目录支持折叠,右栏随左栏收起显示紧凑目录。
12
+ - 新增 `card/glass/minimal/flat` 外观预设、明暗模式切换、颜色/渐变/排版/形状/背景设置,以及独立设置页。
13
+ - 浏览器功能改为原生 ES 模块按需加载,搜索、评论、动态效果、数据服务与请求缓存拥有明确的加载、取消与卸载生命周期;共享脚本、样式与图标不再逐页内联。
14
+ - 搜索改为注册式 provider(内置 `local` 与 `algolia`),入口位于 Leftbar Brand;本地搜索按章节组织结果并分批查询、渲染,浮层带开合转场与滚动条槽位补位,支持 `Command+K` / `Ctrl+K`。
15
+ - Wiki Hero 支持图片与视频背景、滚动视差,以及 `galaxy`、`light-rays`、`ferrofluid`、`Strands` 可选效果;标签目录新增 `gist`。
16
+ - 图片在 `hexo server` / `hexo generate` 时增量提取尺寸与 HSLA 平均色并持久缓存,补全生成 HTML 且不修改 Markdown 或原图;新增 `hexo stellar images`(含 `--dry-run/--page/--refresh`)。
17
+ - 新增 `hexo stellar doctor` 与 `hexo stellar new note`;目录驱动的完整 Blueprint 改由 Stellar Examples 仓库维护。
18
+ - 文章横幅背景、访客头像镜像 `services.gravatar.base_url`、Reveal 的 `duration/interval/distance/blur` 与卡片 Spotlight/Tilt 均可独立配置。
19
+
20
+ ### 行为与样式变化
21
+
22
+ - 内容横幅与列表共用页面 `cover`;`article.banner.background`(默认 `true`)与 Front Matter `banner.background: false` 控制内容页横幅是否使用封面作为背景,关闭时只影响内容页,列表卡片、SEO 与分享仍使用 `cover`。博客列表页附加导航默认开启。
23
+ - 笔记本总索引卡片复用 Wiki 卡片的封面与项目栏视觉:`listing.cover` 作为封面、`listing.headline` 只取显式配置的大标题、`listing.caption` 作为项目副标题,未配置封面时降级为纯色卡片。
24
+ - 正文图片统一 `max-width: 100%` 与 `height: auto`,按原图比例约束高度;图片元数据只采集会被主题消费的图片,分享二维码、评论、侧边栏与页脚等页面框架图片不触发下载或写入缓存。
25
+ - 侧栏集合、Navbar、Dropdown、Grid、文章卡片、下载按钮与 Topbar 对齐改用统一的形状与状态反馈;设置页、单选框、标题装饰、导航下划线、目录指示条与移动端留白一并收敛。
26
+ - 运行环境升级为 Node.js 22+、Hexo 8+;源码读取兼容 Windows CRLF 与 BOM,避免 Front Matter 整体丢失。
27
+
28
+ ### 升级注意(配置变更与破坏性改动)
29
+
30
+ - v1 的 `logo/menubar/site_tree/style/tag_plugins/plugins/data_services/search.service` 等结构不会被兼容读取;请按[公开迁移说明](https://xaoxuu.com/wiki/stellar/migration/v1-to-v2/)逐项迁移到 `topbar/leftbar/rightbar/footer/profiles/appearance/tags/features/services/search`。
31
+ - Collection 与 Front Matter 需要迁移为 `collection/route/navigation/listing/visibility/banner/article/comments/render/seo` 等分组字段;页面 `banner.image` 改为顶层 `cover`,横幅不再使用独立比例配置。未知字段、错误类型与非法枚举会在构建早期失败。
32
+ - 分享服务由 `[wechat, weibo, email, link]` 改为 `qrcode/weibo/x/telegram/whatsapp/email`,配置位置为 `article.footer.share` 或内容 `footer.share`。
33
+ - `plugins.card_hover.enable` 改为独立的 `features.card_hover.spotlight` 与 `features.card_hover.tilt`,默认均为 `false`;`plugins.adaptive_text` 改为固定开启的内置行为。
34
+ - Site Info、Rating、Vote 的服务 endpoint 默认留空,需要自行部署并填写地址;依赖这些服务的站点升级后请显式配置。
35
+ - `features.partial_navigation.enabled` 与 `features.image_optimization.enabled` 默认开启,首次处理远程图片会增加构建时间;Hero 背景默认启用 0.2 强度视差,设为 0 关闭位移。
36
+ - `about`、`users` 标签以及对应的 `chat_users.yml`、`fcircle` 适配器已移除;`tip` 改为单标签 `{% tip 词句 pop:注解 %}`,`quot` 支持直接图标键与 `color`。
37
+ - 博客列表页附加导航 `profiles.blog_index.listing_nav.enabled` 默认开启;语言文件不再使用 `settings.profile.provider_managed` 与 `settings.profile.provider_unknown`。
38
+ - 浏览器后处理必须原样保留 `public/js/runtime/**/*.js` 的 ESM 语义、相对 import 与 JavaScript MIME 类型。
39
+ - v2 源码从仓库 `main` 安装并需要执行 `npm install --prefix themes/stellar`;npm 安装的版本以实际公开版本为准。
40
+
41
+ Full Changelog: [1.44.0...2.0.0](https://github.com/xaoxuu/hexo-theme-stellar/compare/1.44.0...2.0.0)
42
+
3
43
  ## 2.0.0-rc.6
4
44
 
5
45
  > 发布日期:2026-09-18
package/_config.yml CHANGED
@@ -210,7 +210,7 @@ profiles:
210
210
  # 配置博客列表页的附加导航。
211
211
  listing_nav:
212
212
  # 控制是否显示附加导航。
213
- enabled: false
213
+ enabled: true
214
214
  # 设置附加导航标签;空数组表示不添加标签。
215
215
  tabs: []
216
216
 
@@ -460,6 +460,11 @@ article:
460
460
  style: tech
461
461
  # 设置段落首行缩进策略;可选值为 auto、always 或 never。
462
462
  paragraph_indent: auto
463
+ # 配置文章页顶部横幅。
464
+ banner:
465
+ # 控制文章页横幅是否使用页面 cover 作为背景图;false 表示只显示纯色横幅。
466
+ # 可在文章 Front Matter 用 banner.background 覆盖此默认值。
467
+ background: true
463
468
  # 配置文章列表卡片。
464
469
  listing:
465
470
  # 设置置顶文章布局;可选值为 carousel 或 flat。
package/languages/en.yml CHANGED
@@ -98,8 +98,6 @@ settings:
98
98
  url: URL
99
99
  save: Save
100
100
  logout: Log out
101
- provider_managed: '%s manages comment identity outside the theme. Profile editing and logout are unavailable here.'
102
- provider_unknown: The current comment provider
103
101
  cache:
104
102
  title: Cache
105
103
  search: Search cache
@@ -98,8 +98,6 @@ settings:
98
98
  url: URL
99
99
  save: 保存
100
100
  logout: 退出登录
101
- provider_managed: '%s 的评论身份不由主题管理,无法在此编辑或退出。'
102
- provider_unknown: 当前评论服务
103
101
  cache:
104
102
  title: 缓存
105
103
  search: 搜索缓存
@@ -98,8 +98,6 @@ settings:
98
98
  url: URL
99
99
  save: 儲存
100
100
  logout: 登出
101
- provider_managed: '%s 的留言身分不由主題管理,無法在此編輯或登出。'
102
- provider_unknown: 目前留言服務
103
101
  cache:
104
102
  title: 快取
105
103
  search: 搜尋快取
@@ -18,6 +18,15 @@ var banner = Object.assign({}, page.author
18
18
  : postViewModel ? postViewModel.item.presentation.banner : pageConfig.banner || {})
19
19
 
20
20
  var bannerImage = page.author ? page.author.cover : renderViewModel ? renderViewModel.item.cover : pageConfig.cover
21
+ // 横幅背景图由 Front Matter banner.background 覆盖全局默认值;作者页不受全局开关影响。
22
+ // 关闭时仅影响内容页横幅,列表卡片、SEO 与分享仍使用 cover。
23
+ var bannerBackground = banner.background
24
+ if (bannerBackground == null && !page.author) {
25
+ bannerBackground = stellar_config('article.banner.background')
26
+ }
27
+ if (bannerBackground === false) {
28
+ bannerImage = ''
29
+ }
21
30
 
22
31
  // 标题
23
32
  if (banner.headline == null) {
@@ -1,17 +1,37 @@
1
- <%# 笔记本信息卡片 %>
1
+ <%# 笔记本卡片:复用 Wiki 卡片的 .wiki-card 视觉(cover 同图模糊层、底部信息层与项目栏) %>
2
2
  <% var listing = render.listing %>
3
- <article class="md-text">
4
- <div class="preview">
5
- <% if (listing.icon) { %>
6
- <img src="<%= listing.icon %>" alt="icon"/>
7
- <% } else { %>
8
- <%- icon('default:documents') %>
9
- <% } %>
10
- </div>
11
- <div class="excerpt">
12
- <h2 class="post-title"><%= listing.headline || listing.name %></h2>
13
- <% if (listing.description) { %>
14
- <p><%= listing.description %></p>
3
+ <% var cover = listing.cover || '' %>
4
+ <% var headline = listing.headline || '' %>
5
+ <% var caption = listing.caption || '' %>
6
+ <%# 整个属性必须原样输出:EJS 转义输出会把属性里的引号变成实体,属性值带上一对引号,自适应颜色就失效 %>
7
+ <% var adaptiveAttr = cover ? ' data-text-adaptive="split"' : '' %>
8
+ <article class="wiki-card-content">
9
+ <div class="wiki-card-cover<%= cover ? '' : ' no-cover' %>" position="bottom">
10
+ <% if (cover) { %>
11
+ <img src="<%= cover %>" alt=""/>
15
12
  <% } %>
13
+ <div class="wiki-card-info" position="bottom"<%- adaptiveAttr %>>
14
+ <%# headline 是可选的大标题,只在显式配置时渲染;name 固定在底部项目栏 %>
15
+ <% if (headline) { %>
16
+ <div class="wiki-card-summary">
17
+ <h2 class="headline"><%= headline %></h2>
18
+ </div>
19
+ <% } %>
20
+ <div class="wiki-project">
21
+ <div class="project-icon">
22
+ <% if (listing.icon) { %>
23
+ <img src="<%= listing.icon %>" alt=""/>
24
+ <% } else { %>
25
+ <%- icon('default:documents') %>
26
+ <% } %>
27
+ </div>
28
+ <div class="project-text">
29
+ <div class="project-name"><%= listing.name %></div>
30
+ <% if (caption) { %>
31
+ <div class="project-subtitle"><%= caption %></div>
32
+ <% } %>
33
+ </div>
34
+ </div>
35
+ </div>
16
36
  </div>
17
37
  </article>
@@ -1,10 +1,12 @@
1
- <%# 笔记本列表页主体部分 %>
2
- <% for (var notebook of page.notebookIndex.items) { %>
3
- <div class="post-list wiki">
1
+ <%# 笔记本列表页主体部分:复用 Wiki 列表的网格与卡片组件 %>
2
+ <div class="post-list wiki">
3
+ <% for (var notebook of page.notebookIndex.items) { %>
4
+ <% var cover = notebook.cover || '' %>
5
+ <% var coverStyle = cover ? ` style="--cover-url:url('${escape_html(cover.replace(/'/g, '%27'))}')"` : '' %>
4
6
  <div class="post-card-wrap<%= scrollreveal(' ') %>">
5
- <a class="<%- ui_classes('post-card wiki', 'hoverCard') %>" href="<%= pretty_url(notebook.href) %>">
7
+ <a class="<%- ui_classes('wiki-card', 'hoverCard', cover ? '' : 'no-cover') %>"<%- coverStyle %> href="<%= pretty_url(notebook.href) %>">
6
8
  <%- include('_partial/main/notebook/notebook_card', { render: {listing: notebook} }) %>
7
9
  </a>
8
10
  </div>
9
- </div>
10
- <% } %>
11
+ <% } %>
12
+ </div>
@@ -29,6 +29,7 @@ function linkAttributes(url) {
29
29
  <h1><%- escape_html(__('settings.title')) %></h1>
30
30
  </header>
31
31
 
32
+ <% if (providerSupported) { %>
32
33
  <section class="settings-section" aria-labelledby="settings-profile">
33
34
  <h2 id="settings-profile"><%- escape_html(__('settings.profile.title')) %></h2>
34
35
  <div class="settings-section__body">
@@ -38,9 +39,6 @@ function linkAttributes(url) {
38
39
  <span data-profile-avatar-fallback hidden><%- icon('default:profile', '', true) %></span>
39
40
  </div>
40
41
  <div class="settings-profile__content">
41
- <% if (!providerSupported) { %>
42
- <p data-provider-note class="settings-provider-note"><%- escape_html(__('settings.profile.provider_managed', provider || __('settings.profile.provider_unknown'))) %></p>
43
- <% } else { %>
44
42
  <form data-profile-form class="settings-form" novalidate>
45
43
  <label>
46
44
  <span><%- escape_html(__('settings.profile.nickname')) %></span>
@@ -60,11 +58,11 @@ function linkAttributes(url) {
60
58
  </div>
61
59
  </form>
62
60
  <p class="settings-result" data-profile-status role="status" aria-live="polite"></p>
63
- <% } %>
64
61
  </div>
65
62
  </div>
66
63
  </div>
67
64
  </section>
65
+ <% } %>
68
66
 
69
67
  <section class="settings-section" aria-labelledby="settings-cache">
70
68
  <h2 id="settings-cache"><%- escape_html(__('settings.cache.title')) %></h2>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hexo-theme-stellar",
3
- "version": "2.0.0-rc.6",
3
+ "version": "2.0.0",
4
4
  "description": "An all-in-one Hexo theme for blogs, project documentation, knowledge bases, series, and notebooks.",
5
5
  "main": "package.json",
6
6
  "scripts": {
@@ -137,9 +137,13 @@ module.exports = (ctx, pipeline) => {
137
137
  id: collectionId,
138
138
  href: baseDir,
139
139
  name: identity.name,
140
- headline: identity.headline,
140
+ // 大标题只取显式配置的 headline;identity.headline 会回退到 name,
141
+ // 直接透传会让「删掉 headline」仍然渲染出同名大标题。
142
+ headline: typeof collectionConfig.headline === "string" ? collectionConfig.headline : "",
143
+ caption: identity.tagline || identity.description,
141
144
  description: identity.description,
142
145
  icon: identity.icon || "",
146
+ cover: collection.cover || "",
143
147
  order: collection.listing.order,
144
148
  listed: collection.visibility.listed !== false,
145
149
  navigation: {
@@ -13,6 +13,20 @@ const MAX_BYTES = 32 * 1024 * 1024;
13
13
  const RETRY_DELAY = 24 * 60 * 60 * 1000;
14
14
  const stores = new WeakMap();
15
15
 
16
+ // The store serves two runtime consumers: article content images (dimensions
17
+ // for aspect ratio) and average-color backgrounds for adaptive text. Scan the
18
+ // rendered article region plus the adaptive-text owners, matching
19
+ // source/js/plugins/adaptive-text.js, so page chrome (share QR codes, comments,
20
+ // sidebars, footers) never triggers downloads or store entries.
21
+ const METADATA_IMAGE_SELECTOR = [
22
+ 'article.md-text.content img',
23
+ '.banner img',
24
+ '.cover img',
25
+ '.wiki-card-cover img',
26
+ '.pin-slide img',
27
+ '.wiki-hero img'
28
+ ].join(', ');
29
+
16
30
  function metadataPath(ctx) {
17
31
  return path.join(ctx.source_dir || path.join(ctx.base_dir || process.cwd(), 'source'), '_data', 'caches', DATA_FILE);
18
32
  }
@@ -91,12 +105,13 @@ function complete(entry) {
91
105
  return (dimensionsReady(entry?.dimensions) || entry?.dimensions?.status === 'unsupported') && colorDone;
92
106
  }
93
107
 
94
- // Scan rendered img elements, so Markdown, tags, Front Matter and data-driven
95
- // covers use their actual output without maintaining another source-field list.
108
+ // Scan rendered img elements in scope for the store, so Markdown, tags, Front
109
+ // Matter and data-driven covers use their actual output without maintaining
110
+ // another source-field list, while page chrome stays out of scope.
96
111
  function collectImages(html, base) {
97
112
  const urls = new Set();
98
113
  const $ = load(html, null, false);
99
- $('img').each((_, element) => {
114
+ $(METADATA_IMAGE_SELECTOR).each((_, element) => {
100
115
  const img = $(element);
101
116
  const url = imageUrl(img.attr('data-src') || img.attr('src'), base);
102
117
  if (url) urls.add(url);
@@ -247,7 +262,7 @@ function enrichImages(html, ctx, locals = {}) {
247
262
  let changed = false;
248
263
  const autoRatio = (ctx.stellar?.config?.features?.lazyLoading?.autoAspectRatio
249
264
  ?? ctx.theme?.config?.features?.lazy_loading?.auto_aspect_ratio) !== false;
250
- $('img').each((_, image) => {
265
+ $(METADATA_IMAGE_SELECTOR).each((_, image) => {
251
266
  const img = $(image);
252
267
  const src = img.attr('data-src') || img.attr('src');
253
268
  const entry = store.images[imageUrl(src, base)];
@@ -7,6 +7,11 @@ const snapshots = new WeakMap();
7
7
 
8
8
  function sourcePathForData(key) { return `source/_data/${key}.yml`; }
9
9
  function sourcePathForPage(page) { return page.source ? `source/${page.source}` : (page.path || "<page>"); }
10
+ // hexo-front-matter 只识别 LF;Hexo 宿主通过 hexo-fs 读取源码时会去除 BOM 并规范化 CRLF,
11
+ // 直接读取源码必须保持一致,否则 Windows 上的 CRLF 内容会丢失全部 Front Matter。
12
+ function readSourceText(file) {
13
+ return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
14
+ }
10
15
  function sourceCache(ctx) {
11
16
  if (!snapshots.has(ctx)) snapshots.set(ctx, new Map());
12
17
  return snapshots.get(ctx);
@@ -29,7 +34,7 @@ function readFrontMatter(ctx, page) {
29
34
  const stamp = [stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
30
35
  const previous = cache.get(page.source);
31
36
  if (previous?.stamp === stamp) return previous.config;
32
- const config = frontMatter.parse(fs.readFileSync(file, "utf8"));
37
+ const config = frontMatter.parse(readSourceText(file));
33
38
  delete config._content;
34
39
  cache.set(page.source, { stamp, config });
35
40
  return config;
@@ -212,6 +212,7 @@ const FRONT_MATTER_TARGET_DEFINITIONS = [
212
212
  ["tagline", ["string", "null"], literal(null)],
213
213
  ["banner", "object", literal({}), { boundary: "sealed" }],
214
214
  ["banner.enabled", ["boolean", "null"], literal(null)],
215
+ ["banner.background", ["boolean", "null"], literal(null)],
215
216
  ["banner.avatar", ["string", "null"], literal(null)],
216
217
  ["banner.headline", ["string", "null"], literal(null)],
217
218
  ["banner.tagline", ["string", "null"], literal(null)],
@@ -233,6 +233,7 @@ function bannerSchema(factory) {
233
233
  const { field, object } = factory;
234
234
  return object({
235
235
  enabled: field("boolean", { example: true }),
236
+ background: field("boolean", { example: true }),
236
237
  avatar: field("string", { example: "/avatar.webp" }),
237
238
  headline: field("string", { example: "开始使用 Stellar" }),
238
239
  tagline: field("string", { example: "十分钟搭好站点" })
@@ -20,6 +20,7 @@ hr
20
20
 
21
21
  img
22
22
  max-width: 100%
23
+ height: auto
23
24
 
24
25
  li
25
26
  font-size: 'calc(%s - 1px)' % var(--fs-content)
@@ -9,6 +9,8 @@ input.copy-area
9
9
  font-weight: $fw-700
10
10
  color: var(--text-p3)
11
11
 
12
+ // 圆环与圆点画在同一个背景层上:元素落在非整数像素位置时,border 圆环与内部盒子会被
13
+ // 分别对齐,Firefox 下圆点最多偏离圆心 1 个物理像素,因此不用 border 绘制圆环。
12
14
  input.ui-radio
13
15
  -webkit-appearance: none
14
16
  -moz-appearance: none
@@ -19,25 +21,20 @@ input.ui-radio
19
21
  width: 16px
20
22
  height: 16px
21
23
  margin: 0
22
- border: 2px solid var(--text-p3)
24
+ border: 0
23
25
  border-radius: 50%
24
26
  corner-shape: round
25
- background: none
27
+ --radio-ring: var(--text-p3)
28
+ --radio-dot-size: 0
29
+ // 外环:16px 内取 2px 环宽(半径 8px → 6px);圆点:8px(半径 4px)
30
+ background-image: radial-gradient(circle closest-side, transparent 0 5.875px, var(--radio-ring) 6.125px 100%), radial-gradient(circle closest-side, var(--theme) 0 3.875px, transparent 4.125px)
31
+ background-position: center, center
32
+ background-repeat: no-repeat, no-repeat
33
+ background-size: 16px 16px, var(--radio-dot-size) var(--radio-dot-size)
26
34
  cursor: pointer
27
- &:before
28
- content: ""
29
- display: block
30
- width: 8px
31
- height: 8px
32
- margin: 2px
33
- border-radius: 50%
34
- background: var(--theme)
35
- transform: scale(0)
36
35
  &:checked
37
- border-color: var(--theme)
38
- &:checked:before
39
- background: var(--theme)
40
- transform: scale(1)
36
+ --radio-ring: var(--theme)
37
+ --radio-dot-size: 8px
41
38
  &:focus-visible
42
39
  outline: 2px solid var(--theme)
43
40
  outline-offset: 2px
@@ -54,7 +54,6 @@
54
54
  grid-column: 1 / -1
55
55
  min-width: 0
56
56
 
57
- .settings-provider-note,
58
57
  .settings-empty
59
58
  margin: 0
60
59
  color: var(--text-p2)
@@ -252,10 +252,62 @@ html[data-leftbar-state='collapsed'] .site-shell:not([data-drawer='leftbar']) .s
252
252
  background: var(--card)
253
253
  box-shadow: $boxshadow-card-float
254
254
  overflow: hidden
255
+ // 打开动画随 [open] 触发;关闭动画由 main.js 添加 .is-closing 并在动画结束后调用 close()
256
+ &[open]
257
+ animation: search-dialog-in 0.24s ease-out backwards
258
+ &.is-closing
259
+ animation: search-dialog-out 0.18s ease-in both
255
260
  &::backdrop
256
261
  background: rgba(black, .38)
257
262
  backdrop-filter: blur(8px)
258
263
  -webkit-backdrop-filter: blur(8px)
264
+ animation: search-backdrop-in 0.24s ease-out backwards
265
+ &.is-closing::backdrop
266
+ animation: search-backdrop-out 0.18s ease-in both
267
+
268
+ @keyframes search-dialog-in
269
+ from
270
+ opacity: 0
271
+ transform: translateY(8px) scale(.97)
272
+ to
273
+ opacity: 1
274
+ transform: none
275
+
276
+ @keyframes search-dialog-out
277
+ from
278
+ opacity: 1
279
+ transform: none
280
+ to
281
+ opacity: 0
282
+ transform: translateY(8px) scale(.97)
283
+
284
+ @keyframes search-backdrop-in
285
+ from
286
+ opacity: 0
287
+ to
288
+ opacity: 1
289
+
290
+ @keyframes search-backdrop-out
291
+ from
292
+ opacity: 1
293
+ to
294
+ opacity: 0
295
+
296
+ @keyframes search-dialog-in-fluid
297
+ from
298
+ opacity: 0
299
+ transform: translateY(16px)
300
+ to
301
+ opacity: 1
302
+ transform: none
303
+
304
+ @keyframes search-dialog-out-fluid
305
+ from
306
+ opacity: 1
307
+ transform: none
308
+ to
309
+ opacity: 0
310
+ transform: translateY(16px)
259
311
 
260
312
  .search-dialog__panel
261
313
  box-sizing: border-box
@@ -359,6 +411,9 @@ html[data-leftbar-state='collapsed'] .site-shell:not([data-drawer='leftbar']) .s
359
411
 
360
412
  html[data-search-open]
361
413
  overflow: hidden
414
+ // 锁定滚动会移除常驻滚动条槽位,按 main.js 实测宽度补位,避免页面内容宽度变化后重排
415
+ body
416
+ padding-right: var(--stellar-scrollbar-gutter, 0px)
362
417
 
363
418
  @media screen and (max-width: $device-tablet)
364
419
  .search-dialog
@@ -367,6 +422,11 @@ html[data-search-open]
367
422
  max-height: none
368
423
  border: 0
369
424
  border-radius: 0
425
+ // 全屏浮层不做缩放,避免露出被裁切的边缘
426
+ &[open]
427
+ animation-name: search-dialog-in-fluid
428
+ &.is-closing
429
+ animation-name: search-dialog-out-fluid
370
430
  .search-dialog__panel
371
431
  height: 100%
372
432
  padding: unquote('max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left))')
@@ -375,6 +435,10 @@ html[data-search-open]
375
435
 
376
436
  @media (prefers-reduced-motion: reduce)
377
437
  .search-dialog,
378
- .search-dialog::backdrop
438
+ .search-dialog[open],
439
+ .search-dialog.is-closing,
440
+ .search-dialog::backdrop,
441
+ .search-dialog[open]::backdrop,
442
+ .search-dialog.is-closing::backdrop
379
443
  animation: none
380
444
  transition: none
@@ -185,7 +185,9 @@
185
185
  height: 2.5rem
186
186
  app-icon()
187
187
  background: var(--block)
188
- color: var(--text-p2)
188
+ // 未配置 icon 时的默认图标:颜色跟随封面平均色自适应(深色封面显示为接近白色)。
189
+ // --text-banner 只在有封面时由 adaptive-text 写入 .wiki-card-info,无封面时回退到次要文字色。
190
+ color: var(--text-banner, var(--text-p2))
189
191
  img
190
192
  width: 100%
191
193
  height: 100%
package/source/js/main.js CHANGED
@@ -103,6 +103,10 @@ const rightbarDrawerQuery = '(max-width: 1180px)';
103
103
  let shellDrawerTrigger = null;
104
104
  let searchDialogTrigger = null;
105
105
  let searchDialogRestoreFocus = true;
106
+ // 退场动画的安全兜底时长,需大于 search.styl 中 search-dialog-out 的 0.18s
107
+ const searchCloseFallbackDelay = 260;
108
+ let searchCloseTimer = null;
109
+ let searchCloseListener = null;
106
110
  let shellInputModality = 'pointer';
107
111
 
108
112
  function regionElement(region) {
@@ -244,6 +248,11 @@ function applySearchScope(dialog, value, refresh = true) {
244
248
  }
245
249
  }
246
250
 
251
+ function searchMotionEnabled() {
252
+ if (typeof window.matchMedia !== 'function') return true;
253
+ return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
254
+ }
255
+
247
256
  function configureSearchScope(dialog, trigger) {
248
257
  const group = dialog?.querySelector('.search-dialog__scope');
249
258
  if (!group) return false;
@@ -265,10 +274,30 @@ function configureSearchScope(dialog, trigger) {
265
274
  return true;
266
275
  }
267
276
 
268
- function closeSearch() {
269
- const dialog = searchDialogElement();
270
- if (!dialog?.open) return;
271
- dialog.close();
277
+ function clearSearchClosePending(dialog) {
278
+ if (searchCloseTimer !== null) {
279
+ clearTimeout(searchCloseTimer);
280
+ searchCloseTimer = null;
281
+ }
282
+ if (searchCloseListener) {
283
+ dialog.removeEventListener('animationend', searchCloseListener);
284
+ searchCloseListener = null;
285
+ }
286
+ dialog.classList.remove('is-closing');
287
+ }
288
+
289
+ // 锁定页面滚动会移除常驻滚动条,先把槽位宽度写进 CSS 变量供补位使用
290
+ function syncSearchScrollbarGutter() {
291
+ const root = document.documentElement;
292
+ if (root.hasAttribute('data-search-open')) return;
293
+ const gutter = Math.max(0, window.innerWidth - root.clientWidth);
294
+ root.style.setProperty('--stellar-scrollbar-gutter', gutter + 'px');
295
+ }
296
+
297
+ // 关闭动画播放期间浮层仍处于打开状态,退出动画结束或兜底超时后才真正 close 并恢复焦点
298
+ function finishSearchClose(dialog) {
299
+ clearSearchClosePending(dialog);
300
+ if (dialog.open) dialog.close();
272
301
  document.documentElement.removeAttribute('data-search-open');
273
302
  if (searchDialogRestoreFocus && searchDialogTrigger?.focus) searchDialogTrigger.focus();
274
303
  else if (searchDialogTrigger?.blur) searchDialogTrigger.blur();
@@ -276,12 +305,29 @@ function closeSearch() {
276
305
  searchDialogRestoreFocus = true;
277
306
  }
278
307
 
308
+ function closeSearch() {
309
+ const dialog = searchDialogElement();
310
+ if (!dialog?.open || searchCloseTimer !== null) return;
311
+ if (!searchMotionEnabled()) {
312
+ finishSearchClose(dialog);
313
+ return;
314
+ }
315
+ searchCloseListener = function (event) {
316
+ if (event.animationName !== 'search-dialog-out') return;
317
+ finishSearchClose(dialog);
318
+ };
319
+ dialog.classList.add('is-closing');
320
+ dialog.addEventListener('animationend', searchCloseListener);
321
+ searchCloseTimer = setTimeout(function () { finishSearchClose(dialog); }, searchCloseFallbackDelay);
322
+ }
323
+
279
324
  function openSearch(trigger, restoreFocus = true) {
280
325
  const dialog = searchDialogElement();
281
326
  const input = dialog?.querySelector('.search-input');
282
327
  const wrapper = dialog?.querySelector('.search-wrapper');
283
328
  const result = dialog?.querySelector('.search-result');
284
329
  if (!dialog || !input) return;
330
+ clearSearchClosePending(dialog);
285
331
  searchDialogTrigger = trigger || document.activeElement;
286
332
  searchDialogRestoreFocus = restoreFocus;
287
333
  input.value = '';
@@ -294,6 +340,7 @@ function openSearch(trigger, restoreFocus = true) {
294
340
  }
295
341
  result?.replaceChildren();
296
342
  if (!dialog.open) dialog.showModal();
343
+ syncSearchScrollbarGutter();
297
344
  document.documentElement.setAttribute('data-search-open', '');
298
345
  input.focus();
299
346
  }
@@ -90,7 +90,6 @@ export function mount(root, context) {
90
90
  const provider = String(page.dataset.provider || '').toLowerCase();
91
91
  const api = globalThis.window?.stellarProfile;
92
92
  const form = page.querySelector('[data-profile-form]');
93
- const note = page.querySelector('[data-provider-note]');
94
93
  const status = page.querySelector('[data-profile-status]');
95
94
  const avatar = page.querySelector('[data-profile-avatar]');
96
95
  const avatarFallback = page.querySelector('[data-profile-avatar-fallback]');
@@ -174,8 +173,8 @@ export function mount(root, context) {
174
173
  if (urlInput) urlInput.value = identity.url || '';
175
174
  if (form) form.hidden = !supported;
176
175
  if (logoutButton) logoutButton.hidden = !supported;
177
- if (note) note.hidden = supported;
178
- renderAvatar(identity);
176
+ if (supported) renderAvatar(identity);
177
+ else if (avatar) avatar.hidden = true;
179
178
  }
180
179
 
181
180
  function onSubmit(event) {
@@ -195,7 +195,10 @@ export function readImageColor(image, options = {}) {
195
195
  function onLoad() {
196
196
  if (options.signal?.aborted || source(image) !== url) return finish(null);
197
197
  const displayed = normalize(image.currentSrc || image.getAttribute('src'), image);
198
- if (displayed !== url) return; // The lazy placeholder finished loading.
198
+ // 懒加载占位图(src 仍是 1×1 data URI):显示像素不可用,直接 return 会让本 Promise 永久挂起
199
+ // (懒加载运行时没执行时,真图永远不会被换进来),自适应文字与 --wiki-border-color 就全都缺失。
200
+ // 改为按 URL 单独取色,保证颜色不依赖懒加载是否跑通。
201
+ if (displayed !== url) return finish(waitForSample(sampleWithCors(image, url, size, key), options.signal));
199
202
  if (!image.naturalWidth || !image.naturalHeight) return finish(null);
200
203
  const existing = read(key);
201
204
  const raw = existing || sample(image, size);