@hanology/cham-browser 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli.d.ts +2 -0
  2. package/dist/cli.js +191 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/pipeline.d.ts +14 -0
  8. package/dist/pipeline.js +377 -0
  9. package/dist/pipeline.js.map +1 -0
  10. package/package.json +22 -3
  11. package/template/index.html +29 -0
  12. package/template/src/App.vue +29 -0
  13. package/template/src/components/AnnotationLayerSelector.vue +66 -0
  14. package/template/src/components/AnnotationTooltip.vue +189 -0
  15. package/template/src/components/BookCard.vue +85 -0
  16. package/template/src/components/HorizontalDisplay.vue +100 -0
  17. package/template/src/components/PoemCard.vue +131 -0
  18. package/template/src/components/PronunciationGroup.vue +45 -0
  19. package/template/src/components/ReadingToolbar.vue +131 -0
  20. package/template/src/components/SectionBlock.vue +142 -0
  21. package/template/src/components/SideNav.vue +291 -0
  22. package/template/src/components/VerticalScroll.vue +120 -0
  23. package/template/src/composables/useAnnotationRenderer.ts +158 -0
  24. package/template/src/composables/useBook.ts +93 -0
  25. package/template/src/composables/useData.ts +41 -0
  26. package/template/src/composables/useHorizontalScroll.ts +60 -0
  27. package/template/src/composables/useLibrary.ts +40 -0
  28. package/template/src/composables/usePageLayout.ts +25 -0
  29. package/template/src/composables/useReadingMode.ts +70 -0
  30. package/template/src/composables/useTitle.ts +5 -0
  31. package/template/src/main.ts +22 -0
  32. package/template/src/router.ts +29 -0
  33. package/template/src/shims-vue.d.ts +7 -0
  34. package/template/src/styles/main.css +136 -0
  35. package/template/src/types.ts +164 -0
  36. package/template/src/utils/annotationParser.ts +58 -0
  37. package/template/src/utils/chineseNumber.ts +41 -0
  38. package/template/src/views/AuthorView.vue +338 -0
  39. package/template/src/views/BookHome.vue +375 -0
  40. package/template/src/views/LibraryHome.vue +419 -0
  41. package/template/src/views/PieceView.vue +793 -0
  42. package/src/index.ts +0 -20
  43. package/tsconfig.json +0 -16
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanology/cham-browser",
3
- "version": "0.1.0",
4
- "description": "CHAM — browser-compatible parser and serializer for Classical Han Annotated Markdown",
3
+ "version": "0.2.0",
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",
7
7
  "types": "dist/index.d.ts",
@@ -11,16 +11,35 @@
11
11
  "types": "./dist/index.d.ts"
12
12
  }
13
13
  },
14
+ "files": [
15
+ "dist",
16
+ "template"
17
+ ],
14
18
  "scripts": {
15
19
  "build": "tsc"
16
20
  },
21
+ "bin": {
22
+ "cham-browser": "./dist/cli.js"
23
+ },
17
24
  "dependencies": {
18
- "@hanology/cham": "*"
25
+ "@hanology/cham": "*",
26
+ "@unhead/vue": "^2.1.13",
27
+ "@vitejs/plugin-vue": "^6.0.6",
28
+ "vite": "^8.0.10",
29
+ "vite-ssg": "^28.3.0",
30
+ "vue": "^3.5.34",
31
+ "vue-router": "^4.6.4",
32
+ "yaml": "^2.7.0"
19
33
  },
20
34
  "devDependencies": {
21
35
  "typescript": "^6.0.0"
22
36
  },
23
37
  "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/hanologyorg/cham-js",
41
+ "directory": "packages/cham-browser"
42
+ },
24
43
  "publishConfig": {
25
44
  "access": "public"
26
45
  }
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-Hant">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>古典詩文圖書館</title>
7
+ <meta name="description" content="教育局古典詩文誦讀材料數位圖書館,收錄多篇經典詩文。" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Serif+TC:wght@200;300;400;500;600;700;900&family=Noto+Sans+TC:wght@300;400;500;700&display=swap" rel="stylesheet" />
11
+ <script>
12
+ (function(){
13
+ var t=localStorage.getItem('theme');if(t)document.documentElement.setAttribute('data-theme',t);
14
+ var l=localStorage.getItem('layout');if(l)document.documentElement.setAttribute('data-layout',l);
15
+ })();
16
+ </script>
17
+ <style>
18
+ #app-loading{position:fixed;inset:0;background:var(--paper,#faf6ee);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999;font-family:'Noto Serif TC',serif}
19
+ #app-loading .char{font-size:64px;font-weight:900;color:var(--ink,#1a1a1a);animation:pulse 1.2s ease-in-out infinite}
20
+ #app-loading .text{font-size:13px;color:var(--ink-faint,#a89b8a);letter-spacing:4px;margin-top:16px;font-family:'Noto Sans TC',sans-serif}
21
+ @keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <div id="app-loading"><div class="char">詩</div><div class="text">載入中</div></div>
26
+ <div id="app"></div>
27
+ <script type="module" src="/src/main.ts"></script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import { useRouter } from 'vue-router'
3
+ import { useReadingMode } from './composables/useReadingMode'
4
+ import ReadingToolbar from './components/ReadingToolbar.vue'
5
+ import { computed } from 'vue'
6
+
7
+ const router = useRouter()
8
+ const { toggleLayout, cycleTheme, layout } = useReadingMode()
9
+ const isVertical = computed(() => layout.value === 'vertical')
10
+
11
+ function onKey(event: KeyboardEvent) {
12
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return
13
+ if (event.key === 'Escape') router.push('/')
14
+ if (event.key === 'v' || event.key === 'V') toggleLayout()
15
+ if (event.key === 't' || event.key === 'T') cycleTheme()
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <div @keydown="onKey">
21
+ <router-view v-slot="{ Component }">
22
+ <Suspense>
23
+ <component :is="Component" :key="$route.fullPath" />
24
+ </Suspense>
25
+ </router-view>
26
+ <!-- 橫排模式才顯示浮動設定鈕 -->
27
+ <ReadingToolbar v-if="!isVertical" />
28
+ </div>
29
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import type { AnnotationLayer } from '../types'
3
+
4
+ const props = defineProps<{
5
+ layers: AnnotationLayer[]
6
+ activeIds: string[]
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ 'update:activeIds': [ids: string[]]
11
+ }>()
12
+
13
+ function toggle(id: string) {
14
+ const current = props.activeIds
15
+ if (current.includes(id)) {
16
+ emit('update:activeIds', current.filter(x => x !== id))
17
+ } else {
18
+ emit('update:activeIds', [...current, id])
19
+ }
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <div v-if="layers.length > 1" class="layer-selector">
25
+ <button
26
+ v-for="layer in layers"
27
+ :key="layer.id"
28
+ :class="['layer-btn', { active: activeIds.includes(layer.id) }]"
29
+ :title="layer.label"
30
+ @click="toggle(layer.id)"
31
+ >
32
+ {{ layer.shortLabel }}
33
+ </button>
34
+ </div>
35
+ </template>
36
+
37
+ <style scoped>
38
+ .layer-selector {
39
+ display: flex;
40
+ gap: 6px;
41
+ flex-wrap: wrap;
42
+ }
43
+
44
+ .layer-btn {
45
+ border: 1px solid var(--border);
46
+ border-radius: 4px;
47
+ padding: 4px 12px;
48
+ font-size: 13px;
49
+ background: var(--surface);
50
+ color: var(--ink-mid);
51
+ cursor: pointer;
52
+ transition: all 0.15s;
53
+ font-family: var(--sans);
54
+ letter-spacing: 1px;
55
+ }
56
+
57
+ .layer-btn:hover {
58
+ border-color: var(--gold);
59
+ }
60
+
61
+ .layer-btn.active {
62
+ background: var(--ink);
63
+ color: var(--paper);
64
+ border-color: var(--ink);
65
+ }
66
+ </style>
@@ -0,0 +1,189 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useReadingMode } from '../composables/useReadingMode'
4
+ import { annotationToPronSegment } from '../utils/annotationParser'
5
+ import PronunciationGroup from './PronunciationGroup.vue'
6
+ import type { Annotation } from '../types'
7
+
8
+ const props = defineProps<{
9
+ visible: boolean
10
+ annotations: Annotation[]
11
+ layerLabels?: Record<string, string>
12
+ style?: Record<string, string>
13
+ }>()
14
+
15
+ const emit = defineEmits<{ close: [] }>()
16
+ const { layout } = useReadingMode()
17
+ const isMobile = computed(() => window.innerWidth < 768)
18
+
19
+ function getSegment(ann: Annotation) {
20
+ return annotationToPronSegment(ann)
21
+ }
22
+
23
+ function layerLabel(ann: Annotation): string {
24
+ if (!props.layerLabels || !ann.id) return ''
25
+ for (const [prefix, label] of Object.entries(props.layerLabels)) {
26
+ if (ann.id.startsWith(prefix)) return label
27
+ }
28
+ return ''
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <Teleport to="body">
34
+ <Transition name="ann-fade">
35
+ <div v-if="visible && annotations.length" class="ann-backdrop" @click="emit('close')">
36
+ <div
37
+ class="ann-tooltip"
38
+ :class="{ 'ann-vertical': layout === 'vertical', 'ann-mobile-bottom': isMobile }"
39
+ :style="style"
40
+ @click.stop
41
+ >
42
+ <button v-if="isMobile" class="ann-handle" @click="emit('close')">
43
+ <span class="ann-handle-bar" />
44
+ </button>
45
+ <div
46
+ v-for="ann in annotations"
47
+ :key="ann.id"
48
+ class="ann-entry"
49
+ :class="ann.kind"
50
+ >
51
+ <div class="ann-header">
52
+ <span class="ann-kind">{{ ann.kind === 'pronunciation' ? '音' : '義' }}</span>
53
+ <span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
54
+ </div>
55
+ <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
56
+ <div v-else class="ann-body">{{ ann.text }}</div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </Transition>
61
+ </Teleport>
62
+ </template>
63
+
64
+ <style scoped>
65
+ .ann-backdrop {
66
+ position: fixed;
67
+ inset: 0;
68
+ z-index: 999;
69
+ }
70
+
71
+ .ann-tooltip {
72
+ position: fixed;
73
+ padding: 12px 16px;
74
+ background: var(--surface-warm);
75
+ border: 1px solid var(--border);
76
+ border-radius: 8px;
77
+ box-shadow: 0 16px 48px rgba(var(--shadow-rgb), 0.14);
78
+ max-width: 320px;
79
+ max-height: 60vh;
80
+ overflow-y: auto;
81
+ z-index: 1000;
82
+ }
83
+
84
+ /* ─── Mobile bottom sheet ─── */
85
+ .ann-mobile-bottom {
86
+ position: fixed;
87
+ left: 0;
88
+ right: 0;
89
+ bottom: 0;
90
+ max-width: none;
91
+ max-height: 50vh;
92
+ border-radius: 16px 16px 0 0;
93
+ padding: 8px 20px 20px;
94
+ overscroll-behavior: contain;
95
+ }
96
+
97
+ .ann-handle {
98
+ display: flex;
99
+ justify-content: center;
100
+ padding: 8px 0 4px;
101
+ width: 100%;
102
+ border: none;
103
+ background: none;
104
+ cursor: pointer;
105
+ }
106
+ .ann-handle-bar {
107
+ display: block;
108
+ width: 36px;
109
+ height: 4px;
110
+ border-radius: 2px;
111
+ background: var(--border);
112
+ }
113
+
114
+ .ann-entry {
115
+ margin-bottom: 10px;
116
+ letter-spacing: 1px;
117
+ font-size: 15px;
118
+ color: var(--ink-mid);
119
+ }
120
+ .ann-entry:last-child { margin-bottom: 0; }
121
+
122
+ .ann-kind {
123
+ display: inline-block;
124
+ font-size: 10px;
125
+ font-family: var(--sans);
126
+ padding: 1px 3px;
127
+ border-radius: 2px;
128
+ font-weight: 600;
129
+ letter-spacing: 1px;
130
+ margin-right: 3px;
131
+ line-height: 1;
132
+ vertical-align: middle;
133
+ }
134
+ .ann-header {
135
+ display: inline-flex;
136
+ align-items: center;
137
+ gap: 6px;
138
+ margin-bottom: 2px;
139
+ }
140
+ .ann-layer {
141
+ font-size: 11px;
142
+ font-family: var(--sans);
143
+ color: var(--ink-faint);
144
+ letter-spacing: 1px;
145
+ }
146
+ .pronunciation .ann-kind {
147
+ background: var(--jade);
148
+ color: #fff;
149
+ }
150
+ .semantic .ann-kind {
151
+ background: var(--vermillion);
152
+ color: #fff;
153
+ }
154
+
155
+ .ann-body {
156
+ margin-top: 4px;
157
+ line-height: 1.8;
158
+ }
159
+
160
+ /* ─── 直排模式 tooltip ─── */
161
+ .ann-vertical {
162
+ writing-mode: vertical-rl;
163
+ text-orientation: mixed;
164
+ max-width: none;
165
+ max-height: 60vh;
166
+ padding: 16px 12px;
167
+ }
168
+ .ann-vertical .ann-entry {
169
+ margin-bottom: 0;
170
+ margin-left: 12px;
171
+ }
172
+ .ann-vertical .ann-kind {
173
+ margin-right: 0;
174
+ text-combine-upright: all;
175
+ vertical-align: baseline;
176
+ }
177
+ .ann-vertical .ann-body {
178
+ margin-top: 0;
179
+ margin-left: 6px;
180
+ }
181
+
182
+ /* ─── Transition ─── */
183
+ .ann-fade-enter-active, .ann-fade-leave-active {
184
+ transition: opacity 0.15s ease;
185
+ }
186
+ .ann-fade-enter-from, .ann-fade-leave-to {
187
+ opacity: 0;
188
+ }
189
+ </style>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ import type { BookMeta } from '../types'
3
+ import { useRouter } from 'vue-router'
4
+
5
+ defineProps<{ book: BookMeta }>()
6
+ const router = useRouter()
7
+
8
+ const genreLabel: Record<string, string> = {
9
+ poetry: '詩歌',
10
+ prose: '散文',
11
+ mixed: '綜合',
12
+ drama: '戲曲',
13
+ }
14
+ </script>
15
+
16
+ <template>
17
+ <div class="bc-root" @click="router.push(`/${book.id}`)">
18
+ <div class="bc-accent"></div>
19
+ <div class="bc-body">
20
+ <h2 class="bc-title">{{ book.title }}</h2>
21
+ <p v-if="book.subtitle" class="bc-subtitle">{{ book.subtitle }}</p>
22
+ <div class="bc-stats">
23
+ <span class="bc-count">{{ book.count }} 篇</span>
24
+ <span class="bc-genre">{{ genreLabel[book.genre] || book.genre }}</span>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <style scoped>
31
+ .bc-root {
32
+ position: relative;
33
+ background: var(--surface);
34
+ border: 1px solid var(--border-light);
35
+ border-radius: 8px;
36
+ cursor: pointer;
37
+ transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
38
+ overflow: hidden;
39
+ }
40
+ .bc-accent {
41
+ position: absolute;
42
+ top: 0; left: 0;
43
+ width: 3px; height: 0;
44
+ background: var(--vermillion);
45
+ transition: height 0.35s ease;
46
+ }
47
+ .bc-root:hover {
48
+ transform: translateY(-4px);
49
+ box-shadow: 0 16px 48px rgba(var(--shadow-rgb), 0.1);
50
+ border-color: var(--gold-light);
51
+ }
52
+ .bc-root:hover .bc-accent { height: 100%; }
53
+ .bc-body { padding: 28px 24px; }
54
+ .bc-title {
55
+ font-size: 22px;
56
+ font-weight: 700;
57
+ letter-spacing: 3px;
58
+ color: var(--ink);
59
+ margin-bottom: 6px;
60
+ }
61
+ .bc-subtitle {
62
+ font-size: 13px;
63
+ font-family: var(--sans);
64
+ color: var(--ink-light);
65
+ letter-spacing: 1px;
66
+ margin-bottom: 16px;
67
+ }
68
+ .bc-stats {
69
+ display: flex;
70
+ gap: 16px;
71
+ font-size: 12px;
72
+ font-family: var(--sans);
73
+ color: var(--ink-faint);
74
+ }
75
+ .bc-count {
76
+ padding: 2px 8px;
77
+ background: var(--surface-warm);
78
+ border-radius: 4px;
79
+ }
80
+ .bc-genre {
81
+ padding: 2px 8px;
82
+ border: 1px solid var(--border-light);
83
+ border-radius: 4px;
84
+ }
85
+ </style>
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ import type { Annotation, VerseLine } from '../types'
3
+ import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations, countVerseSpans } from '../composables/useAnnotationRenderer'
4
+
5
+ const props = defineProps<{
6
+ title: string
7
+ author: string
8
+ verses: VerseLine[]
9
+ annotations: Annotation[]
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ annotationHover: [event: MouseEvent, annotations: Annotation[]]
14
+ annotationLeave: []
15
+ annotationTap: [event: MouseEvent, annotations: Annotation[]]
16
+ }>()
17
+
18
+ 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)
23
+ }
24
+
25
+ function onHover(event: MouseEvent) {
26
+ const matched = resolveHoveredAnnotations(event, props.annotations)
27
+ if (matched) emit('annotationHover', event, matched)
28
+ }
29
+
30
+ function onLeave() {
31
+ emit('annotationLeave')
32
+ }
33
+
34
+ function onTap(event: MouseEvent) {
35
+ const matched = resolveHoveredAnnotations(event, props.annotations)
36
+ if (matched) emit('annotationTap', event, matched)
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <div class="h-display" @mouseover="onHover" @mouseleave="onLeave" @click="onTap">
42
+ <div class="h-display-title">{{ title }}</div>
43
+ <div class="h-display-author">{{ author }}</div>
44
+ <div
45
+ v-for="(_, i) in verses"
46
+ :key="i"
47
+ class="h-display-line"
48
+ v-html="verseHtml(i)"
49
+ />
50
+ </div>
51
+ </template>
52
+
53
+ <style scoped>
54
+ .h-display {
55
+ display: inline-block;
56
+ background: var(--surface);
57
+ border: 1px solid var(--border);
58
+ border-radius: 8px;
59
+ padding: 40px 56px;
60
+ box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
61
+ text-align: center;
62
+ }
63
+ .h-display-title {
64
+ font-size: 32px; font-weight: 900;
65
+ color: var(--ink); letter-spacing: 6px;
66
+ margin-bottom: 6px;
67
+ }
68
+ .h-display-author {
69
+ font-size: 16px; color: var(--ink-light);
70
+ margin-bottom: 24px; letter-spacing: 3px;
71
+ }
72
+ .h-display-line {
73
+ font-size: var(--main-font-size, 24px); line-height: 2.6;
74
+ letter-spacing: 4px; color: var(--ink);
75
+ }
76
+
77
+ :deep(.ann-target) {
78
+ border-bottom: 2px solid var(--vermillion);
79
+ cursor: help;
80
+ transition: background 0.15s;
81
+ }
82
+ :deep(.ann-target:hover) {
83
+ background: rgba(194, 58, 43, 0.08);
84
+ }
85
+ :deep(.ann-num) {
86
+ font-size: 10px;
87
+ color: var(--vermillion);
88
+ font-family: var(--sans);
89
+ font-weight: 600;
90
+ vertical-align: super;
91
+ margin-right: 1px;
92
+ letter-spacing: 0;
93
+ }
94
+ :deep(.ann-target.pronunciation:hover) {
95
+ background: rgba(58, 107, 94, 0.08);
96
+ }
97
+ :deep(.ann-target.pronunciation) {
98
+ border-bottom-color: var(--jade);
99
+ }
100
+ </style>
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { Poem } from '../types'
4
+
5
+ const props = defineProps<{
6
+ poem: Poem
7
+ vertical?: boolean
8
+ }>()
9
+ defineEmits<{ click: [] }>()
10
+
11
+ const preview = computed(() => {
12
+ const max = props.vertical ? 2 : 2
13
+ return props.poem.verses.slice(0, max).map(v => v.text).join('\n')
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <div class="pc-root" :class="{ 'pc-vertical': vertical }" @click="$emit('click')">
19
+ <div class="pc-accent"></div>
20
+ <div class="pc-body">
21
+ <div class="pc-num">{{ String(poem.num).padStart(3, '0') }}</div>
22
+ <h3 class="pc-title">{{ poem.title }}</h3>
23
+ <div class="pc-author">{{ poem.author }}</div>
24
+ <p class="pc-preview" style="white-space: pre-line">{{ preview }}</p>
25
+ </div>
26
+ </div>
27
+ </template>
28
+
29
+ <style scoped>
30
+ .pc-root {
31
+ position: relative;
32
+ background: var(--surface);
33
+ border: 1px solid var(--border-light);
34
+ border-radius: 8px;
35
+ cursor: pointer;
36
+ transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
37
+ overflow: hidden;
38
+ }
39
+ .pc-accent {
40
+ position: absolute;
41
+ top: 0; left: 0;
42
+ width: 3px; height: 0;
43
+ background: var(--vermillion);
44
+ transition: height 0.35s ease;
45
+ }
46
+ .pc-root:hover {
47
+ transform: translateY(-4px);
48
+ box-shadow: 0 16px 48px rgba(var(--shadow-rgb), 0.1);
49
+ border-color: var(--gold-light);
50
+ }
51
+ .pc-root:hover .pc-accent { height: 100%; }
52
+ .pc-body { padding: 24px; }
53
+ .pc-num {
54
+ font-size: 11px; color: var(--ink-faint);
55
+ font-family: var(--sans); letter-spacing: 2px;
56
+ margin-bottom: 10px;
57
+ }
58
+ .pc-title {
59
+ font-size: 20px; font-weight: 700;
60
+ letter-spacing: 2px; margin-bottom: 6px;
61
+ color: var(--ink);
62
+ }
63
+ .pc-author {
64
+ font-size: 13px; color: var(--ink-light);
65
+ font-family: var(--sans); letter-spacing: 1px;
66
+ }
67
+ .pc-preview {
68
+ font-size: 13px; color: var(--ink-faint);
69
+ margin-top: 14px; line-height: 1.7;
70
+ overflow: hidden;
71
+ }
72
+
73
+ /* ─── 直排卡片:固定寬度,最小高度 ─── */
74
+ .pc-vertical {
75
+ writing-mode: vertical-rl;
76
+ text-orientation: mixed;
77
+ direction: ltr;
78
+ width: 180px;
79
+ min-height: 240px;
80
+ flex-shrink: 0;
81
+ align-self: start;
82
+ justify-self: start;
83
+ }
84
+ .pc-vertical .pc-body {
85
+ padding: 16px 20px 24px;
86
+ box-sizing: border-box;
87
+ overflow: hidden;
88
+ height: auto;
89
+ -webkit-mask-image: linear-gradient(to left, black 60%, transparent);
90
+ mask-image: linear-gradient(to left, black 60%, transparent);
91
+ }
92
+ .pc-vertical .pc-num {
93
+ font-size: 11px;
94
+ margin-bottom: 0;
95
+ margin-left: 6px;
96
+ display: block;
97
+ text-combine-upright: all;
98
+ }
99
+ .pc-vertical .pc-title {
100
+ font-size: 22px;
101
+ letter-spacing: 4px;
102
+ margin-bottom: 0;
103
+ margin-left: 6px;
104
+ display: block;
105
+ }
106
+ .pc-vertical .pc-author {
107
+ font-size: 13px;
108
+ letter-spacing: 2px;
109
+ margin-top: 0;
110
+ margin-left: 4px;
111
+ display: block;
112
+ }
113
+ .pc-vertical .pc-preview {
114
+ margin-top: 0;
115
+ margin-left: 6px;
116
+ font-size: 12px;
117
+ letter-spacing: 1px;
118
+ line-height: 2;
119
+ display: block;
120
+ overflow: hidden;
121
+ }
122
+ .pc-vertical .pc-accent {
123
+ top: auto; left: 0; bottom: 0;
124
+ width: 0; height: 3px;
125
+ transition: width 0.35s ease;
126
+ }
127
+ .pc-vertical:hover {
128
+ transform: translateX(-4px);
129
+ }
130
+ .pc-vertical:hover .pc-accent { width: 100%; height: 3px; }
131
+ </style>