@hanology/cham-browser 0.4.63 → 0.4.65

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": "@hanology/cham-browser",
3
- "version": "0.4.63",
3
+ "version": "0.4.65",
4
4
  "description": "CHAM — browser-compatible parser, serializer, and site generator for Classical Han Annotated Markdown",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -2,7 +2,7 @@
2
2
  <html lang="zh-Hant">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>CHAM</title>
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -168,4 +168,11 @@ function onKey(event: KeyboardEvent) {
168
168
  color: var(--ink-faint);
169
169
  letter-spacing: 1px;
170
170
  }
171
+
172
+ .route-loading {
173
+ display: flex;
174
+ align-items: center;
175
+ justify-content: center;
176
+ min-height: 100vh;
177
+ }
171
178
  </style>
@@ -146,7 +146,7 @@ onBeforeUnmount(() => {
146
146
  <Teleport to="body">
147
147
  <Transition name="ann-dim">
148
148
  <div
149
- v-if="visible && annotations.length && vertical && isMobile"
149
+ v-if="visible && annotations.length && vertical && ww < 768"
150
150
  class="ann-pane-dim"
151
151
  @click="emit('close')"
152
152
  />
@@ -341,18 +341,18 @@ onBeforeUnmount(() => {
341
341
  line-height: 1.5;
342
342
  }
343
343
 
344
- .ann-pane-kind.pronunciation { background: var(--jade); color: #fff; }
345
- .ann-pane-kind.semantic { background: var(--vermillion); color: #fff; }
346
- .ann-pane-kind.etymology { background: var(--ann-etymology); color: #fff; }
344
+ .ann-pane-kind.pronunciation { background: var(--jade); color: var(--paper); }
345
+ .ann-pane-kind.semantic { background: var(--vermillion); color: var(--paper); }
346
+ .ann-pane-kind.etymology { background: var(--ann-etymology); color: var(--paper); }
347
347
  .ann-pane-kind.note,
348
348
  .ann-pane-kind.definition { background: var(--ink); color: var(--paper); }
349
- .ann-pane-kind.commentary { background: var(--ann-commentary); color: #fff; }
350
- .ann-pane-kind.translation { background: var(--ann-translation); color: #fff; }
351
- .ann-pane-kind.person { background: var(--ann-person); color: #fff; }
352
- .ann-pane-kind.place { background: var(--ann-place); color: #fff; }
353
- .ann-pane-kind.event { background: var(--ann-event); color: #fff; }
354
- .ann-pane-kind.date { background: var(--ann-date); color: #fff; }
355
- .ann-pane-kind.allusion { background: var(--ann-allusion); color: #fff; }
349
+ .ann-pane-kind.commentary { background: var(--ann-commentary); color: var(--paper); }
350
+ .ann-pane-kind.translation { background: var(--ann-translation); color: var(--paper); }
351
+ .ann-pane-kind.person { background: var(--ann-person); color: var(--paper); }
352
+ .ann-pane-kind.place { background: var(--ann-place); color: var(--paper); }
353
+ .ann-pane-kind.event { background: var(--ann-event); color: var(--paper); }
354
+ .ann-pane-kind.date { background: var(--ann-date); color: var(--paper); }
355
+ .ann-pane-kind.allusion { background: var(--ann-allusion); color: var(--paper); }
356
356
 
357
357
  .ann-pane-layer {
358
358
  font-size: 10px;
@@ -231,18 +231,18 @@ onBeforeUnmount(() => {
231
231
  letter-spacing: 1px;
232
232
  line-height: 1.5;
233
233
  }
234
- .ann-kind.pronunciation { background: var(--jade); color: #fff; }
235
- .ann-kind.semantic { background: var(--vermillion); color: #fff; }
236
- .ann-kind.etymology { background: var(--ann-etymology); color: #fff; }
234
+ .ann-kind.pronunciation { background: var(--jade); color: var(--paper); }
235
+ .ann-kind.semantic { background: var(--vermillion); color: var(--paper); }
236
+ .ann-kind.etymology { background: var(--ann-etymology); color: var(--paper); }
237
237
  .ann-kind.note,
238
238
  .ann-kind.definition { background: var(--ink); color: var(--paper); }
239
- .ann-kind.commentary { background: var(--ann-commentary); color: #fff; }
240
- .ann-kind.translation { background: var(--ann-translation); color: #fff; }
241
- .ann-kind.person { background: var(--ann-person); color: #fff; }
242
- .ann-kind.place { background: var(--ann-place); color: #fff; }
243
- .ann-kind.event { background: var(--ann-event); color: #fff; }
244
- .ann-kind.date { background: var(--ann-date); color: #fff; }
245
- .ann-kind.allusion { background: var(--ann-allusion); color: #fff; }
239
+ .ann-kind.commentary { background: var(--ann-commentary); color: var(--paper); }
240
+ .ann-kind.translation { background: var(--ann-translation); color: var(--paper); }
241
+ .ann-kind.person { background: var(--ann-person); color: var(--paper); }
242
+ .ann-kind.place { background: var(--ann-place); color: var(--paper); }
243
+ .ann-kind.event { background: var(--ann-event); color: var(--paper); }
244
+ .ann-kind.date { background: var(--ann-date); color: var(--paper); }
245
+ .ann-kind.allusion { background: var(--ann-allusion); color: var(--paper); }
246
246
 
247
247
  .ann-layer {
248
248
  font-size: 10px;
@@ -364,7 +364,7 @@ onBeforeUnmount(() => {
364
364
  }
365
365
 
366
366
  .ann-sheet-scroll {
367
- padding: 4px 16px 24px;
367
+ padding: 4px 16px max(24px, env(safe-area-inset-bottom, 0px));
368
368
  overflow-y: auto;
369
369
  overscroll-behavior: contain;
370
370
  flex: 1;
@@ -384,17 +384,45 @@ onBeforeUnmount(() => {
384
384
 
385
385
  /* ─── Active annotation on page ─── */
386
386
  :global(.ann-target.ann-active) {
387
- background: rgba(194, 58, 43, 0.12) !important;
388
- box-shadow: 0 0 0 2px rgba(194, 58, 43, 0.15);
387
+ background: color-mix(in srgb, var(--vermillion) 12%, transparent) !important;
388
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--vermillion) 15%, transparent);
389
389
  border-radius: 2px;
390
390
  }
391
391
  :global(.ann-target.ann-active.pronunciation) {
392
- background: rgba(58, 107, 94, 0.12) !important;
393
- box-shadow: 0 0 0 2px rgba(58, 107, 94, 0.15);
392
+ background: color-mix(in srgb, var(--jade) 12%, transparent) !important;
393
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--jade) 15%, transparent);
394
394
  }
395
395
  :global(.ann-target.ann-active.person) {
396
- background: rgba(58, 90, 140, 0.12) !important;
397
- box-shadow: 0 0 0 2px rgba(58, 90, 140, 0.15);
396
+ background: color-mix(in srgb, var(--ann-person) 12%, transparent) !important;
397
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ann-person) 15%, transparent);
398
+ }
399
+ :global(.ann-target.ann-active.place) {
400
+ background: color-mix(in srgb, var(--ann-place) 12%, transparent) !important;
401
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ann-place) 15%, transparent);
402
+ }
403
+ :global(.ann-target.ann-active.event) {
404
+ background: color-mix(in srgb, var(--ann-event) 12%, transparent) !important;
405
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ann-event) 15%, transparent);
406
+ }
407
+ :global(.ann-target.ann-active.date) {
408
+ background: color-mix(in srgb, var(--ann-date) 12%, transparent) !important;
409
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ann-date) 15%, transparent);
410
+ }
411
+ :global(.ann-target.ann-active.allusion) {
412
+ background: color-mix(in srgb, var(--ann-allusion) 12%, transparent) !important;
413
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ann-allusion) 15%, transparent);
414
+ }
415
+ :global(.ann-target.ann-active.etymology) {
416
+ background: color-mix(in srgb, var(--ann-etymology) 12%, transparent) !important;
417
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ann-etymology) 15%, transparent);
418
+ }
419
+ :global(.ann-target.ann-active.commentary) {
420
+ background: color-mix(in srgb, var(--ann-commentary) 12%, transparent) !important;
421
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ann-commentary) 15%, transparent);
422
+ }
423
+ :global(.ann-target.ann-active.translation) {
424
+ background: color-mix(in srgb, var(--ann-translation) 12%, transparent) !important;
425
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ann-translation) 15%, transparent);
398
426
  }
399
427
 
400
428
  @media (min-width: 768px) {
@@ -61,7 +61,7 @@ onUnmounted(detach)
61
61
 
62
62
  <template>
63
63
  <Transition name="btt">
64
- <button v-if="visible" class="btt" :class="{ 'btt-v': vertical }" @click="scrollToTop" :aria-label="vertical ? t('action.backToStart') : t('action.backToStart')">
64
+ <button v-if="visible" class="btt" :class="{ 'btt-v': vertical }" @click="scrollToTop" :aria-label="vertical ? t('action.backToStart') : t('action.backToTop')">
65
65
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
66
66
  <path d="M12 19V5M5 12l7-7 7 7"/>
67
67
  </svg>
@@ -72,7 +72,7 @@ onUnmounted(detach)
72
72
  <style scoped>
73
73
  .btt {
74
74
  position: fixed;
75
- bottom: 80px;
75
+ bottom: max(80px, calc(72px + env(safe-area-inset-bottom, 0px)));
76
76
  right: 24px;
77
77
  width: 40px;
78
78
  height: 40px;
@@ -98,7 +98,7 @@ onUnmounted(detach)
98
98
  }
99
99
 
100
100
  @media (max-width: 768px) {
101
- .btt { bottom: 88px; right: 16px; width: 36px; height: 36px; }
101
+ .btt { bottom: max(88px, calc(80px + env(safe-area-inset-bottom, 0px))); right: 16px; width: 36px; height: 36px; }
102
102
  }
103
103
  .btt:hover {
104
104
  background: var(--vermillion);
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  import type { Annotation, VerseLine } from '../types'
3
- import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations, countVerseSpans } from '../composables/useAnnotationRenderer'
4
+ import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations } from '../composables/useAnnotationRenderer'
4
5
 
5
6
  const props = defineProps<{
6
7
  title: string
@@ -15,11 +16,23 @@ const emit = defineEmits<{
15
16
  annotationTap: [event: MouseEvent, annotations: Annotation[]]
16
17
  }>()
17
18
 
19
+ const allVerseSpans = computed(() =>
20
+ props.verses.map((_, i) => buildVerseAnnotations(props.annotations, i))
21
+ )
22
+
23
+ const verseOffsets = computed(() => {
24
+ const offsets: number[] = []
25
+ let acc = 0
26
+ for (const spans of allVerseSpans.value) {
27
+ offsets.push(acc)
28
+ acc += spans.length
29
+ }
30
+ return offsets
31
+ })
32
+
18
33
  function verseHtml(index: number): string {
19
- let offset = 0
20
- for (let i = 0; i < index; i++) offset += countVerseSpans(props.annotations, i)
21
- const spans = buildVerseAnnotations(props.annotations, index)
22
- return renderAnnotatedText(props.verses[index].text, spans, false, offset)
34
+ const spans = allVerseSpans.value[index]
35
+ return renderAnnotatedText(props.verses[index].text, spans, false, verseOffsets.value[index])
23
36
  }
24
37
 
25
38
  function onHover(event: MouseEvent) {
@@ -97,8 +97,8 @@ const preview = computed(() => {
97
97
  box-sizing: border-box;
98
98
  overflow: hidden;
99
99
  height: auto;
100
- -webkit-mask-image: linear-gradient(to left, black 60%, transparent);
101
- mask-image: linear-gradient(to left, black 60%, transparent);
100
+ -webkit-mask-image: linear-gradient(to left, black 80%, transparent);
101
+ mask-image: linear-gradient(to left, black 80%, transparent);
102
102
  }
103
103
  .pc-vertical .pc-num {
104
104
  font-size: 11px;
@@ -1,15 +1,25 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  import type { PronSegment } from '../types'
4
+ import { useI18n } from '../composables/useI18n'
3
5
 
4
- defineProps<{
6
+ const { t } = useI18n()
7
+
8
+ const props = defineProps<{
5
9
  segment: PronSegment
6
10
  }>()
11
+
12
+ const label = computed(() => {
13
+ if (props.segment.lang === 'yue') return t('pron.yue')
14
+ if (props.segment.lang === 'cmn') return t('pron.cmn')
15
+ return props.segment.label
16
+ })
7
17
  </script>
8
18
 
9
19
  <template>
10
20
  <span class="pron-group">
11
21
  <span class="pron-badge" :class="segment.lang === 'yue' ? 'pron-yue' : 'pron-cmn'">
12
- {{ segment.label }}
22
+ {{ label }}
13
23
  </span>
14
24
  <span class="pron-text">{{ segment.parts.join(' ') }}</span>
15
25
  </span>
@@ -39,7 +49,7 @@ defineProps<{
39
49
  }
40
50
  .pron-yue {
41
51
  background: var(--jade);
42
- color: #fff;
52
+ color: var(--paper);
43
53
  }
44
54
  .pron-cmn {
45
55
  background: var(--ink);
@@ -15,7 +15,7 @@ function close() { open.value = false }
15
15
  <template>
16
16
  <div class="rt" :class="{ open }">
17
17
  <button class="rt-fab" @click="toggle" :aria-label="open ? t('settings.close') : t('settings.reading')">
18
- <span v-if="!open" class="rt-icon">設</span>
18
+ <span v-if="!open" class="rt-icon">{{ t('settings.shortTitle').charAt(0) }}</span>
19
19
  <span v-else class="rt-icon">✕</span>
20
20
  </button>
21
21
  <div v-if="open" class="rt-panel" @click.stop>
@@ -117,13 +117,13 @@ function close() { open.value = false }
117
117
  <style scoped>
118
118
  .rt {
119
119
  position: fixed;
120
- bottom: 24px;
120
+ bottom: max(24px, calc(16px + env(safe-area-inset-bottom, 0px)));
121
121
  right: 24px;
122
122
  z-index: 500;
123
123
  }
124
124
 
125
125
  @media (max-width: 768px) {
126
- .rt { bottom: 16px; right: 16px; }
126
+ .rt { bottom: max(16px, calc(12px + env(safe-area-inset-bottom, 0px))); right: 16px; }
127
127
  .rt-panel {
128
128
  position: fixed;
129
129
  bottom: 0;
@@ -134,6 +134,7 @@ function close() { open.value = false }
134
134
  max-height: 60vh;
135
135
  overflow-y: auto;
136
136
  overscroll-behavior: contain;
137
+ padding-bottom: max(16px, env(safe-area-inset-bottom, 0px));
137
138
  animation: slideUpMobile 0.3s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
138
139
  }
139
140
  @keyframes slideUpMobile {
@@ -1,8 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, onMounted, onUnmounted } from 'vue'
3
3
  import { parseAnnotationBlock } from '../utils/annotationParser'
4
+ import { useI18n } from '../composables/useI18n'
4
5
  import PronunciationGroup from './PronunciationGroup.vue'
5
6
 
7
+ const { locale } = useI18n()
8
+
6
9
  const props = defineProps<{
7
10
  num: string
8
11
  label: string
@@ -49,6 +52,12 @@ const displayNum = computed(() => {
49
52
  return props.num
50
53
  })
51
54
 
55
+ const displayLabel = computed(() => {
56
+ if (!props.special) return props.label
57
+ if (locale.value === 'en') return props.label
58
+ return '【' + props.label + '】'
59
+ })
60
+
52
61
  const entries = computed(() =>
53
62
  props.isAnnotations ? parseAnnotationBlock(props.text) : []
54
63
  )
@@ -64,7 +73,7 @@ const paragraphsHtml = computed(() => {
64
73
  <div v-if="text" ref="rootRef" class="sb-root" :class="{ 'sb-vertical': vertical, 'sb-visible': visible }">
65
74
  <div class="sb-header">
66
75
  <span v-if="displayNum" class="sb-num" :class="{ special }">{{ displayNum }}</span>
67
- <h3>{{ special ? '【' + label + '】' : label }}</h3>
76
+ <h3>{{ displayLabel }}</h3>
68
77
  </div>
69
78
  <div v-if="isAnnotations" class="sb-text sb-ann-list">
70
79
  <div v-for="entry in entries" :key="entry.num" class="sb-ann-entry">
@@ -104,7 +113,7 @@ const paragraphsHtml = computed(() => {
104
113
  .sb-num {
105
114
  display: inline-flex; align-items: center; justify-content: center;
106
115
  width: 28px; height: 28px; border-radius: 50%;
107
- background: var(--vermillion); color: #fff;
116
+ background: var(--vermillion); color: var(--paper);
108
117
  font-family: var(--sans); font-size: 13px; font-weight: 700;
109
118
  flex-shrink: 0;
110
119
  }
@@ -142,7 +151,7 @@ const paragraphsHtml = computed(() => {
142
151
  height: 22px;
143
152
  border-radius: 4px;
144
153
  background: var(--vermillion);
145
- color: #fff;
154
+ color: var(--paper);
146
155
  font-family: var(--sans);
147
156
  font-size: 12px;
148
157
  font-weight: 700;
@@ -135,7 +135,7 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
135
135
  border-left: 1px solid var(--border);
136
136
  display: flex; flex-direction: column;
137
137
  align-items: center;
138
- padding: 12px 0;
138
+ padding: max(12px, env(safe-area-inset-top, 0px)) 0 max(12px, env(safe-area-inset-bottom, 0px));
139
139
  z-index: 200;
140
140
  gap: 8px;
141
141
  }
@@ -347,11 +347,11 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
347
347
  }
348
348
 
349
349
  @media (max-width: 768px) {
350
- .sidenav { width: 44px; padding: 8px 0; gap: 5px; }
350
+ .sidenav { width: 44px; padding: max(8px, env(safe-area-inset-top, 0px)) 0 max(8px, env(safe-area-inset-bottom, 0px)); gap: 5px; }
351
351
  .sn-brand { width: 30px; height: 36px; margin-bottom: 2px; }
352
352
  .sn-seal { font-size: 14px; }
353
- .sn-btn { width: 28px; height: 28px; }
354
- .sn-btn svg { width: 15px; height: 15px; }
353
+ .sn-btn { width: 32px; height: 32px; }
354
+ .sn-btn svg { width: 16px; height: 16px; }
355
355
  .sn-context { font-size: 10px; max-height: 70px; }
356
356
  .sn-settings { width: 180px; right: 52px; padding: 12px; }
357
357
  .sn-layout-tag { width: 20px; height: 20px; font-size: 10px; }
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  import type { Annotation, VerseLine } from '../types'
3
- import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations, countVerseSpans } from '../composables/useAnnotationRenderer'
4
+ import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations } from '../composables/useAnnotationRenderer'
4
5
 
5
6
  const props = defineProps<{
6
7
  title: string
@@ -17,11 +18,23 @@ const emit = defineEmits<{
17
18
  openAuthor: [name: string]
18
19
  }>()
19
20
 
21
+ const allVerseSpans = computed(() =>
22
+ props.verses.map((_, i) => buildVerseAnnotations(props.annotations, i))
23
+ )
24
+
25
+ const verseOffsets = computed(() => {
26
+ const offsets: number[] = []
27
+ let acc = 0
28
+ for (const spans of allVerseSpans.value) {
29
+ offsets.push(acc)
30
+ acc += spans.length
31
+ }
32
+ return offsets
33
+ })
34
+
20
35
  function verseHtml(index: number): string {
21
- let offset = 0
22
- for (let i = 0; i < index; i++) offset += countVerseSpans(props.annotations, i)
23
- const spans = buildVerseAnnotations(props.annotations, index)
24
- return renderAnnotatedText(props.verses[index].text, spans, true, offset)
36
+ const spans = allVerseSpans.value[index]
37
+ return renderAnnotatedText(props.verses[index].text, spans, true, verseOffsets.value[index])
25
38
  }
26
39
 
27
40
  function onHover(event: MouseEvent) {
@@ -60,7 +73,7 @@ function onTap(event: MouseEvent) {
60
73
  --ann-shadow-y: -2px;
61
74
  writing-mode: vertical-rl;
62
75
  text-orientation: mixed;
63
- height: calc(100vh - 100px);
76
+ height: calc(100vh - var(--nav-width, 56px));
64
77
  padding: 32px 24px;
65
78
  background: var(--surface);
66
79
  border: 1px solid var(--border);
@@ -78,6 +78,7 @@ const messages: Record<Locale, Record<string, string>> = {
78
78
  'annotation.noteCount': '{count}注',
79
79
  'action.close': '關閉',
80
80
  'action.backToStart': '回到起始',
81
+ 'action.backToTop': '回到頂部',
81
82
  'shortcut.toggleLayout': '直/橫',
82
83
  'shortcut.toggleTheme': '主題',
83
84
  'shortcut.goHome': '首頁',
@@ -93,8 +94,9 @@ const messages: Record<Locale, Record<string, string>> = {
93
94
  'theme.sepia': '暖',
94
95
  'theme.dark': '暗',
95
96
  'theme.oled': '黑',
96
- 'annotation.kind.pronunciation': '讀音',
97
- 'annotation.kind.semantic': '釋義',
97
+ 'pron.yue': '',
98
+ 'pron.cmn': '',
99
+ 'annotation.kind.pronunciation': '讀音', 'annotation.kind.semantic': '釋義',
98
100
  'annotation.kind.etymology': '詞源',
99
101
  'annotation.kind.note': '備注',
100
102
  'annotation.kind.definition': '釋義',
@@ -175,6 +177,7 @@ const messages: Record<Locale, Record<string, string>> = {
175
177
  'annotation.noteCount': '{count}注',
176
178
  'action.close': '关闭',
177
179
  'action.backToStart': '回到起始',
180
+ 'action.backToTop': '回到顶部',
178
181
  'shortcut.toggleLayout': '直/横',
179
182
  'shortcut.toggleTheme': '主题',
180
183
  'shortcut.goHome': '首页',
@@ -190,6 +193,8 @@ const messages: Record<Locale, Record<string, string>> = {
190
193
  'theme.sepia': '暖',
191
194
  'theme.dark': '暗',
192
195
  'theme.oled': '黑',
196
+ 'pron.yue': '粤',
197
+ 'pron.cmn': '普',
193
198
  'annotation.kind.pronunciation': '读音',
194
199
  'annotation.kind.semantic': '释义',
195
200
  'annotation.kind.etymology': '词源',
@@ -272,6 +277,7 @@ const messages: Record<Locale, Record<string, string>> = {
272
277
  'annotation.noteCount': '{count} notes',
273
278
  'action.close': 'Close',
274
279
  'action.backToStart': 'Back to start',
280
+ 'action.backToTop': 'Back to top',
275
281
  'shortcut.toggleLayout': 'V/H',
276
282
  'shortcut.toggleTheme': 'Theme',
277
283
  'shortcut.goHome': 'Home',
@@ -287,6 +293,8 @@ const messages: Record<Locale, Record<string, string>> = {
287
293
  'theme.sepia': 'Warm',
288
294
  'theme.dark': 'Dark',
289
295
  'theme.oled': 'Black',
296
+ 'pron.yue': 'Yue',
297
+ 'pron.cmn': 'Man',
290
298
  'annotation.kind.pronunciation': 'Pronunciation',
291
299
  'annotation.kind.semantic': 'Definition',
292
300
  'annotation.kind.etymology': 'Etymology',
@@ -5,13 +5,6 @@ export type LayoutMode = 'horizontal' | 'vertical'
5
5
 
6
6
  export const THEMES: Theme[] = ['light', 'sepia', 'dark', 'oled']
7
7
 
8
- export const THEME_LABELS: Record<Theme, string> = {
9
- light: '亮',
10
- sepia: '暖',
11
- dark: '暗',
12
- oled: '黑',
13
- }
14
-
15
8
  export const FONT_SIZES = [12, 14, 16, 18, 20, 22, 24, 28, 32] as const
16
9
  export type FontSize = typeof FONT_SIZES[number]
17
10
 
@@ -18,32 +18,44 @@
18
18
 
19
19
  /* ─── Annotation kind hover states ─── */
20
20
  .ann-target:hover {
21
- background: rgba(194, 58, 43, 0.1);
22
- box-shadow: 0 var(--ann-shadow-y, 2px) 8px rgba(194, 58, 43, 0.08);
21
+ background: color-mix(in srgb, var(--vermillion) 10%, transparent);
22
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--vermillion) 8%, transparent);
23
23
  }
24
24
  .ann-target.pronunciation:hover {
25
- background: rgba(58, 107, 94, 0.1);
26
- box-shadow: 0 var(--ann-shadow-y, 2px) 8px rgba(58, 107, 94, 0.08);
25
+ background: color-mix(in srgb, var(--jade) 10%, transparent);
26
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--jade) 8%, transparent);
27
27
  }
28
28
  .ann-target.person:hover {
29
- background: rgba(58, 90, 140, 0.1);
30
- box-shadow: 0 var(--ann-shadow-y, 2px) 8px rgba(58, 90, 140, 0.08);
29
+ background: color-mix(in srgb, var(--ann-person) 10%, transparent);
30
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--ann-person) 8%, transparent);
31
31
  }
32
32
  .ann-target.place:hover {
33
- background: rgba(139, 105, 20, 0.1);
34
- box-shadow: 0 var(--ann-shadow-y, 2px) 8px rgba(139, 105, 20, 0.08);
33
+ background: color-mix(in srgb, var(--ann-place) 10%, transparent);
34
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--ann-place) 8%, transparent);
35
35
  }
36
36
  .ann-target.event:hover {
37
- background: rgba(107, 76, 138, 0.1);
38
- box-shadow: 0 var(--ann-shadow-y, 2px) 8px rgba(107, 76, 138, 0.08);
37
+ background: color-mix(in srgb, var(--ann-event) 10%, transparent);
38
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--ann-event) 8%, transparent);
39
39
  }
40
40
  .ann-target.date:hover {
41
- background: rgba(42, 122, 122, 0.1);
42
- box-shadow: 0 var(--ann-shadow-y, 2px) 8px rgba(42, 122, 122, 0.08);
41
+ background: color-mix(in srgb, var(--ann-date) 10%, transparent);
42
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--ann-date) 8%, transparent);
43
43
  }
44
44
  .ann-target.allusion:hover {
45
- background: rgba(181, 101, 29, 0.1);
46
- box-shadow: 0 var(--ann-shadow-y, 2px) 8px rgba(181, 101, 29, 0.08);
45
+ background: color-mix(in srgb, var(--ann-allusion) 10%, transparent);
46
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--ann-allusion) 8%, transparent);
47
+ }
48
+ .ann-target.etymology:hover {
49
+ background: color-mix(in srgb, var(--ann-etymology) 10%, transparent);
50
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--ann-etymology) 8%, transparent);
51
+ }
52
+ .ann-target.commentary:hover {
53
+ background: color-mix(in srgb, var(--ann-commentary) 10%, transparent);
54
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--ann-commentary) 8%, transparent);
55
+ }
56
+ .ann-target.translation:hover {
57
+ background: color-mix(in srgb, var(--ann-translation) 10%, transparent);
58
+ box-shadow: 0 var(--ann-shadow-y, 2px) 8px color-mix(in srgb, var(--ann-translation) 8%, transparent);
47
59
  }
48
60
 
49
61
  /* ─── Annotation number ─── */
@@ -167,7 +167,7 @@ button:focus-visible {
167
167
 
168
168
  /* ===== ACTIVE ANNOTATION FLASH ===== */
169
169
  @keyframes ann-flash-anim {
170
- 0% { background: rgba(194, 58, 43, 0.25); box-shadow: 0 0 12px rgba(194, 58, 43, 0.15); }
170
+ 0% { background: color-mix(in srgb, var(--vermillion) 25%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--vermillion) 15%, transparent); }
171
171
  100% { background: transparent; box-shadow: none; }
172
172
  }
173
173
 
@@ -251,7 +251,7 @@ button:focus-visible {
251
251
  flex-direction: row-reverse;
252
252
  overflow-x: auto;
253
253
  overflow-y: hidden;
254
- margin-right: var(--nav-width, 56px);
254
+ margin-right: calc(var(--nav-width, 56px) + env(safe-area-inset-right, 0px));
255
255
  scrollbar-width: thin;
256
256
  scrollbar-color: var(--gold) transparent;
257
257
  scroll-snap-type: x proximity;
@@ -328,3 +328,13 @@ button:focus-visible {
328
328
  [data-theme="oled"] .sb-vertical {
329
329
  border-right-color: var(--border-light);
330
330
  }
331
+
332
+ /* ===== REDUCED MOTION ===== */
333
+ @media (prefers-reduced-motion: reduce) {
334
+ *, *::before, *::after {
335
+ animation-duration: 0.01ms !important;
336
+ animation-iteration-count: 1 !important;
337
+ transition-duration: 0.01ms !important;
338
+ scroll-behavior: auto !important;
339
+ }
340
+ }
@@ -1,6 +1,8 @@
1
1
  import type { Annotation } from '../types'
2
2
  import { useI18n } from '../composables/useI18n'
3
3
 
4
+ const { t } = useI18n()
5
+
4
6
  const KIND_I18N_KEYS: Record<string, string> = {
5
7
  pronunciation: 'annotation.kind.pronunciation',
6
8
  semantic: 'annotation.kind.semantic',
@@ -18,9 +20,5 @@ const KIND_I18N_KEYS: Record<string, string> = {
18
20
 
19
21
  export function kindLabel(ann: Annotation): string {
20
22
  const key = KIND_I18N_KEYS[ann.kind]
21
- if (key) {
22
- const { t } = useI18n()
23
- return t(key)
24
- }
25
- return ann.kind
23
+ return key ? t(key) : ann.kind
26
24
  }
@@ -177,8 +177,7 @@ function goHome() { router.push('/') }
177
177
  display: flex; align-items: center; justify-content: center;
178
178
  font-size: 28px; font-weight: 900;
179
179
  color: var(--vermillion);
180
- margin-left: 24px; padding-left: 20px;
181
- border-left: 3px solid var(--vermillion);
180
+ margin-left: 24px;
182
181
  }
183
182
  .v-name {
184
183
  font-size: 48px; font-weight: 900;
@@ -251,7 +250,7 @@ function goHome() { router.push('/') }
251
250
  .h-page { min-height: 100vh; }
252
251
  .h-nav {
253
252
  position: sticky; top: 0; z-index: 100;
254
- background: var(--paper); opacity: 0.97;
253
+ background: var(--paper);
255
254
  backdrop-filter: blur(20px);
256
255
  border-bottom: 1px solid var(--border-light);
257
256
  padding: 0 40px;
@@ -78,7 +78,7 @@ function scrollToCatalog() {
78
78
  <span class="v-ch-line"> </span>
79
79
  <span class="v-count">{{ t('catalog.total', { count: filtered.length }) }}</span>
80
80
  <span class="v-search-wrap">
81
- <input v-model="searchQuery" class="v-search" :placeholder="t('catalog.search')" aria-label="search" />
81
+ <input v-model="searchQuery" class="v-search" :placeholder="t('catalog.search')" :aria-label="t('catalog.search')" />
82
82
  </span>
83
83
  </section>
84
84
 
@@ -92,6 +92,9 @@ function scrollToCatalog() {
92
92
  @click="openPiece(piece.num)"
93
93
  />
94
94
  </div>
95
+ <div v-if="searchQuery && filtered.length === 0" class="v-empty">
96
+ <span class="v-empty-text">{{ t('catalog.noResults', { query: searchQuery }) }}</span>
97
+ </div>
95
98
  </div>
96
99
  </div>
97
100
 
@@ -379,6 +382,23 @@ function scrollToCatalog() {
379
382
  opacity: 0.5;
380
383
  }
381
384
 
385
+ .v-empty {
386
+ writing-mode: vertical-rl;
387
+ text-orientation: mixed;
388
+ flex-shrink: 0;
389
+ height: 100vh;
390
+ display: flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ padding: 40px 20px;
394
+ }
395
+ .v-empty-text {
396
+ font-size: 14px;
397
+ color: var(--ink-faint);
398
+ letter-spacing: 2px;
399
+ font-family: var(--sans);
400
+ }
401
+
382
402
  @media (max-width: 768px) {
383
403
  .h-hero { min-height: 80vh; height: auto; padding: 60px 16px; }
384
404
  .h-ornament { font-size: 32px; letter-spacing: 12px; margin-bottom: 20px; }
@@ -312,20 +312,20 @@ function navigate(delta: number) {
312
312
  if (target !== null) router.push(`/${props.bookId}/${target}`)
313
313
  }
314
314
 
315
- const ROLE_LABELS: Record<string, string> = {
315
+ const ROLE_LABELS = computed(() => ({
316
316
  author: t('role.author'),
317
317
  commentator: t('role.commentator'),
318
318
  editor: t('role.editor'),
319
319
  translator: t('role.translator'),
320
320
  annotator: t('role.annotator'),
321
- }
321
+ }))
322
322
 
323
323
  const contributorGroups = computed(() => {
324
324
  const c = piece.value?.contributors
325
325
  if (!c || c.length <= 1) return []
326
326
  const groups = new Map<string, string[]>()
327
327
  for (const x of c) {
328
- const t = x.title || ROLE_LABELS[x.role] || t('role.defaultAuthor')
328
+ const t = x.title || ROLE_LABELS.value[x.role] || t('role.defaultAuthor')
329
329
  if (!groups.has(t)) groups.set(t, [])
330
330
  groups.get(t)!.push(x.name)
331
331
  }
@@ -516,8 +516,8 @@ function tcy(n: number): string {
516
516
  </div>
517
517
  <div class="v-pane-links">
518
518
  <a v-if="selectedAuthorData?.ctextId" :href="`https://ctext.org/wiki.pl?if=en&res=${selectedAuthorData.ctextId}`" target="_blank" rel="noopener" class="v-pane-link">CTEXT</a>
519
- <a v-if="selectedAuthorData?.wikipediaZh" :href="selectedAuthorData.wikipediaZh" target="_blank" rel="noopener" class="v-pane-link">Wikipedia</a>
520
- <a v-if="selectedAuthorData?.wikipediaEn" :href="selectedAuthorData.wikipediaEn" target="_blank" rel="noopener" class="v-pane-link">Wikipedia</a>
519
+ <a v-if="selectedAuthorData?.wikipediaZh" :href="selectedAuthorData.wikipediaZh" target="_blank" rel="noopener" class="v-pane-link">Wikipedia ZH</a>
520
+ <a v-if="selectedAuthorData?.wikipediaEn" :href="selectedAuthorData.wikipediaEn" target="_blank" rel="noopener" class="v-pane-link">Wikipedia EN</a>
521
521
  <a v-if="selectedAuthorData?.wikidata" :href="`https://www.wikidata.org/wiki/${selectedAuthorData.wikidata}`" target="_blank" rel="noopener" class="v-pane-link">Wikidata</a>
522
522
  </div>
523
523
  <div v-if="selectedAuthorBio" class="v-pane-bio">
@@ -671,8 +671,8 @@ function tcy(n: number): string {
671
671
  <span v-if="selectedAuthorWorkCount" class="h-pane-count">{{ t('piece.collected', { count: selectedAuthorWorkCount }) }}</span>
672
672
  </div>
673
673
  <div v-if="selectedAuthorData?.courtesyName || selectedAuthorData?.artName" class="h-pane-alt-names">
674
- <span v-if="selectedAuthorData?.courtesyName">字 {{ selectedAuthorData.courtesyName }}</span>
675
- <span v-if="selectedAuthorData?.artName">號 {{ selectedAuthorData.artName }}</span>
674
+ <span v-if="selectedAuthorData?.courtesyName">{{ t('author.courtesyName', { name: selectedAuthorData.courtesyName }) }}</span>
675
+ <span v-if="selectedAuthorData?.artName">{{ t('author.artName', { name: selectedAuthorData.artName }) }}</span>
676
676
  </div>
677
677
  </div>
678
678
  </div>
@@ -857,7 +857,7 @@ function tcy(n: number): string {
857
857
  .h-page { min-height: 100vh; }
858
858
  .h-nav {
859
859
  position: sticky; top: 0; z-index: 100;
860
- background: var(--paper); opacity: 0.97;
860
+ background: var(--paper);
861
861
  backdrop-filter: blur(20px);
862
862
  border-bottom: 1px solid var(--border-light);
863
863
  padding: 0 40px;
@@ -1332,7 +1332,7 @@ function tcy(n: number): string {
1332
1332
 
1333
1333
  @media (max-width: 768px) {
1334
1334
  /* ─── 直排模式 ─── */
1335
- .v-page { margin-right: var(--nav-width, 44px); }
1335
+ .v-page { margin-right: calc(var(--nav-width, 44px) + env(safe-area-inset-right, 0px)); }
1336
1336
  .v-title-col { padding: 20px 12px; }
1337
1337
  .v-poem-title { font-size: 28px; letter-spacing: 6px; padding-left: 12px; }
1338
1338
  .v-poem-author { font-size: 18px; letter-spacing: 4px; }