@anglefeint/astro-theme 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anglefeint/astro-theme",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Anglefeint core theme package for Astro",
6
6
  "keywords": [
@@ -135,7 +135,7 @@ function extractThemeConfigObject(configSource) {
135
135
  }
136
136
 
137
137
  function localeKeyFromToken(key) {
138
- return /^[a-z]{2,3}(?:-[a-z0-9]+)?$/i.test(key) ? key.toLowerCase() : '';
138
+ return /^[a-z]{2,3}(?:-[a-z0-9]+)?$/i.test(key) ? key : '';
139
139
  }
140
140
 
141
141
  function localeObjectIsEnabled(localeObjectSource) {
@@ -107,6 +107,7 @@ const articleSchema = isArticle
107
107
 
108
108
  const extraSchemas = schema ? (Array.isArray(schema) ? schema : [schema]) : [];
109
109
  const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchemas].filter(Boolean);
110
+ const serializeJsonLd = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
110
111
  ---
111
112
 
112
113
  <!-- Global Metadata -->
@@ -178,6 +179,6 @@ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchem
178
179
 
179
180
  {
180
181
  jsonLdSchemas.map((schemaItem) => (
181
- <script is:inline type="application/ld+json" set:html={JSON.stringify(schemaItem)} />
182
+ <script is:inline type="application/ld+json" set:html={serializeJsonLd(schemaItem)} />
182
183
  ))
183
184
  }
@@ -25,6 +25,10 @@ type Props = {
25
25
  const { comments, resolvedLocale } = Astro.props;
26
26
  const normalizedCommentTerm = comments.TERM.trim();
27
27
  const normalizedCommentNumber = comments.NUMBER.trim();
28
+ const GISCUS_LOCALE_MAP: Record<string, string> = {
29
+ zh: 'zh-CN',
30
+ };
31
+ const giscusLocale = comments.LANG || GISCUS_LOCALE_MAP[resolvedLocale] || resolvedLocale;
28
32
  ---
29
33
 
30
34
  <script
@@ -42,7 +46,7 @@ const normalizedCommentNumber = comments.NUMBER.trim();
42
46
  data-emit-metadata={comments.EMIT_METADATA}
43
47
  data-input-position={comments.INPUT_POSITION}
44
48
  data-theme={comments.THEME}
45
- data-lang={comments.LANG || resolvedLocale}
49
+ data-lang={giscusLocale}
46
50
  data-loading={comments.LOADING}
47
51
  crossorigin={comments.CROSSORIGIN}
48
52
  async></script>
@@ -0,0 +1,235 @@
1
+ ---
2
+ import type { PaginationModel } from '../../utils/pagination';
3
+ import {
4
+ resolvePaginationItemVariant,
5
+ resolvePaginationVariant,
6
+ } from '../../utils/pagination-style';
7
+
8
+ interface Props {
9
+ model: PaginationModel;
10
+ locale: string;
11
+ blogRoot: string;
12
+ pathname: string;
13
+ labels: {
14
+ previous: string;
15
+ next: string;
16
+ paginationAria: string;
17
+ jumpTo: string;
18
+ jumpInputLabel: string;
19
+ jumpGo: string;
20
+ };
21
+ config: {
22
+ jump: {
23
+ enabled: boolean;
24
+ enterToGo: boolean;
25
+ };
26
+ style: {
27
+ enabled: boolean;
28
+ mode: 'random' | 'sequential' | 'fixed';
29
+ variants: number;
30
+ fixedVariant: number;
31
+ };
32
+ };
33
+ }
34
+
35
+ const { model, locale, blogRoot, pathname, labels, config } = Astro.props as Props;
36
+
37
+ const pageHref = (pageNum: number) => (pageNum === 1 ? blogRoot : `${blogRoot}${pageNum}/`);
38
+ const styleEnabled = config.style.enabled;
39
+ const styleMode = config.style.mode;
40
+ const styleVariants = Math.max(1, Math.min(12, Math.floor(config.style.variants)));
41
+ const styleFixedVariant = Math.max(
42
+ 1,
43
+ Math.min(styleVariants, Math.floor(config.style.fixedVariant))
44
+ );
45
+ const showJump = config.jump.enabled && model.showJump;
46
+
47
+ const wrapVariant = resolvePaginationVariant({
48
+ currentPage: model.currentPage,
49
+ totalPages: model.totalPages,
50
+ locale,
51
+ pathname,
52
+ config: {
53
+ ENABLED: styleEnabled,
54
+ MODE: styleMode,
55
+ VARIANTS: styleVariants,
56
+ FIXED_VARIANT: styleFixedVariant,
57
+ },
58
+ });
59
+
60
+ const itemVariantClass = (seed: string, index: number) => {
61
+ if (!styleEnabled) return 'pg-var-1';
62
+ if (styleMode === 'random') return 'pg-var-random';
63
+ return resolvePaginationItemVariant({
64
+ seed,
65
+ index,
66
+ config: {
67
+ ENABLED: styleEnabled,
68
+ MODE: styleMode,
69
+ VARIANTS: styleVariants,
70
+ FIXED_VARIANT: styleFixedVariant,
71
+ },
72
+ }).className;
73
+ };
74
+ ---
75
+
76
+ {
77
+ model.totalPages > 1 && (
78
+ <div
79
+ class={`pagination-wrap ${wrapVariant.className}`}
80
+ data-pagination-component="cyber"
81
+ data-style-enabled={styleEnabled ? 'true' : 'false'}
82
+ data-style-mode={styleMode}
83
+ data-style-variants={String(styleVariants)}
84
+ data-style-fixed-variant={String(styleFixedVariant)}
85
+ >
86
+ <nav class="pagination" aria-label={labels.paginationAria}>
87
+ {model.currentPage > 1 && (
88
+ <a
89
+ class={`pg-item pg-nav ${itemVariantClass(`${locale}:prev:${model.currentPage}`, 0)}`}
90
+ href={pageHref(model.currentPage - 1)}
91
+ aria-label={labels.previous}
92
+ >
93
+ {labels.previous}
94
+ </a>
95
+ )}
96
+ {model.items.map((item, index) =>
97
+ item.kind === 'ellipsis' ? (
98
+ <span
99
+ class={`pg-item pg-ellipsis ${itemVariantClass(`${locale}:ellipsis:${item.id}`, index)}`}
100
+ aria-hidden="true"
101
+ >
102
+ ...
103
+ </span>
104
+ ) : (
105
+ <a
106
+ class={`pg-item ${itemVariantClass(`${locale}:page:${item.page}`, index)} ${item.page === model.currentPage ? 'current' : ''}`}
107
+ href={pageHref(item.page)}
108
+ aria-current={item.page === model.currentPage ? 'page' : undefined}
109
+ >
110
+ {item.page}
111
+ </a>
112
+ )
113
+ )}
114
+ {model.currentPage < model.totalPages && (
115
+ <a
116
+ class={`pg-item pg-nav ${itemVariantClass(`${locale}:next:${model.currentPage}`, model.items.length + 2)}`}
117
+ href={pageHref(model.currentPage + 1)}
118
+ aria-label={labels.next}
119
+ >
120
+ {labels.next}
121
+ </a>
122
+ )}
123
+ </nav>
124
+ {showJump && (
125
+ <form
126
+ class="pagination-jump"
127
+ data-blog-root={blogRoot}
128
+ data-total-pages={String(model.totalPages)}
129
+ data-enter-to-go={config.jump.enterToGo ? 'true' : 'false'}
130
+ aria-label={labels.jumpTo}
131
+ >
132
+ <label class="sr-only" for="pagination-jump-input">
133
+ {labels.jumpInputLabel}
134
+ </label>
135
+ <span class="pagination-jump-label">{labels.jumpTo}</span>
136
+ <input
137
+ id="pagination-jump-input"
138
+ class="pagination-jump-input"
139
+ type="text"
140
+ inputmode="numeric"
141
+ pattern="[0-9]*"
142
+ value={model.currentPage}
143
+ />
144
+ <button type="submit" class="pagination-jump-btn">
145
+ {labels.jumpGo}
146
+ </button>
147
+ </form>
148
+ )}
149
+ </div>
150
+ )
151
+ }
152
+
153
+ <script is:inline>
154
+ document.querySelectorAll('[data-pagination-component="cyber"]').forEach((wrap) => {
155
+ if (!(wrap instanceof HTMLElement)) return;
156
+ const toInt = (value, fallback) => {
157
+ const parsed = Number.parseInt(value ?? '', 10);
158
+ return Number.isFinite(parsed) ? parsed : fallback;
159
+ };
160
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
161
+
162
+ const styleEnabled = wrap.dataset.styleEnabled === 'true';
163
+ const styleMode = wrap.dataset.styleMode || 'random';
164
+ const variants = clamp(toInt(wrap.dataset.styleVariants, 9), 1, 12);
165
+ const fixedVariant = clamp(toInt(wrap.dataset.styleFixedVariant, 1), 1, variants);
166
+
167
+ const items = wrap.querySelectorAll('.pagination .pg-item');
168
+
169
+ if (!styleEnabled) {
170
+ items.forEach((item) => {
171
+ item.className = item.className
172
+ .replace(/\bpg-var-\d+\b/g, '')
173
+ .replace(/\bpg-var-random\b/g, '')
174
+ .trim();
175
+ item.classList.add('pg-var-1');
176
+ });
177
+ } else if (styleMode === 'random') {
178
+ items.forEach((item) => {
179
+ item.className = item.className
180
+ .replace(/\bpg-var-\d+\b/g, '')
181
+ .replace(/\bpg-var-random\b/g, '')
182
+ .trim();
183
+ const variant = Math.floor(Math.random() * variants) + 1;
184
+ item.classList.add(`pg-var-${variant}`);
185
+ });
186
+ } else if (styleMode === 'fixed') {
187
+ items.forEach((item) => {
188
+ item.className = item.className
189
+ .replace(/\bpg-var-\d+\b/g, '')
190
+ .replace(/\bpg-var-random\b/g, '')
191
+ .trim();
192
+ item.classList.add(`pg-var-${fixedVariant}`);
193
+ });
194
+ }
195
+
196
+ const form = wrap.querySelector('.pagination-jump');
197
+ if (!(form instanceof HTMLFormElement)) return;
198
+
199
+ const blogRoot = form.getAttribute('data-blog-root') || '/blog/';
200
+ const totalPages = Math.max(1, parseInt(form.getAttribute('data-total-pages') || '1', 10));
201
+ const enterToGo = form.getAttribute('data-enter-to-go') !== 'false';
202
+ const input = form.querySelector('.pagination-jump-input');
203
+ if (!(input instanceof HTMLInputElement)) return;
204
+
205
+ const normalizeRoot = (root) => {
206
+ if (!root) return '/blog/';
207
+ return root.endsWith('/') ? root : `${root}/`;
208
+ };
209
+
210
+ const toHref = (pageNum) => {
211
+ const root = normalizeRoot(blogRoot);
212
+ return pageNum === 1 ? root : `${root}${pageNum}/`;
213
+ };
214
+
215
+ const runJump = () => {
216
+ const normalized = (input.value || '').replace(/[^\d]/g, '');
217
+ const value = Number.parseInt(normalized || '1', 10);
218
+ if (!Number.isFinite(value)) return;
219
+ const target = Math.max(1, Math.min(totalPages, Math.round(value)));
220
+ window.location.assign(toHref(target));
221
+ };
222
+
223
+ form.addEventListener('submit', (event) => {
224
+ event.preventDefault();
225
+ runJump();
226
+ });
227
+
228
+ if (!enterToGo) {
229
+ input.addEventListener('keydown', (event) => {
230
+ if (event.key !== 'Enter') return;
231
+ event.preventDefault();
232
+ });
233
+ }
234
+ });
235
+ </script>
@@ -80,7 +80,9 @@ const localeOptions = SUPPORTED_LOCALES.map((targetLocale) => ({
80
80
  <span class="nav-status-dot" aria-hidden="true"></span>
81
81
  <span class="nav-status-text">{labels.status}</span>
82
82
  </div>
83
- <SocialMenu links={SOCIAL_LINKS} />
83
+ <div class="social-menu-wrap">
84
+ <SocialMenu links={SOCIAL_LINKS} />
85
+ </div>
84
86
  </div>
85
87
  </div>
86
88
  </nav>
@@ -244,6 +246,9 @@ const localeOptions = SUPPORTED_LOCALES.map((targetLocale) => ({
244
246
  display: none !important;
245
247
  }
246
248
  .social-links {
249
+ display: flex;
250
+ }
251
+ .social-menu-wrap {
247
252
  display: none;
248
253
  }
249
254
  .lang-switcher {
@@ -32,7 +32,8 @@ export function localePath(locale: string, path = '/'): string {
32
32
 
33
33
  export function stripLocaleFromPath(pathname: string, locale: string): string {
34
34
  const prefix = `/${locale}`;
35
- if (!pathname.startsWith(prefix)) return pathname;
35
+ if (pathname === prefix) return '/';
36
+ if (!pathname.startsWith(`${prefix}/`)) return pathname;
36
37
  const withoutLocale = pathname.slice(prefix.length);
37
38
  return withoutLocale || '/';
38
39
  }
@@ -96,7 +96,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
96
96
  ethos: 'Hacker Ethos',
97
97
  now: 'Now',
98
98
  contact: 'Contact',
99
- regenerate: 'Regenerate',
99
+ regenerate: 'Replay scan',
100
100
  },
101
101
  blog: {
102
102
  title: 'Blog',
@@ -130,7 +130,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
130
130
  heroMonitor: 'neural monitor',
131
131
  heroSignalSync: 'signal sync active',
132
132
  heroModelOnline: 'model online',
133
- regenerate: 'Regenerate',
133
+ regenerate: 'Replay scan',
134
134
  relatedAria: 'Related posts',
135
135
  backToBlogAria: 'Back to blog',
136
136
  paginationAria: 'Pagination',
@@ -165,7 +165,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
165
165
  ethos: 'ハッカー精神',
166
166
  now: '現在',
167
167
  contact: '連絡先',
168
- regenerate: '再生成',
168
+ regenerate: 'スキャン再生',
169
169
  },
170
170
  blog: {
171
171
  title: 'ブログ',
@@ -199,7 +199,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
199
199
  heroMonitor: 'ニューラルモニター',
200
200
  heroSignalSync: 'シグナル同期中',
201
201
  heroModelOnline: 'モデルオンライン',
202
- regenerate: '再生成',
202
+ regenerate: 'スキャン再生',
203
203
  relatedAria: '関連記事',
204
204
  backToBlogAria: 'ブログへ戻る',
205
205
  paginationAria: 'ページネーション',
@@ -234,7 +234,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
234
234
  ethos: '해커 정신',
235
235
  now: '지금',
236
236
  contact: '연락처',
237
- regenerate: '재생성',
237
+ regenerate: '스캔 재생',
238
238
  },
239
239
  blog: {
240
240
  title: '블로그',
@@ -268,7 +268,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
268
268
  heroMonitor: '뉴럴 모니터',
269
269
  heroSignalSync: '신호 동기화 활성',
270
270
  heroModelOnline: '모델 온라인',
271
- regenerate: '재생성',
271
+ regenerate: '스캔 재생',
272
272
  relatedAria: '관련 글',
273
273
  backToBlogAria: '블로그로 돌아가기',
274
274
  paginationAria: '페이지네이션',
@@ -303,7 +303,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
303
303
  ethos: 'Ethos hacker',
304
304
  now: 'Ahora',
305
305
  contact: 'Contacto',
306
- regenerate: 'Regenerar',
306
+ regenerate: 'Repetir escaneo',
307
307
  },
308
308
  blog: {
309
309
  title: 'Blog',
@@ -338,7 +338,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
338
338
  heroMonitor: 'monitor neural',
339
339
  heroSignalSync: 'sincronización de señal activa',
340
340
  heroModelOnline: 'modelo en línea',
341
- regenerate: 'Regenerar',
341
+ regenerate: 'Repetir escaneo',
342
342
  relatedAria: 'Publicaciones relacionadas',
343
343
  backToBlogAria: 'Volver al blog',
344
344
  paginationAria: 'Paginación',
@@ -373,7 +373,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
373
373
  ethos: '黑客精神',
374
374
  now: '现在',
375
375
  contact: '联系',
376
- regenerate: '重新生成',
376
+ regenerate: '重播扫描',
377
377
  },
378
378
  blog: {
379
379
  title: '博客',
@@ -407,7 +407,7 @@ export const DEFAULT_MESSAGES: Record<string, Messages> = {
407
407
  heroMonitor: '神经监视器',
408
408
  heroSignalSync: '信号同步中',
409
409
  heroModelOnline: '模型在线',
410
- regenerate: '重新生成',
410
+ regenerate: '重播扫描',
411
411
  relatedAria: '相关文章',
412
412
  backToBlogAria: '返回博客',
413
413
  paginationAria: '分页导航',
@@ -22,6 +22,8 @@ type Props = CollectionEntry<'blog'>['data'] & {
22
22
  locale?: string;
23
23
  related?: CollectionEntry<'blog'>[];
24
24
  localeHrefs?: Partial<Record<Locale, string>>;
25
+ aiLatencyEstimated?: boolean;
26
+ aiConfidenceEstimated?: boolean;
25
27
  };
26
28
 
27
29
  const {
@@ -37,7 +39,9 @@ const {
37
39
  aiMode,
38
40
  aiState,
39
41
  aiLatencyMs,
42
+ aiLatencyEstimated = false,
40
43
  aiConfidence,
44
+ aiConfidenceEstimated = false,
41
45
  wordCount,
42
46
  tokenCount,
43
47
  author,
@@ -169,7 +173,7 @@ const hasCommentsConfig = Boolean(
169
173
  <Image
170
174
  class="hero-base-image"
171
175
  src={heroImage}
172
- alt={title}
176
+ alt=""
173
177
  loading="eager"
174
178
  decoding="async"
175
179
  fetchpriority="high"
@@ -257,12 +261,14 @@ const hasCommentsConfig = Boolean(
257
261
  <div class="ai-response-meta">
258
262
  {aiLatencyMs !== undefined && (
259
263
  <span>
260
- {messages.blog.latencyLabel} <strong>{aiLatencyMs}</strong> ms
264
+ {messages.blog.latencyLabel}{' '}
265
+ <strong>{aiLatencyEstimated ? `~${aiLatencyMs}` : aiLatencyMs}</strong> ms
261
266
  </span>
262
267
  )}
263
268
  {confidenceText !== undefined && (
264
269
  <span>
265
- {messages.blog.confidenceLabel} <strong>{confidenceText}</strong>
270
+ {messages.blog.confidenceLabel}{' '}
271
+ <strong>{aiConfidenceEstimated ? `~${confidenceText}` : confidenceText}</strong>
266
272
  </span>
267
273
  )}
268
274
  </div>
@@ -293,8 +299,11 @@ const hasCommentsConfig = Boolean(
293
299
  </div>
294
300
  )
295
301
  }
296
- <button type="button" class="ai-regenerate" aria-label={messages.blog.regenerate}
297
- >{messages.blog.regenerate}</button
302
+ <button
303
+ type="button"
304
+ class="ai-regenerate"
305
+ aria-label={messages.blog.regenerate}
306
+ title={messages.blog.regenerate}>{messages.blog.regenerate}</button
298
307
  >
299
308
  </div>
300
309
  </article>
@@ -310,7 +319,7 @@ const hasCommentsConfig = Boolean(
310
319
  >
311
320
  {p.data.heroImage ? (
312
321
  <div class="ai-related-img">
313
- <Image width={320} height={180} src={p.data.heroImage} alt={p.data.title} />
322
+ <Image width={320} height={180} src={p.data.heroImage} alt="" />
314
323
  </div>
315
324
  ) : (
316
325
  <div class="ai-related-placeholder">
@@ -10,7 +10,19 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
10
10
  const modalOverlay = document.getElementById('hacker-modal');
11
11
  const modalBody = document.getElementById('hacker-modal-body');
12
12
  const modalTitle = document.querySelector('.hacker-modal-title');
13
+ const closeButton = document.querySelector('.hacker-modal-close');
13
14
  if (!modalOverlay || !modalBody || !modalTitle) return;
15
+ let lastFocusedElement = null;
16
+
17
+ function getFocusableElements() {
18
+ return Array.from(
19
+ modalOverlay.querySelectorAll(
20
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
21
+ )
22
+ ).filter(
23
+ (element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true'
24
+ );
25
+ }
14
26
 
15
27
  const decryptorKeysLabel =
16
28
  typeof runtimeConfig.decryptorKeysLabel === 'string' ? runtimeConfig.decryptorKeysLabel : '';
@@ -36,10 +48,15 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
36
48
  if (modalEl) modalEl.classList.remove('hacker-modal-wide');
37
49
  modalOverlay.classList.remove('open');
38
50
  modalOverlay.setAttribute('aria-hidden', 'true');
51
+ if (lastFocusedElement && typeof lastFocusedElement.focus === 'function') {
52
+ lastFocusedElement.focus();
53
+ }
39
54
  };
40
55
 
41
56
  const openModal = (data) => {
42
57
  if (!data) return;
58
+ lastFocusedElement =
59
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
43
60
 
44
61
  decryptor.stop();
45
62
  if (cleanupKeyboard) {
@@ -84,6 +101,13 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
84
101
 
85
102
  modalOverlay.classList.add('open');
86
103
  modalOverlay.setAttribute('aria-hidden', 'false');
104
+ window.requestAnimationFrame(() => {
105
+ const focusables = getFocusableElements();
106
+ const nextFocus = focusables[0] || closeButton || modalOverlay;
107
+ if (nextFocus && typeof nextFocus.focus === 'function') {
108
+ nextFocus.focus();
109
+ }
110
+ });
87
111
  };
88
112
 
89
113
  const folderButtons = Array.from(document.querySelectorAll('.hacker-folder[data-modal]'));
@@ -97,7 +121,6 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
97
121
  return [button, onClick];
98
122
  });
99
123
 
100
- const closeButton = document.querySelector('.hacker-modal-close');
101
124
  if (closeButton) closeButton.addEventListener('click', closeModal);
102
125
 
103
126
  const onOverlayClick = (event) => {
@@ -106,7 +129,38 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
106
129
  modalOverlay.addEventListener('click', onOverlayClick);
107
130
 
108
131
  const onDocumentKeydown = (event) => {
109
- if (event.key === 'Escape' && modalOverlay.classList.contains('open')) closeModal();
132
+ if (!modalOverlay.classList.contains('open')) return;
133
+ if (event.key === 'Escape') {
134
+ closeModal();
135
+ return;
136
+ }
137
+ if (event.key !== 'Tab') return;
138
+
139
+ const focusables = getFocusableElements();
140
+ if (focusables.length === 0) {
141
+ event.preventDefault();
142
+ if (closeButton && typeof closeButton.focus === 'function') {
143
+ closeButton.focus();
144
+ }
145
+ return;
146
+ }
147
+
148
+ const currentIndex = focusables.indexOf(document.activeElement);
149
+ const first = focusables[0];
150
+ const last = focusables[focusables.length - 1];
151
+
152
+ if (event.shiftKey) {
153
+ if (document.activeElement === first || currentIndex === -1) {
154
+ event.preventDefault();
155
+ last.focus();
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (document.activeElement === last) {
161
+ event.preventDefault();
162
+ first.focus();
163
+ }
110
164
  };
111
165
  document.addEventListener('keydown', onDocumentKeydown);
112
166
 
@@ -6,9 +6,10 @@ export function initAboutReadingUi(runtimeConfig, prefersReducedMotion) {
6
6
  p60: 'inference stable',
7
7
  p90: 'output finalized',
8
8
  };
9
- var toastMessages = runtimeConfig.effects && runtimeConfig.effects.scrollToasts
10
- ? runtimeConfig.effects.scrollToasts
11
- : fallbackToasts;
9
+ var toastMessages =
10
+ runtimeConfig.effects && runtimeConfig.effects.scrollToasts
11
+ ? runtimeConfig.effects.scrollToasts
12
+ : fallbackToasts;
12
13
  var stageSeen = { p30: false, p60: false, p90: false };
13
14
  var toastTimer = 0;
14
15
  var hasScrolled = false;
@@ -18,9 +19,9 @@ export function initAboutReadingUi(runtimeConfig, prefersReducedMotion) {
18
19
  toast.textContent = '> ' + msg;
19
20
  toast.classList.add('visible');
20
21
  clearTimeout(toastTimer);
21
- toastTimer = setTimeout(function() {
22
+ toastTimer = setTimeout(function () {
22
23
  toast.classList.remove('visible');
23
- }, 900);
24
+ }, 1800);
24
25
  }
25
26
 
26
27
  if (progress) {
@@ -53,7 +54,7 @@ export function initAboutReadingUi(runtimeConfig, prefersReducedMotion) {
53
54
 
54
55
  var backTop = document.querySelector('.hacker-back-to-top');
55
56
  if (backTop) {
56
- backTop.addEventListener('click', function() {
57
+ backTop.addEventListener('click', function () {
57
58
  window.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
58
59
  });
59
60
  }
@@ -23,9 +23,9 @@ export function initReadProgressAndBackToTop(prefersReducedMotion) {
23
23
  toast.textContent = msg;
24
24
  toast.classList.add('visible');
25
25
  clearTimeout(toastTimer);
26
- toastTimer = setTimeout(function() {
26
+ toastTimer = setTimeout(function () {
27
27
  toast.classList.remove('visible');
28
- }, 900);
28
+ }, 1800);
29
29
  }
30
30
 
31
31
  if (progress) {
@@ -62,7 +62,7 @@ export function initReadProgressAndBackToTop(prefersReducedMotion) {
62
62
 
63
63
  var backTop = document.querySelector('.ai-back-to-top');
64
64
  if (backTop) {
65
- backTop.addEventListener('click', function() {
65
+ backTop.addEventListener('click', function () {
66
66
  window.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
67
67
  });
68
68
  }
@@ -0,0 +1,8 @@
1
+ export function clamp(value: number, min: number, max: number): number {
2
+ return Math.max(min, Math.min(max, value));
3
+ }
4
+
5
+ export function int(value: number): number {
6
+ if (!Number.isFinite(value)) return 0;
7
+ return Math.floor(value);
8
+ }
@@ -0,0 +1,81 @@
1
+ import { clamp, int } from './number';
2
+
3
+ type PaginationStyleMode = 'random' | 'sequential' | 'fixed';
4
+
5
+ interface PaginationStyleConfig {
6
+ ENABLED: boolean;
7
+ MODE: PaginationStyleMode;
8
+ VARIANTS: number;
9
+ FIXED_VARIANT: number;
10
+ }
11
+
12
+ interface ResolvePaginationVariantOptions {
13
+ currentPage: number;
14
+ totalPages: number;
15
+ locale: string;
16
+ pathname: string;
17
+ config: PaginationStyleConfig;
18
+ }
19
+
20
+ function hashString(value: string): number {
21
+ let hash = 5381;
22
+ for (let i = 0; i < value.length; i += 1) {
23
+ hash = (hash * 33) ^ value.charCodeAt(i);
24
+ }
25
+ return Math.abs(hash) >>> 0;
26
+ }
27
+
28
+ export function resolvePaginationVariant(options: ResolvePaginationVariantOptions): {
29
+ className: string;
30
+ variant: number;
31
+ } {
32
+ const variants = clamp(int(options.config.VARIANTS), 1, 12);
33
+ if (!options.config.ENABLED) {
34
+ return { className: 'cyber-pg-v1', variant: 1 };
35
+ }
36
+
37
+ const mode = options.config.MODE;
38
+ let variant = 1;
39
+
40
+ if (mode === 'fixed') {
41
+ variant = clamp(int(options.config.FIXED_VARIANT), 1, variants);
42
+ } else if (mode === 'sequential') {
43
+ variant = ((Math.max(1, int(options.currentPage)) - 1) % variants) + 1;
44
+ } else {
45
+ const seedKey = `${options.locale}:${options.pathname}:${options.currentPage}:${options.totalPages}`;
46
+ variant = (hashString(seedKey) % variants) + 1;
47
+ }
48
+
49
+ return {
50
+ className: `cyber-pg-v${variant}`,
51
+ variant,
52
+ };
53
+ }
54
+
55
+ interface ResolvePaginationItemVariantOptions {
56
+ seed: string;
57
+ index: number;
58
+ config: PaginationStyleConfig;
59
+ }
60
+
61
+ export function resolvePaginationItemVariant(options: ResolvePaginationItemVariantOptions): {
62
+ className: string;
63
+ variant: number;
64
+ } {
65
+ const variants = clamp(int(options.config.VARIANTS), 1, 12);
66
+ if (!options.config.ENABLED) {
67
+ return { className: 'pg-var-1', variant: 1 };
68
+ }
69
+
70
+ const mode = options.config.MODE;
71
+ let variant = 1;
72
+ if (mode === 'fixed') {
73
+ variant = clamp(int(options.config.FIXED_VARIANT), 1, variants);
74
+ } else if (mode === 'sequential') {
75
+ variant = (int(options.index) % variants) + 1;
76
+ } else {
77
+ variant = (hashString(options.seed) % variants) + 1;
78
+ }
79
+
80
+ return { className: `pg-var-${variant}`, variant };
81
+ }
@@ -0,0 +1,67 @@
1
+ import { clamp, int } from './number';
2
+
3
+ export type PaginationItem = { kind: 'page'; page: number } | { kind: 'ellipsis'; id: string };
4
+
5
+ export interface PaginationModel {
6
+ currentPage: number;
7
+ totalPages: number;
8
+ showJump: boolean;
9
+ items: PaginationItem[];
10
+ }
11
+
12
+ interface BuildPaginationModelOptions {
13
+ currentPage: number;
14
+ totalPages: number;
15
+ windowSize?: number;
16
+ showJumpThreshold?: number;
17
+ }
18
+
19
+ function addRange(set: Set<number>, start: number, end: number, totalPages: number) {
20
+ const s = clamp(int(start), 1, totalPages);
21
+ const e = clamp(int(end), 1, totalPages);
22
+ for (let page = s; page <= e; page += 1) set.add(page);
23
+ }
24
+
25
+ export function buildPaginationModel(options: BuildPaginationModelOptions): PaginationModel {
26
+ const totalPages = Math.max(1, int(options.totalPages));
27
+ const currentPage = clamp(int(options.currentPage), 1, totalPages);
28
+ const windowSize = clamp(int(options.windowSize ?? 7), 5, 21);
29
+ const showJumpThreshold = Math.max(1, int(options.showJumpThreshold ?? 12));
30
+ const showJump = totalPages > showJumpThreshold;
31
+
32
+ if (totalPages <= windowSize) {
33
+ return {
34
+ currentPage,
35
+ totalPages,
36
+ showJump,
37
+ items: Array.from({ length: totalPages }, (_, i) => ({ kind: 'page', page: i + 1 })),
38
+ };
39
+ }
40
+
41
+ const boundaryCount = 1;
42
+ const siblingCount = Math.max(1, Math.floor((windowSize - (boundaryCount * 2 + 3)) / 2));
43
+ const nearEdgeSlots = boundaryCount + siblingCount * 2 + 2;
44
+ const picked = new Set<number>();
45
+
46
+ addRange(picked, 1, boundaryCount, totalPages);
47
+ addRange(picked, totalPages - boundaryCount + 1, totalPages, totalPages);
48
+ addRange(picked, currentPage - siblingCount, currentPage + siblingCount, totalPages);
49
+
50
+ if (currentPage <= nearEdgeSlots) addRange(picked, 1, nearEdgeSlots + 1, totalPages);
51
+ if (currentPage >= totalPages - nearEdgeSlots + 1) {
52
+ addRange(picked, totalPages - nearEdgeSlots, totalPages, totalPages);
53
+ }
54
+
55
+ const pages = [...picked].sort((a, b) => a - b);
56
+ const items: PaginationItem[] = [];
57
+ for (let i = 0; i < pages.length; i += 1) {
58
+ const page = pages[i];
59
+ const prev = pages[i - 1];
60
+ if (typeof prev === 'number' && page - prev > 1) {
61
+ items.push({ kind: 'ellipsis', id: `ellipsis-${prev}-${page}` });
62
+ }
63
+ items.push({ kind: 'page', page });
64
+ }
65
+
66
+ return { currentPage, totalPages, showJump, items };
67
+ }