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

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0-rc.6
4
+
5
+ > 发布日期:2026-09-18
6
+
7
+ 本节记录从公开候选 `2.0.0-rc.5` 到 `2.0.0-rc.6` 最终文件树的净变化。
8
+
9
+ ### 新功能
10
+
11
+ - Wiki Hero 新增视频背景 `hero.background.video`,静音循环自动播放,图片可用作 poster;设置视频时不加载动态效果。
12
+ - Hero 新增滚动视差 `hero.background.parallax`,范围 0–1、默认 0.2,设为 0 关闭位移;图片与视频背景随滚动淡出,减少动态效果偏好下关闭视差位移。
13
+ - 新增 Strands WebGL 背景效果,支持光带配色、形态、发光及玻璃折射参数。
14
+ - Leftbar 新增 `menu_columns`,支持 1–5 列,默认 1;1–2 列显示图标与标题,3–5 列仅显示图标并保留无障碍名称。支持 Profile、Collection、Page 覆盖。
15
+ - 新增 `services.gravatar.base_url`,侧栏与设置页共享访客头像镜像地址,默认 `https://gravatar.com/avatar/`;自定义服务需兼容 SHA-256 并包含 `/avatar/` 路径。
16
+
17
+ ### 修复与改进
18
+
19
+ - 同集合页面切换等待超过 150ms 时显示加载反馈,完成、失败或取消后清理,快速切换不闪烁。
20
+ - 修复目录活动指示条在 CSS 压缩后高度丢失的问题。
21
+ - 中英文安装说明补充 Windows PowerShell Blueprint 安装命令。
22
+
23
+ ### 升级注意
24
+
25
+ - 现有 Hero 背景默认启用 0.2 强度视差;如需关闭位移,显式设置 `hero.background.parallax: 0`。
26
+
27
+ Full Changelog: [2.0.0-rc.5...2.0.0-rc.6](https://github.com/xaoxuu/hexo-theme-stellar/compare/2.0.0-rc.5...2.0.0-rc.6)
28
+
3
29
  ## 2.0.0-rc.5
4
30
 
5
31
  > 发布日期:2026-09-12
package/README.md CHANGED
@@ -49,10 +49,18 @@
49
49
 
50
50
  准备好 Git 和 npm 后,一条命令即可启动交互式蓝图安装器:
51
51
 
52
+ macOS / Linux:
53
+
52
54
  ```bash
53
55
  sh -c "$(curl -fsSL https://github.com/xaoxuu/hexo-theme-stellar-examples/raw/main/install.sh)"
54
56
  ```
55
57
 
58
+ Windows PowerShell 7+(`pwsh`):
59
+
60
+ ```powershell
61
+ & ([scriptblock]::Create((Invoke-RestMethod https://github.com/xaoxuu/hexo-theme-stellar-examples/raw/main/install.ps1)))
62
+ ```
63
+
56
64
  安装器会读取示例仓库的 [`blueprints.json`](https://github.com/xaoxuu/hexo-theme-stellar-examples/blob/main/blueprints.json),展示当前可用的蓝图,并依次确认蓝图、项目目录、依赖安装和创建计划。
57
65
 
58
66
  ### 从 npm 安装
package/README_EN.md CHANGED
@@ -49,10 +49,18 @@ Your writing preserves ideas while external data stays current. Remote content,
49
49
 
50
50
  With Git and npm available, launch the interactive Blueprint installer with one command:
51
51
 
52
+ macOS / Linux:
53
+
52
54
  ```bash
53
55
  sh -c "$(curl -fsSL https://github.com/xaoxuu/hexo-theme-stellar-examples/raw/main/install.sh)"
54
56
  ```
55
57
 
58
+ Windows PowerShell 7+ (`pwsh`):
59
+
60
+ ```powershell
61
+ & ([scriptblock]::Create((Invoke-RestMethod https://github.com/xaoxuu/hexo-theme-stellar-examples/raw/main/install.ps1)))
62
+ ```
63
+
56
64
  The installer reads the example repository's [`blueprints.json`](https://github.com/xaoxuu/hexo-theme-stellar-examples/blob/main/blueprints.json), presents the available Blueprints, then confirms the Blueprint, project directory, dependency installation, and creation plan.
57
65
 
58
66
  ### Install from npm
package/_config.yml CHANGED
@@ -58,6 +58,8 @@ leftbar:
58
58
  tagline: null
59
59
  # 设置品牌链接。
60
60
  href: /
61
+ # 设置 Leftbar 菜单列数(1–5);1–2 列显示图标与标题,3–5 列仅显示居中的图标。
62
+ menu_columns: 1
61
63
  # 设置 Leftbar 菜单项;数组会整体替换,空数组表示不显示菜单项。
62
64
  menu:
63
65
  # 声明默认的博客入口。
@@ -930,6 +932,10 @@ features:
930
932
 
931
933
  # 配置主题使用的数据服务。
932
934
  services:
935
+ # 配置访客身份头像使用的 Gravatar 服务(侧边栏与设置页共享)。
936
+ gravatar:
937
+ # 设置兼容 SHA-256 的头像镜像地址,包含 /avatar/ 路径;末尾斜杠可省略。
938
+ base_url: https://gravatar.com/avatar/
933
939
  # 配置所有动态 Markdown 内容共享的 Marked 客户端。
934
940
  markdown:
935
941
  # 自定义脚本地址;null 使用默认资源。
@@ -13,6 +13,7 @@ function layoutWikiCover() {
13
13
  }
14
14
  var backgroundConfig = cover.background || {};
15
15
  var background = typeof backgroundConfig.image === 'string' && backgroundConfig.image.length > 0 ? backgroundConfig.image : '';
16
+ var video = typeof backgroundConfig.video === 'string' ? backgroundConfig.video.trim() : '';
16
17
  var effect = backgroundConfig.effect || {};
17
18
  var preview = cover.preview || {};
18
19
  var commands = Array.isArray(preview.commands) ? preview.commands.filter(function (item) {
@@ -25,7 +26,7 @@ function layoutWikiCover() {
25
26
  var actions = Array.isArray(cover.actions) ? cover.actions.filter(function (item) {
26
27
  return item && item.title && item.url;
27
28
  }) : [];
28
- var hasEffect = typeof effect.type === 'string' && effect.type.length > 0;
29
+ var hasEffect = !video && typeof effect.type === 'string' && effect.type.length > 0;
29
30
  var isImageBackground = background.length > 0;
30
31
  var effectOptions = effect.options && typeof effect.options === 'object' && !Array.isArray(effect.options) ? effect.options : {};
31
32
  var effectRuntime = effect.runtime && typeof effect.runtime === 'object' && !Array.isArray(effect.runtime) ? effect.runtime : {};
@@ -37,18 +38,23 @@ function layoutWikiCover() {
37
38
  var effectRuntimeJson = escape_html(JSON.stringify(effectRuntime));
38
39
  var projectName = cover.projectName;
39
40
  var adaptiveTextAttr = '';
40
- if (isImageBackground) {
41
+ if (video) {
42
+ adaptiveTextAttr = ' data-text-adaptive="split" style="--adaptive-background:#000000"';
43
+ } else if (isImageBackground) {
41
44
  adaptiveTextAttr = ' data-text-adaptive="split" style="--cover-url:url(\'' + escape_html(background.replace(/'/g, '%27')) + '\')"';
42
45
  } else if (hasEffect) {
43
46
  adaptiveTextAttr = ' data-text-adaptive="split" style="--adaptive-background:' + escape_html(effectBackground) + '"';
44
47
  }
45
48
 
46
49
  el += '<div class="cover-content wiki wiki-hero' + (hasPreview ? '' : ' without-preview') + '">';
47
- el += '<div class="wiki-cover-background' + (hasEffect ? ' has-effect' : '') + (respectsReducedMotion ? ' respect-reduced-motion' : '') + (isImageBackground ? ' image' : '') + '"' + (hasEffect ? ' style="--hero-effect-background:' + escape_html(effectBackground) + '"' : '') + ' aria-hidden="true">';
50
+ el += '<div class="wiki-cover-background' + (hasEffect ? ' has-effect' : '') + (respectsReducedMotion ? ' respect-reduced-motion' : '') + (isImageBackground ? ' image' : '') + (video ? ' has-video' : '') + '" data-parallax="' + escape_html(String(backgroundConfig.parallax)) + '"' + (hasEffect ? ' style="--hero-effect-background:' + escape_html(effectBackground) + '"' : '') + ' aria-hidden="true">';
48
51
  if (isImageBackground) {
49
52
  el += '<img class="wiki-cover-background-image" src="' + escape_html(background) + '" alt="" />';
50
53
  el += '<img class="wiki-cover-background-blur" src="' + escape_html(background) + '" alt="" />';
51
54
  }
55
+ if (video) {
56
+ el += '<video class="wiki-cover-background-video" src="' + escape_html(video) + '"' + (isImageBackground ? ' poster="' + escape_html(background) + '"' : '') + ' autoplay muted loop playsinline preload="metadata" disablepictureinpicture tabindex="-1"></video>';
57
+ }
52
58
  if (hasEffect) {
53
59
  el += '<canvas data-hero-effect="' + escape_html(effect.type) + '" data-effect-resource="' + effectResourceJson + '" data-effect-options="' + effectOptionsJson + '" data-effect-runtime="' + effectRuntimeJson + '"></canvas>';
54
60
  }
@@ -5,8 +5,9 @@ if (!allowedPlacements.includes(placement)) {
5
5
  }
6
6
  var navigationItems = Array.isArray(entries) ? entries : []
7
7
  var navigationBody = partial('_partial/components/collection', {options: {
8
- layout: 'list',
9
- variant: 'nav',
8
+ layout: placement === 'leftbar' && typeof columns !== 'undefined' && columns > 1 ? 'grid' : 'list',
9
+ columns: typeof columns !== 'undefined' ? columns : 1,
10
+ variant: placement === 'leftbar' && typeof columns !== 'undefined' && columns > 2 ? 'icon' : 'nav',
10
11
  items: navigationItems
11
12
  }})
12
13
  var html = ''
@@ -69,7 +69,7 @@ function stack(items, zone, options) {
69
69
  <% if (leftbarBrandBody.trim().length > 0) { %><div class="widget-instance widget-instance--brand" data-widget-id="leftbar:fixed:brand" data-presentation="leftbar" data-leftbar-rail><%- leftbarBrandBody %></div><% } %>
70
70
  </header>
71
71
  <div class="site-region__body site-region__zone site-region__zone--body">
72
- <% if (menuItems.length > 0) { %><div class="widget-instance widget-instance--menu" data-widget-id="leftbar:fixed:menu" data-presentation="leftbar" data-leftbar-rail><%- partial('../sidebar/menu', {where: 'leftbar', menu: menuItems, activeMenu: activeViewModel?.item.navigation.menu, viewModel: activeViewModel}) %></div><% } %>
72
+ <% if (menuItems.length > 0) { %><div class="widget-instance widget-instance--menu" data-widget-id="leftbar:fixed:menu" data-presentation="leftbar" data-leftbar-rail><%- partial('../sidebar/menu', {where: 'leftbar', menu: menuItems, menuColumns: regionState.menuColumns, activeMenu: activeViewModel?.item.navigation.menu, viewModel: activeViewModel}) %></div><% } %>
73
73
  <%- stack(regionWidgets, 'body') %>
74
74
  </div>
75
75
  <footer class="site-region__footer">
@@ -9,6 +9,7 @@ function layoutDiv() {
9
9
  var pathMenu = menu_active_by_path(page.path, menuItems)
10
10
  if (pathMenu != null) currentMenu = pathMenu
11
11
  }
12
+ var iconLayout = where === 'leftbar' && typeof menuColumns !== 'undefined' && menuColumns > 2
12
13
  var entries = []
13
14
  for (var menuItem of menuItems) {
14
15
  if (menuItem == null || menuItem.id == null || menuItem.url == null || menuItem.title == null) {
@@ -18,6 +19,7 @@ function layoutDiv() {
18
19
  href: pretty_url(menuItem.url),
19
20
  title: __(menuItem.title),
20
21
  icon: menuItem.icon,
22
+ attrs: iconLayout ? {'aria-label': __(menuItem.title)} : {},
21
23
  theme: menuItem.accent,
22
24
  active: menuItem.id == currentMenu,
23
25
  className: menuItem.accent?.length > 0 ? 'is-themed' : ''
@@ -25,6 +27,7 @@ function layoutDiv() {
25
27
  }
26
28
  return partial('_partial/primitives/navigation', {
27
29
  placement: ['main', 'topbar', 'rightbar'].includes(where) ? where : 'leftbar',
30
+ columns: where === 'leftbar' && typeof menuColumns !== 'undefined' ? menuColumns : 1,
28
31
  entries: entries
29
32
  })
30
33
  }
@@ -5,10 +5,10 @@ This project includes or adapts portions of the following third-party software.
5
5
  ## React Bits
6
6
 
7
7
  - Project: [React Bits](https://github.com/DavidHDev/react-bits)
8
- - Components: Ferrofluid, Galaxy and Spotlight Card
8
+ - Components: Ferrofluid, Galaxy, Strands and Spotlight Card
9
9
  - License: MIT
10
10
  - Copyright: Copyright (c) 2026 David Haz
11
- - Usage: Adapted for the Wiki Ferrofluid and Galaxy WebGL backgrounds and Card Hover spotlight layer.
11
+ - Usage: Adapted for the Wiki Ferrofluid, Galaxy and Strands WebGL backgrounds and Card Hover spotlight layer.
12
12
 
13
13
  ### MIT License
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hexo-theme-stellar",
3
- "version": "2.0.0-rc.5",
3
+ "version": "2.0.0-rc.6",
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": {
@@ -22,6 +22,7 @@ function clientAssets(hexo) {
22
22
  'default:settings', 'copy:copy', 'weibo:repeat', 'weibo:like'];
23
23
  const clientIcons = Object.fromEntries(keys.map(key => [key, (icons[key] || '').replace(/<!--[\s\S]*?-->/g, '')]));
24
24
  const services = JSON.parse(JSON.stringify(INTERNAL.assets.services));
25
+ services.gravatar = { baseUrl: config.services.gravatar.baseUrl };
25
26
  services.siteinfo.api = resolveServiceProvider(config.services.siteInfo)?.endpoint || null;
26
27
  const provider = config.search.provider;
27
28
  const search = provider ? { service: provider === 'local' ? 'local_search' : provider } : null;
@@ -256,11 +256,11 @@ function validateEffectOptions(node, definition, options, source, path, issues)
256
256
  issues.push(issue("invalid_value", source, optionPath, valueType(value), "six-digit hexadecimal color", node.migration));
257
257
  } else if (rule.type === "hex_color_array" && (
258
258
  !Array.isArray(value)
259
- || value.length < 1
259
+ || value.length < (rule.minItems ?? 1)
260
260
  || value.length > 8
261
261
  || value.some(item => typeof item !== "string" || !/^#?[a-f\d]{6}$/i.test(item))
262
262
  )) {
263
- issues.push(issue("invalid_value", source, optionPath, valueType(value), "one to eight six-digit hexadecimal colors", node.migration));
263
+ issues.push(issue("invalid_value", source, optionPath, valueType(value), `${rule.minItems ?? 1} to eight six-digit hexadecimal colors`, node.migration));
264
264
  }
265
265
  }
266
266
  }
@@ -139,6 +139,20 @@ const CONTRIBUTIONS = defineContributions([
139
139
  });
140
140
  }
141
141
  },
142
+ {
143
+ id: "hero-parallax",
144
+ scope: "region",
145
+ kind: "feature",
146
+ entry: runtimeEntry("/js/runtime/extensions/hero-parallax.js"),
147
+ resources: [],
148
+ activation: selector(".wiki-hero"),
149
+ schema: null,
150
+ i18n: null,
151
+ docs: { category: "Components", path: "docs/knowledge/03-内容系统/wiki-docs.md" },
152
+ tests: [RUNTIME_TEST, RUNTIME_CONSUMPTION_TEST],
153
+ defaultsOwner: null,
154
+ project: () => configResult({})
155
+ },
142
156
  {
143
157
  id: "hero-effect",
144
158
  scope: "region",
@@ -32,7 +32,7 @@ function optionMatches(rule, value) {
32
32
  if (rule.type === "hex_color") return typeof value === "string" && /^#?[a-f\d]{6}$/i.test(value);
33
33
  if (rule.type === "hex_color_array") {
34
34
  return Array.isArray(value)
35
- && value.length >= 1
35
+ && value.length >= (rule.minItems ?? 1)
36
36
  && value.length <= 8
37
37
  && value.every(item => typeof item === "string" && /^#?[a-f\d]{6}$/i.test(item));
38
38
  }
@@ -91,6 +91,55 @@ const LIGHT_RAYS_ORIGINS = Object.freeze([
91
91
  ]);
92
92
 
93
93
  const DEFINITIONS = defineHeroEffects([
94
+ {
95
+ id: "strands",
96
+ label: "Strands",
97
+ module: INTERNAL.assets.heroEffects.strands,
98
+ options: {
99
+ colors: { type: "hex_color_array", minItems: 0 },
100
+ count: { type: "number" },
101
+ speed: { type: "number" },
102
+ amplitude: { type: "number" },
103
+ waviness: { type: "number" },
104
+ thickness: { type: "number" },
105
+ glow: { type: "number" },
106
+ taper: { type: "number" },
107
+ spread: { type: "number" },
108
+ hueShift: { type: "number" },
109
+ intensity: { type: "number" },
110
+ saturation: { type: "number" },
111
+ opacity: { type: "number" },
112
+ scale: { type: "number" },
113
+ glass: { type: "boolean" },
114
+ refraction: { type: "number" },
115
+ dispersion: { type: "number" },
116
+ glassSize: { type: "number" }
117
+ },
118
+ defaults: {
119
+ colors: ["#F97316", "#7C3AED", "#06B6D4"],
120
+ count: 3,
121
+ speed: 0.5,
122
+ amplitude: 1,
123
+ waviness: 1,
124
+ thickness: 0.7,
125
+ glow: 2.6,
126
+ taper: 3,
127
+ spread: 1,
128
+ hueShift: 0,
129
+ intensity: 0.6,
130
+ saturation: 1.5,
131
+ opacity: 1,
132
+ scale: 1.5,
133
+ glass: false,
134
+ refraction: 1,
135
+ dispersion: 1,
136
+ glassSize: 1
137
+ },
138
+ presentation() {
139
+ return { background: "#000000" };
140
+ }
141
+ },
142
+
94
143
  {
95
144
  id: "galaxy",
96
145
  label: "Galaxy",
@@ -57,6 +57,7 @@ module.exports = deepFreeze({
57
57
  settings: "/js/runtime/extensions/settings.js"
58
58
  },
59
59
  heroEffects: {
60
+ strands: "/js/runtime/hero-effects/strands.js",
60
61
  ferrofluid: "/js/runtime/hero-effects/ferrofluid.js",
61
62
  galaxy: "/js/runtime/hero-effects/galaxy.js",
62
63
  lightRays: "/js/runtime/hero-effects/light-rays.js"
@@ -7,6 +7,7 @@ const { firstContentImage } = require("../seo");
7
7
  const { caption } = require("../caption");
8
8
  const { wikiReadmeHtml } = require("../wiki_readme");
9
9
  const { filterShareServices } = require("../share-services");
10
+ const { COLLECTION_CONFIG_SCHEMA } = require("../../schema/content-config-schema");
10
11
  const { projectHeroEffect } = require("../hero-effect-registry");
11
12
  const { normalizeThemeComments } = require("../comments");
12
13
  const { articleFooterDefaults, articlePresentationDefaults, collectionFooterDefaults, requireContentConfig } = require("../content-defaults");
@@ -14,6 +15,7 @@ const { cloneValue, deepFreeze, pick, mergeConfig, toContentNavigation, collecti
14
15
 
15
16
  function heroBackground(value) {
16
17
  const background = cloneValue(value || {});
18
+ background.parallax ??= COLLECTION_CONFIG_SCHEMA.properties.hero.properties.background.properties.parallax.default.value;
17
19
  if (background.effect) background.effect = projectHeroEffect(background.effect);
18
20
  return background;
19
21
  }
@@ -61,7 +61,10 @@ function resolveRegion(layers, region, options = {}) {
61
61
  result.brand = false;
62
62
  result.menu = [];
63
63
  }
64
- if (region === "leftbar") result.footer = { actions: [] };
64
+ if (region === "leftbar") {
65
+ result.menuColumns = 1;
66
+ result.footer = { actions: [] };
67
+ }
65
68
  for (const layer of layers) {
66
69
  const current = regionLayer(layer?.[region]);
67
70
  if (!current) continue;
@@ -72,6 +75,9 @@ function resolveRegion(layers, region, options = {}) {
72
75
  if (["topbar", "leftbar"].includes(region) && Array.isArray(current.menu)) {
73
76
  result.menu = current.menu.map(clone);
74
77
  }
78
+ if (region === "leftbar" && current.menuColumns != null) {
79
+ result.menuColumns = current.menuColumns;
80
+ }
75
81
  if (region === "leftbar" && Array.isArray(current.footer?.actions)) {
76
82
  result.footer.actions = current.footer.actions.map(clone);
77
83
  }
@@ -112,6 +118,7 @@ function resolveRegions(options = {}) {
112
118
  menu: clone(state.menu)
113
119
  } : {}),
114
120
  ...(region === "leftbar" ? {
121
+ menuColumns: state.menuColumns,
115
122
  defaultState: options.defaultState || "expanded",
116
123
  footer: clone(state.footer)
117
124
  } : {})
@@ -156,6 +156,7 @@ const CONFIG_RULES = Object.freeze([
156
156
  ["leftbar.default_state", { values: ["expanded", "collapsed"] }],
157
157
  ["leftbar.enabled", { type: ["boolean"] }],
158
158
  ["leftbar.brand", regionBrand(leftbarBrandProperties)],
159
+ ["leftbar.menu_columns", { minimum: 1, maximum: 5, validator: "non_negative_integer" }],
159
160
  ["leftbar.menu", { validator: "menu_items", items: menuItem }],
160
161
  ["leftbar.footer.actions", { validator: "footer_actions", items: footerAction }],
161
162
  ["leftbar.widgets", { items: widgetItems }],
@@ -169,6 +170,7 @@ const CONFIG_RULES = Object.freeze([
169
170
  ["profiles.*.topbar.widgets", { type: ["array", "null"], defaultValue: null, items: widgetItems }],
170
171
  ["profiles.*.leftbar.enabled", { type: ["boolean", "null"], defaultValue: null }],
171
172
  ["profiles.*.leftbar.brand", inheritedRegionBrand(leftbarBrandProperties)],
173
+ ["profiles.*.leftbar.menu_columns", { type: ["number", "null"], defaultValue: null, minimum: 1, maximum: 5, validator: "nullable_non_negative_integer" }],
172
174
  ["profiles.*.leftbar.menu", { type: ["array", "null"], defaultValue: null, validator: "menu_items", items: menuItem }],
173
175
  ["profiles.*.leftbar.footer.actions", { type: ["array", "null"], defaultValue: null, validator: "footer_actions", items: footerAction }],
174
176
  ["profiles.*.leftbar.widgets", { type: ["array", "null"], defaultValue: null, items: widgetItems }],
@@ -274,6 +276,7 @@ const CONFIG_RULES = Object.freeze([
274
276
  ["services.contributors.provider", { values: ["github"] }],
275
277
  ["services.contributors.github.repositories", { validator: "contributor_repositories", items: contributorRepository }],
276
278
  ["services.github.*", { validator: "absolute_http_url" }],
279
+ ["services.gravatar.base_url", { validator: "absolute_http_url" }],
277
280
  ["services.github_card.provider", { values: ["github_readme_stats"] }],
278
281
  ["services.github_card.github_readme_stats", { runtimeKey: "github_readme_stats" }],
279
282
  ["services.github_card.github_readme_stats.endpoint", { type: ["string"], validator: "absolute_http_url" }],
@@ -127,6 +127,7 @@ const CONTENT_OVERRIDE_DEFINITIONS = [
127
127
  ["leftbar.brand.ghuser", ["string", "null"], literal(null)],
128
128
  ["leftbar.brand.search", ["boolean", "null"], literal(null)],
129
129
  ["leftbar.brand.href", ["string", "null"], literal(null)],
130
+ ["leftbar.menu_columns", ["number", "null"], literal(null), { minimum: 1, maximum: 5 }],
130
131
  ["leftbar.menu", ["array", "null"], literal(null), { items: MENU_ITEMS }],
131
132
  ["leftbar.footer", "object", literal({}), { boundary: "sealed" }],
132
133
  ["leftbar.footer.actions", ["array", "null"], literal(null), { items: ACTION_ITEMS }],
@@ -172,6 +173,8 @@ const COLLECTION_TARGET_DEFINITIONS = [
172
173
  ["hero.enabled", ["boolean", "null"], literal(null)],
173
174
  ["hero.background", "object", literal({}), { boundary: "sealed" }],
174
175
  ["hero.background.image", ["string", "null"], literal(null)],
176
+ ["hero.background.video", ["string", "null"], literal(null)],
177
+ ["hero.background.parallax", "number", literal(0.2), { minimum: 0, maximum: 1 }],
175
178
  ["hero.background.effect", ["object", "null"], literal(null), { boundary: "registered_schema" }],
176
179
  ["hero.preview", ["object", "null"], literal(null), { boundary: "sealed" }],
177
180
  ["hero.preview.type", ["string", "null"], literal(null), { values: ["terminal", "image"] }],
@@ -166,6 +166,7 @@ function targetNode(target) {
166
166
  ...(target.boundary === "sealed" ? { sealed: true } : {}),
167
167
  ...(target.values ? { values: clone(target.values) } : {}),
168
168
  ...(target.minimum !== undefined ? { minimum: target.minimum } : {}),
169
+ ...(target.maximum !== undefined ? { maximum: target.maximum } : {}),
169
170
  ...(target.exclusiveMinimum !== undefined ? { exclusiveMinimum: target.exclusiveMinimum } : {})
170
171
  };
171
172
  }
@@ -271,6 +272,7 @@ function decorateSharedSchemas(schema) {
271
272
  schema.properties[region].properties.brand.properties.href.validator = "nullable_safe_navigation_url";
272
273
  schema.properties[region].properties.menu.validator = "menu_items";
273
274
  }
275
+ schema.properties.leftbar.properties.menu_columns.validator = "nullable_non_negative_integer";
274
276
  schema.properties.leftbar.properties.footer.properties.actions.validator = "footer_actions";
275
277
 
276
278
  const effect = schema.properties.hero?.properties.background?.properties.effect;
@@ -206,6 +206,7 @@ function regionConfigSchemas(factory) {
206
206
  schema.type = ["object", "boolean", "null"];
207
207
  return schema;
208
208
  })(),
209
+ menuColumns: field("number", { example: 1 }),
209
210
  menu: array(menuItem, { example: [] }),
210
211
  footer: object({
211
212
  actions: array(actionItem, { example: [] })
@@ -253,6 +254,8 @@ function heroSchema(factory, options = {}) {
253
254
  enabled: field("boolean", { example: true }),
254
255
  background: object({
255
256
  image: field("string", { example: "/hero.webp", consumers: options.pathConsumers }),
257
+ video: field("string", { example: "/hero.mp4", consumers: options.pathConsumers }),
258
+ parallax: field("number", { example: 0.2 }),
256
259
  effect: object({
257
260
  type: field("string", { example: "galaxy" }),
258
261
  options: field("object", {
@@ -41,3 +41,20 @@ svg.loading
41
41
  .article-footer
42
42
  .loading-wrap
43
43
  background: none
44
+
45
+ .loading-wrap.navigation-loading
46
+ position: fixed
47
+ top: calc(env(safe-area-inset-top, 0px) + 1rem)
48
+ left: 50%
49
+ transform: translateX(-50%)
50
+ z-index: 1000
51
+ display: flex
52
+ align-items: center
53
+ gap: .5rem
54
+ padding: .5rem 1rem
55
+ background: var(--card)
56
+ box-shadow: 0 2px 12px rgba(0, 0, 0, .12)
57
+ pointer-events: none
58
+ .lazy-icon
59
+ flex-shrink: 0
60
+ margin: 0
@@ -78,6 +78,9 @@
78
78
  inset: 0
79
79
  overflow: hidden
80
80
  background: var(--background)
81
+ &.has-parallax
82
+ top: var(--hero-parallax-inset, 0%)
83
+ bottom: var(--hero-parallax-inset, 0%)
81
84
  &.image
82
85
  .wiki-cover-background-image,
83
86
  .wiki-cover-background-blur
@@ -101,6 +104,23 @@
101
104
  inset: 0
102
105
  pointer-events: none
103
106
  background: linear-gradient(to bottom, transparent 0%, transparent 80%, var(--background) 100%)
107
+ &.has-video
108
+ background: #000000
109
+ .wiki-cover-background-video
110
+ display: block
111
+ position: absolute
112
+ z-index: 2
113
+ inset: 0
114
+ width: 100%
115
+ height: 100%
116
+ object-fit: cover
117
+ pointer-events: none
118
+ .wiki-cover-background-overlay
119
+ position: absolute
120
+ z-index: 3
121
+ inset: 0
122
+ pointer-events: none
123
+ background: rgba(black, .15)
104
124
  &.has-effect
105
125
  &:not(.image)
106
126
  background: var(--hero-effect-background, #000000)
@@ -9,3 +9,13 @@
9
9
  .menu
10
10
  .ui-collection__item
11
11
  font-size: $fs-15
12
+
13
+ .site-region--leftbar .widget-instance--menu .ui-collection[data-layout='grid']
14
+ --ui-grid-min: 0px
15
+ &[data-variant='icon']
16
+ .ui-collection__item
17
+ padding-inline: 0
18
+ .ui-collection__content
19
+ display: none
20
+ .ui-collection__initial
21
+ display: block
@@ -38,7 +38,7 @@
38
38
  top: 4px
39
39
  left: 0
40
40
  width: 4px
41
- height: unquote('max(0px, calc(var(--toc-active-height, 8px) - 8px))')
41
+ height: calc(var(--toc-active-height, 8px) - 8px)
42
42
  border-radius: 4px
43
43
  background: var(--theme)
44
44
  opacity: var(--toc-active-opacity, 0)
@@ -2,7 +2,7 @@
2
2
  var SUPPORTED_PROVIDERS = ['artalk', 'waline', 'twikoo'];
3
3
  var SETTINGS_ICON = 'default:settings';
4
4
  var PROFILE_ICON = 'default:profile';
5
- var GRAVATAR_BASE_URL = 'https://gravatar.com/avatar/';
5
+ var GRAVATAR_BASE_URL = window.stellarClientData.ctx.services.gravatar.baseUrl.replace(/\/+$/, '') + '/';
6
6
  var localStore = null;
7
7
  var sessionStore = null;
8
8
  var gravatarHashes = new Map();
@@ -0,0 +1,70 @@
1
+ const SELECTOR = '.wiki-hero';
2
+
3
+ export function mount(root, context = {}) {
4
+ const heroes = [...root.querySelectorAll(SELECTOR)];
5
+ if (root.matches?.(SELECTOR)) heroes.unshift(root);
6
+ const cleanups = heroes.map(hero => {
7
+ const background = hero.querySelector('.wiki-cover-background');
8
+ if (!background?.matches('.image, .has-video, .has-effect')) return () => {};
9
+ const configuredStrength = Number(background.getAttribute('data-parallax'));
10
+ const strength = Number.isFinite(configuredStrength) ? Math.max(0, Math.min(1, configuredStrength)) : 0;
11
+ const fades = background.matches('.has-video, .image:not(.has-effect)');
12
+ const documentRef = hero.ownerDocument;
13
+ const windowRef = documentRef.defaultView;
14
+ const motion = windowRef.matchMedia('(prefers-reduced-motion: reduce)');
15
+ let frame = null;
16
+ let visible = true;
17
+
18
+ function update() {
19
+ frame = null;
20
+ const rect = hero.getBoundingClientRect();
21
+ if (fades) {
22
+ const opacity = rect.height > 0 ? Math.max(0, Math.min(1, (rect.top + rect.height) / rect.height)) : 1;
23
+ background.style.opacity = String(opacity);
24
+ }
25
+ if (motion.matches || strength === 0) {
26
+ background.style.removeProperty('transform');
27
+ background.style.removeProperty('--hero-parallax-inset');
28
+ background.classList.remove('has-parallax');
29
+ return;
30
+ }
31
+ background.style.setProperty('--hero-parallax-inset', `${-strength * 100}%`);
32
+ background.classList.add('has-parallax');
33
+ const offset = Math.max(-rect.height * strength, Math.min(rect.height * strength, -rect.top * strength));
34
+ background.style.transform = `translate3d(0, ${offset}px, 0)`;
35
+ }
36
+ function schedule() {
37
+ if (frame === null && visible && !documentRef.hidden) frame = windowRef.requestAnimationFrame(update);
38
+ }
39
+ const observer = new windowRef.IntersectionObserver(entries => {
40
+ visible = entries[0].isIntersecting;
41
+ // Commit the endpoint even when a large scroll moves the Hero offscreen.
42
+ if (frame !== null) windowRef.cancelAnimationFrame(frame);
43
+ update();
44
+ });
45
+ observer.observe(hero);
46
+ const resize = new windowRef.ResizeObserver(schedule);
47
+ resize.observe(hero);
48
+ windowRef.addEventListener('scroll', schedule, { passive: true });
49
+ windowRef.addEventListener('resize', schedule);
50
+ documentRef.addEventListener('visibilitychange', schedule);
51
+ motion.addEventListener('change', schedule);
52
+ update();
53
+ return () => {
54
+ if (frame !== null) windowRef.cancelAnimationFrame(frame);
55
+ observer.disconnect();
56
+ resize.disconnect();
57
+ windowRef.removeEventListener('scroll', schedule);
58
+ windowRef.removeEventListener('resize', schedule);
59
+ documentRef.removeEventListener('visibilitychange', schedule);
60
+ motion.removeEventListener('change', schedule);
61
+ background.style.removeProperty('transform');
62
+ background.style.removeProperty('--hero-parallax-inset');
63
+ if (fades) background.style.removeProperty('opacity');
64
+ background.classList.remove('has-parallax');
65
+ };
66
+ });
67
+ const cleanup = () => cleanups.forEach(dispose => dispose());
68
+ context.onCleanup?.(cleanup);
69
+ return cleanup;
70
+ }
@@ -79,6 +79,20 @@ export function mount(root, context) {
79
79
  }
80
80
  async function navigate(url, { pop = false, position } = {}) {
81
81
  const request = queue.begin();
82
+ const loading = document.createElement('div');
83
+ loading.className = 'loading-wrap navigation-loading';
84
+ loading.setAttribute('role', 'status');
85
+ const icon = document.createElement('div');
86
+ icon.className = 'lazy-icon';
87
+ icon.setAttribute('aria-hidden', 'true');
88
+ loading.append(icon);
89
+ // Fast navigations should finish without flashing a loading indicator.
90
+ const loadingTimer = setTimeout(() => document.body.append(loading), 150);
91
+ const clearLoading = () => {
92
+ clearTimeout(loadingTimer);
93
+ loading.remove();
94
+ };
95
+ request.signal.addEventListener('abort', clearLoading, { once: true });
82
96
  let timedOut = false;
83
97
  const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 15000);
84
98
  if (!pop) remember();
@@ -134,7 +148,10 @@ export function mount(root, context) {
134
148
  });
135
149
  } catch (error) {
136
150
  if ((!request.signal.aborted || timedOut) && !lifetime.signal.aborted) fallback(url, pop);
137
- } finally { clearTimeout(timeout); }
151
+ } finally {
152
+ clearTimeout(timeout);
153
+ clearLoading();
154
+ }
138
155
  }
139
156
 
140
157
  document.addEventListener('click', event => {
@@ -0,0 +1,302 @@
1
+ /* Adapted from React Bits Strands (MIT, Copyright (c) 2026 David Haz).
2
+ * See legal/THIRD-PARTY-NOTICES.md. Native WebGL2 renderer for Stellar. */
3
+
4
+ const VERT = `#version 300 es
5
+ in vec2 position;
6
+ void main() {
7
+ gl_Position = vec4(position, 0.0, 1.0);
8
+ }
9
+ `;
10
+
11
+ const FRAG = `#version 300 es
12
+ precision highp float;
13
+
14
+ uniform float uTime;
15
+ uniform vec2 uResolution;
16
+ uniform vec3 uColors[8];
17
+ uniform int uColorCount;
18
+ uniform int uStrandCount;
19
+ uniform float uSpeed;
20
+ uniform float uAmplitude;
21
+ uniform float uWaviness;
22
+ uniform float uThickness;
23
+ uniform float uGlow;
24
+ uniform float uTaper;
25
+ uniform float uSpread;
26
+ uniform float uHueShift;
27
+ uniform float uIntensity;
28
+ uniform float uOpacity;
29
+ uniform float uScale;
30
+ uniform float uSaturation;
31
+
32
+ out vec4 fragColor;
33
+
34
+ const float PI = 3.14159265;
35
+
36
+ vec3 spectrum(float t) {
37
+ return 0.5 + 0.5 * cos(2.0 * PI * (t + vec3(0.00, 0.33, 0.67)));
38
+ }
39
+
40
+ vec3 samplePalette(float t) {
41
+ t = fract(t);
42
+ float scaled = t * float(uColorCount);
43
+ int idx = int(floor(scaled));
44
+ float blend = fract(scaled);
45
+ int nextIdx = idx + 1;
46
+ if (nextIdx >= uColorCount) nextIdx = 0;
47
+ return mix(uColors[idx], uColors[nextIdx], blend);
48
+ }
49
+
50
+ vec3 strandColor(float t) {
51
+ if (uColorCount > 0) return samplePalette(t);
52
+ return spectrum(t);
53
+ }
54
+
55
+ void main() {
56
+ vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;
57
+ uv /= max(uScale, 0.0001);
58
+
59
+ float e = 0.06 + uIntensity * 0.94;
60
+ float env = pow(max(cos(uv.x * PI * 1.3), 0.0), uTaper);
61
+
62
+ vec3 col = vec3(0.0);
63
+
64
+ for (int i = 0; i < 12; i++) {
65
+ if (i >= uStrandCount) break;
66
+
67
+ float fi = float(i);
68
+ float ph = fi * 1.7 * uSpread;
69
+ float freq = (2.0 + fi * 0.35) * uWaviness;
70
+ float spd = 1.4 + fi * 1.2;
71
+
72
+ float tt = uTime * uSpeed;
73
+ float w = sin(uv.x * freq + tt * spd + ph) * 0.60
74
+ + sin(uv.x * freq * 1.1 - tt * spd * 0.7 + ph * 1.7) * 0.40;
75
+
76
+ float amp = (0.1 + 0.02 * e) * env * uAmplitude;
77
+ float y = w * amp;
78
+
79
+ float d = abs(uv.y - y);
80
+ float thick = (0.001 + 0.05 * e) * (0.35 + env) * uThickness;
81
+ float g = thick / (d + thick * 0.45);
82
+ g = g * g;
83
+
84
+ float h = fi / float(uStrandCount) + uv.x * 0.30 + uTime * 0.04 + uHueShift;
85
+ col += strandColor(h) * g * env;
86
+ }
87
+
88
+ col *= 0.45 + 0.7 * e;
89
+ col = 1.0 - exp(-col * uGlow);
90
+
91
+ float gray = dot(col, vec3(0.2126, 0.7152, 0.0722));
92
+ col = max(mix(vec3(gray), col, uSaturation), 0.0);
93
+
94
+ float lum = max(max(col.r, col.g), col.b);
95
+ float alpha = clamp(lum, 0.0, 1.0) * uOpacity;
96
+
97
+ fragColor = vec4(col * uOpacity, alpha);
98
+ }
99
+ `;
100
+
101
+ const GLASS_FRAG = `#version 300 es
102
+ precision highp float;
103
+
104
+ uniform sampler2D uScene;
105
+ uniform vec2 uResolution;
106
+ uniform float uRadius;
107
+ uniform float uRefraction;
108
+ uniform float uDispersion;
109
+
110
+ out vec4 fragColor;
111
+
112
+ vec2 toUv(vec2 p) {
113
+ return p * (uResolution.y / uResolution) + 0.5;
114
+ }
115
+
116
+ void main() {
117
+ vec2 p = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;
118
+ float d = length(p);
119
+ float r = uRadius;
120
+
121
+ float edge = fwidth(d) * 1.5;
122
+ float mask = 1.0 - smoothstep(r - edge, r + edge, d);
123
+ if (mask <= 0.0) {
124
+ fragColor = vec4(0.0);
125
+ return;
126
+ }
127
+
128
+ // sphere height: 0 at the rim, 1 at the center
129
+ float z = sqrt(max(r * r - d * d, 0.0)) / r;
130
+ float nd = d / r; // 0 at the center, 1 at the rim
131
+
132
+ // refraction is confined to a narrow band near the rim; the rest stays undistorted
133
+ vec2 dir = d > 0.0 ? p / d : vec2(0.0);
134
+ float lens = smoothstep(0.85, 1.0, nd) * pow(nd, 6.0);
135
+ vec2 offset = -dir * lens * uRefraction * 0.15;
136
+ vec2 disp = -dir * lens * uDispersion * 0.012;
137
+
138
+ vec3 light;
139
+ light.r = texture(uScene, toUv(p + offset - disp)).r;
140
+ light.g = texture(uScene, toUv(p + offset)).g;
141
+ light.b = texture(uScene, toUv(p + offset + disp)).b;
142
+
143
+ // neutral fresnel rim (no color tint so the glass stays clear)
144
+ float fres = pow(1.0 - z, 3.0);
145
+ vec3 rim = vec3(1.0) * fres * 0.18;
146
+
147
+ // specular highlight from the upper-left
148
+ vec2 lightDir = normalize(vec2(-0.55, 0.6));
149
+ float spec = pow(max(dot(p / max(r, 1e-4), lightDir), 0.0), 6.0);
150
+ spec *= (1.0 - smoothstep(r * 0.55, r, d));
151
+
152
+ vec3 emissive = light + rim + vec3(spec) * 0.4;
153
+ float emissiveA = clamp(max(max(emissive.r, emissive.g), emissive.b), 0.0, 1.0);
154
+
155
+ // almost clear glass body: only a faint neutral darkening, mostly near the rim
156
+ float bodyA = 0.05 + fres * 0.05;
157
+
158
+ // composite emissive light over the clear body (premultiplied)
159
+ float outA = emissiveA + bodyA * (1.0 - emissiveA);
160
+ vec3 outRGB = emissive;
161
+
162
+ outRGB *= mask;
163
+ outA *= mask;
164
+
165
+ fragColor = vec4(outRGB, outA);
166
+ }
167
+ `;
168
+
169
+ export function createRenderer(canvas, options, defaults) {
170
+ if (!canvas) return null;
171
+ let gl;
172
+ try {
173
+ gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false });
174
+ } catch {
175
+ return null;
176
+ }
177
+ if (!gl) return null;
178
+ const params = { ...defaults, ...options };
179
+ const programs = [];
180
+ const shaders = [];
181
+ let buffer;
182
+ let texture;
183
+ let framebuffer;
184
+ let destroyed = false;
185
+
186
+ function destroy() {
187
+ if (destroyed) return;
188
+ destroyed = true;
189
+ if (buffer) gl.deleteBuffer(buffer);
190
+ if (texture) gl.deleteTexture(texture);
191
+ if (framebuffer) gl.deleteFramebuffer(framebuffer);
192
+ programs.forEach(program => gl.deleteProgram(program));
193
+ shaders.forEach(shader => gl.deleteShader(shader));
194
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
195
+ }
196
+
197
+ function programFor(fragment) {
198
+ const program = gl.createProgram();
199
+ programs.push(program);
200
+ for (const [type, source] of [[gl.VERTEX_SHADER, VERT], [gl.FRAGMENT_SHADER, fragment]]) {
201
+ const shader = gl.createShader(type);
202
+ shaders.push(shader);
203
+ gl.shaderSource(shader, source);
204
+ gl.compileShader(shader);
205
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
206
+ gl.attachShader(program, shader);
207
+ }
208
+ gl.linkProgram(program);
209
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
210
+ return program;
211
+ }
212
+
213
+ try {
214
+ const scene = programFor(FRAG);
215
+ const glass = params.glass ? programFor(GLASS_FRAG) : null;
216
+ buffer = gl.createBuffer();
217
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
218
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
219
+ function use(program) {
220
+ gl.useProgram(program);
221
+ const position = gl.getAttribLocation(program, 'position');
222
+ gl.enableVertexAttribArray(position);
223
+ gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
224
+ }
225
+ function scalar(program, name, value) {
226
+ gl.uniform1f(gl.getUniformLocation(program, name), value);
227
+ }
228
+ use(scene);
229
+ const palette = params.colors.slice(0, 8);
230
+ const colors = Array.from({ length: 8 }, (_, index) => {
231
+ const hex = (palette[index] || palette[palette.length - 1] || '#ffffff').replace(/^#/, '');
232
+ return [0, 2, 4].map(offset => parseInt(hex.slice(offset, offset + 2), 16) / 255);
233
+ }).flat();
234
+ gl.uniform3fv(gl.getUniformLocation(scene, 'uColors[0]'), colors);
235
+ gl.uniform1i(gl.getUniformLocation(scene, 'uColorCount'), palette.length);
236
+ gl.uniform1i(gl.getUniformLocation(scene, 'uStrandCount'), Math.min(12, Math.max(1, Math.round(params.count))));
237
+ for (const name of ['speed', 'amplitude', 'waviness', 'thickness', 'glow', 'taper', 'spread', 'hueShift', 'intensity', 'opacity', 'scale', 'saturation']) {
238
+ let value = params[name];
239
+ if (name !== 'speed' && name !== 'hueShift') value = Math.max(0, value);
240
+ if (name === 'thickness' || name === 'scale') value = Math.max(0.0001, value);
241
+ if (name === 'opacity') value = Math.min(1, value);
242
+ scalar(scene, `u${name[0].toUpperCase()}${name.slice(1)}`, value);
243
+ }
244
+ const timeLocation = gl.getUniformLocation(scene, 'uTime');
245
+ if (glass) {
246
+ use(glass);
247
+ scalar(glass, 'uRadius', Math.max(0.0001, 0.46 * params.glassSize));
248
+ scalar(glass, 'uRefraction', params.refraction);
249
+ scalar(glass, 'uDispersion', params.dispersion);
250
+ gl.uniform1i(gl.getUniformLocation(glass, 'uScene'), 0);
251
+ texture = gl.createTexture();
252
+ gl.bindTexture(gl.TEXTURE_2D, texture);
253
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
254
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
255
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
256
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
257
+ framebuffer = gl.createFramebuffer();
258
+ }
259
+ let width = 0;
260
+ let height = 0;
261
+ return Object.freeze({
262
+ pointer: false,
263
+ resize(rect, devicePixelRatio) {
264
+ const dpr = Math.min(devicePixelRatio || 1, 2);
265
+ const nextWidth = Math.max(1, Math.round(rect.width * dpr));
266
+ const nextHeight = Math.max(1, Math.round(rect.height * dpr));
267
+ if (width === nextWidth && height === nextHeight) return;
268
+ width = canvas.width = nextWidth;
269
+ height = canvas.height = nextHeight;
270
+ gl.viewport(0, 0, width, height);
271
+ for (const program of [scene, glass].filter(Boolean)) {
272
+ use(program);
273
+ gl.uniform2f(gl.getUniformLocation(program, 'uResolution'), width, height);
274
+ }
275
+ if (glass) {
276
+ gl.bindTexture(gl.TEXTURE_2D, texture);
277
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
278
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
279
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
280
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
281
+ }
282
+ },
283
+ render(time) {
284
+ if (destroyed) return;
285
+ use(scene);
286
+ gl.bindFramebuffer(gl.FRAMEBUFFER, glass ? framebuffer : null);
287
+ gl.uniform1f(timeLocation, time * 0.001);
288
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
289
+ if (glass) {
290
+ use(glass);
291
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
292
+ gl.bindTexture(gl.TEXTURE_2D, texture);
293
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
294
+ }
295
+ },
296
+ destroy
297
+ });
298
+ } catch {
299
+ destroy();
300
+ return null;
301
+ }
302
+ }