@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
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import type { PronSegment } from '../types'
3
+
4
+ defineProps<{
5
+ segment: PronSegment
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <span class="pron-group">
11
+ <span class="ann-pron" :class="segment.lang === 'yue' ? 'ann-yue' : 'ann-cmn'">
12
+ {{ segment.label }}
13
+ </span>
14
+ <span
15
+ v-for="(part, i) in segment.parts"
16
+ :key="i"
17
+ class="ann-phonetic"
18
+ >{{ part }}</span>
19
+ </span>
20
+ </template>
21
+
22
+ <style scoped>
23
+ .pron-group {
24
+ display: inline-flex;
25
+ align-items: baseline;
26
+ gap: 0.3em;
27
+ white-space: nowrap;
28
+ }
29
+ .ann-pron {
30
+ display: inline-block;
31
+ font-size: 0.75em;
32
+ font-family: var(--sans);
33
+ font-weight: 600;
34
+ padding: 1px 3px;
35
+ border-radius: 2px;
36
+ vertical-align: middle;
37
+ line-height: 1;
38
+ }
39
+ .ann-yue { background: var(--jade); color: #fff; }
40
+ .ann-cmn { background: var(--ink); color: var(--paper); }
41
+ .ann-phonetic {
42
+ font-family: var(--sans);
43
+ color: var(--ink-light);
44
+ }
45
+ </style>
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useReadingMode, THEMES, THEME_LABELS } from '../composables/useReadingMode'
4
+ import type { LayoutMode } from '../composables/useReadingMode'
5
+
6
+ const { theme, layout, setTheme, setLayout } = useReadingMode()
7
+ const open = ref(false)
8
+
9
+ function toggle() { open.value = !open.value }
10
+ function close() { open.value = false }
11
+ </script>
12
+
13
+ <template>
14
+ <div class="rt" :class="{ open }">
15
+ <button class="rt-fab" @click="toggle" :aria-label="open ? '關閉設定' : '閱讀設定'">
16
+ <span v-if="!open" class="rt-icon">設</span>
17
+ <span v-else class="rt-icon">✕</span>
18
+ </button>
19
+ <div v-if="open" class="rt-panel" @click.stop>
20
+ <div class="rt-group">
21
+ <div class="rt-label">版面</div>
22
+ <div class="rt-options">
23
+ <button
24
+ class="rt-opt"
25
+ :class="{ active: layout === 'horizontal' }"
26
+ @click="setLayout('horizontal' as LayoutMode)"
27
+ >橫排</button>
28
+ <button
29
+ class="rt-opt"
30
+ :class="{ active: layout === 'vertical' }"
31
+ @click="setLayout('vertical' as LayoutMode)"
32
+ >直排</button>
33
+ </div>
34
+ </div>
35
+ <div class="rt-group">
36
+ <div class="rt-label">主題</div>
37
+ <div class="rt-options">
38
+ <button
39
+ v-for="t in THEMES"
40
+ :key="t"
41
+ class="rt-opt rt-theme"
42
+ :class="{ active: theme === t, ['theme-' + t]: true }"
43
+ @click="setTheme(t)"
44
+ >{{ THEME_LABELS[t] }}</button>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ <div v-if="open" class="rt-backdrop" @click="close" />
49
+ </div>
50
+ </template>
51
+
52
+ <style scoped>
53
+ .rt {
54
+ position: fixed;
55
+ bottom: 24px;
56
+ right: 24px;
57
+ z-index: 500;
58
+ }
59
+ .rt-fab {
60
+ width: 44px; height: 44px;
61
+ border-radius: 50%;
62
+ border: 1px solid var(--border);
63
+ background: var(--surface);
64
+ color: var(--ink-mid);
65
+ font-size: 16px;
66
+ cursor: pointer;
67
+ box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.12);
68
+ transition: all 0.2s;
69
+ display: flex; align-items: center; justify-content: center;
70
+ }
71
+ .rt-fab:hover {
72
+ background: var(--ink);
73
+ color: var(--paper);
74
+ border-color: var(--ink);
75
+ }
76
+ .rt-icon {
77
+ font-family: var(--sans);
78
+ font-weight: 600;
79
+ font-size: 15px;
80
+ }
81
+ .rt-panel {
82
+ position: absolute;
83
+ bottom: 56px;
84
+ right: 0;
85
+ width: 220px;
86
+ background: var(--surface);
87
+ border: 1px solid var(--border);
88
+ border-radius: 8px;
89
+ box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.16);
90
+ padding: 16px;
91
+ animation: slideUp 0.2s ease;
92
+ }
93
+ @keyframes slideUp {
94
+ from { opacity: 0; transform: translateY(8px); }
95
+ to { opacity: 1; transform: translateY(0); }
96
+ }
97
+ .rt-group { margin-bottom: 14px; }
98
+ .rt-group:last-child { margin-bottom: 0; }
99
+ .rt-label {
100
+ font-family: var(--sans);
101
+ font-size: 11px;
102
+ font-weight: 600;
103
+ color: var(--ink-faint);
104
+ letter-spacing: 2px;
105
+ margin-bottom: 8px;
106
+ text-transform: uppercase;
107
+ }
108
+ .rt-options { display: flex; gap: 6px; }
109
+ .rt-opt {
110
+ flex: 1;
111
+ padding: 6px 10px;
112
+ border: 1px solid var(--border);
113
+ border-radius: 4px;
114
+ background: none;
115
+ font-family: var(--sans);
116
+ font-size: 13px;
117
+ color: var(--ink-mid);
118
+ cursor: pointer;
119
+ transition: all 0.15s;
120
+ }
121
+ .rt-opt:hover { border-color: var(--ink); color: var(--ink); }
122
+ .rt-opt.active {
123
+ background: var(--ink);
124
+ color: var(--paper);
125
+ border-color: var(--ink);
126
+ }
127
+ .rt-backdrop {
128
+ position: fixed; inset: 0;
129
+ z-index: -1;
130
+ }
131
+ </style>
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { parseAnnotationBlock } from '../utils/annotationParser'
4
+ import PronunciationGroup from './PronunciationGroup.vue'
5
+
6
+ const props = defineProps<{
7
+ num: string
8
+ label: string
9
+ special: boolean
10
+ text: string
11
+ isAnnotations: boolean
12
+ vertical?: boolean
13
+ }>()
14
+
15
+ function esc(str: string): string {
16
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
17
+ }
18
+
19
+ const CIRCULAR_NUMS: Record<string, string> = {
20
+ '01': '①', '02': '②', '03': '③', '04': '④', '05': '⑤',
21
+ '06': '⑥', '07': '⑦', '08': '⑧', '09': '⑨', '10': '⑩',
22
+ }
23
+
24
+ const displayNum = computed(() => {
25
+ if (props.vertical && !props.special && CIRCULAR_NUMS[props.num]) {
26
+ return CIRCULAR_NUMS[props.num]
27
+ }
28
+ return props.num
29
+ })
30
+
31
+ const entries = computed(() =>
32
+ props.isAnnotations ? parseAnnotationBlock(props.text) : []
33
+ )
34
+
35
+ const paragraphsHtml = computed(() => {
36
+ if (props.isAnnotations) return ''
37
+ const lines = props.text.split('\n').filter(l => l.trim())
38
+ return lines.length ? lines.map(p => `<p>${esc(p.trim())}</p>`).join('') : ''
39
+ })
40
+ </script>
41
+
42
+ <template>
43
+ <div v-if="text" class="sb-root" :class="{ 'sb-vertical': vertical }">
44
+ <div class="sb-header">
45
+ <span v-if="displayNum" class="sb-num" :class="{ special }">{{ displayNum }}</span>
46
+ <h3>{{ special ? '【' + label + '】' : label }}</h3>
47
+ </div>
48
+ <div v-if="isAnnotations" class="sb-text sb-ann-list">
49
+ <div v-for="entry in entries" :key="entry.num" class="sb-ann-entry">
50
+ <span class="sb-ann-num">{{ entry.numDisplay }}</span>
51
+ <span class="sb-ann-term">{{ entry.term }}</span>
52
+ <PronunciationGroup
53
+ v-for="seg in entry.pronSegments"
54
+ :key="seg.lang"
55
+ :segment="seg"
56
+ />
57
+ <span v-if="entry.definition" class="sb-ann-def">{{ entry.definition }}</span>
58
+ </div>
59
+ </div>
60
+ <div v-else class="sb-text" v-html="paragraphsHtml" />
61
+ </div>
62
+ </template>
63
+
64
+ <style scoped>
65
+ .sb-root {
66
+ margin-bottom: 40px;
67
+ animation: sb-fade-up 0.5s ease forwards;
68
+ }
69
+ @keyframes sb-fade-up {
70
+ from { opacity: 0; transform: translateY(16px); }
71
+ to { opacity: 1; transform: translateY(0); }
72
+ }
73
+ .sb-header {
74
+ display: flex; align-items: center; gap: 12px;
75
+ margin-bottom: 20px; padding-bottom: 12px;
76
+ border-bottom: 1px solid var(--border);
77
+ }
78
+ .sb-num {
79
+ display: inline-flex; align-items: center; justify-content: center;
80
+ width: 28px; height: 28px; border-radius: 50%;
81
+ background: var(--vermillion); color: #fff;
82
+ font-family: var(--sans); font-size: 13px; font-weight: 700;
83
+ flex-shrink: 0;
84
+ }
85
+ .sb-num.special { background: var(--jade); }
86
+ .sb-header h3 { font-size: 18px; font-weight: 700; letter-spacing: 3px; color: var(--ink); }
87
+ .sb-text {
88
+ font-size: var(--body-font-size, 16px); line-height: 2.2; color: var(--ink-mid);
89
+ text-align: justify;
90
+ }
91
+ .sb-text :deep(p) { margin-bottom: 16px; text-indent: 2em; }
92
+ .sb-text :deep(p:last-child) { margin-bottom: 0; }
93
+
94
+ .sb-ann-list { text-align: start; }
95
+ .sb-ann-entry { margin-bottom: 14px; }
96
+ .sb-ann-num { color: var(--vermillion); font-weight: 600; font-family: var(--sans); }
97
+ .sb-ann-term { font-weight: 600; color: var(--ink); }
98
+ .sb-ann-def { white-space: pre-line; }
99
+
100
+ /* ─── 直排模式 ─── */
101
+ .sb-vertical {
102
+ writing-mode: vertical-rl;
103
+ text-orientation: mixed;
104
+ height: 100vh;
105
+ flex-shrink: 0;
106
+ padding: 32px 20px;
107
+ border-right: 1px solid var(--border);
108
+ overflow-x: auto;
109
+ overflow-y: hidden;
110
+ animation: none;
111
+ }
112
+ .sb-vertical .sb-header {
113
+ flex-direction: column;
114
+ align-items: flex-start;
115
+ margin-bottom: 0;
116
+ margin-left: 16px;
117
+ padding-bottom: 0;
118
+ border-bottom: none;
119
+ padding-left: 16px;
120
+ border-left: 1px solid var(--border);
121
+ }
122
+ .sb-vertical .sb-num {
123
+ width: auto; height: auto;
124
+ border-radius: 0;
125
+ background: none;
126
+ color: var(--vermillion);
127
+ font-size: 18px;
128
+ }
129
+ .sb-vertical .sb-text {
130
+ margin-left: 16px;
131
+ text-align: start;
132
+ }
133
+ .sb-vertical .sb-text :deep(p) {
134
+ margin-bottom: 0;
135
+ margin-left: 12px;
136
+ text-indent: 0;
137
+ }
138
+ .sb-vertical .sb-ann-entry {
139
+ margin-bottom: 0;
140
+ margin-left: 16px;
141
+ }
142
+ </style>
@@ -0,0 +1,291 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useReadingMode, THEMES, THEME_LABELS, FONT_SIZES } from '../composables/useReadingMode'
4
+ import type { LayoutMode, FontSize } from '../composables/useReadingMode'
5
+
6
+ defineProps<{
7
+ context?: string
8
+ poemTitle?: string
9
+ poemAuthor?: string
10
+ titleCollapsed?: boolean
11
+ }>()
12
+
13
+ const emit = defineEmits<{
14
+ back: []
15
+ home: []
16
+ }>()
17
+
18
+ const { theme, layout, mainFontSize, bodyFontSize, setTheme, setLayout, setMainFontSize, setBodyFontSize } = useReadingMode()
19
+ const settingsOpen = ref(false)
20
+
21
+ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
22
+ </script>
23
+
24
+ <template>
25
+ <nav class="sidenav">
26
+ <button class="sn-brand" @click="emit('home')" title="首頁">
27
+ <span class="sn-seal">漢流</span>
28
+ </button>
29
+
30
+ <button class="sn-btn" @click="emit('back')" title="返回">
31
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
32
+ </button>
33
+
34
+ <div v-if="context && !titleCollapsed" class="sn-context">{{ context }}</div>
35
+
36
+ <Transition name="title-in">
37
+ <div v-if="titleCollapsed && poemTitle" class="sn-poem-info">
38
+ <div class="sn-poem-title">{{ poemTitle }}</div>
39
+ <div v-if="poemAuthor" class="sn-poem-author">{{ poemAuthor }}</div>
40
+ </div>
41
+ </Transition>
42
+
43
+ <div class="sn-spacer" />
44
+
45
+ <button
46
+ class="sn-btn"
47
+ :class="{ active: settingsOpen }"
48
+ @click="toggleSettings"
49
+ title="設定"
50
+ >
51
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
52
+ </button>
53
+
54
+ <div v-if="layout === 'vertical'" class="sn-layout-tag">直</div>
55
+
56
+ <Transition name="slide-left">
57
+ <div v-if="settingsOpen" class="sn-settings" @click.stop>
58
+ <div class="ss-group">
59
+ <div class="ss-label">版面</div>
60
+ <div class="ss-options">
61
+ <button class="ss-opt" :class="{ active: layout === 'horizontal' }" @click="setLayout('horizontal' as LayoutMode)">橫排</button>
62
+ <button class="ss-opt" :class="{ active: layout === 'vertical' }" @click="setLayout('vertical' as LayoutMode)">直排</button>
63
+ </div>
64
+ </div>
65
+ <div class="ss-group">
66
+ <div class="ss-label">主題</div>
67
+ <div class="ss-options">
68
+ <button
69
+ v-for="t in THEMES"
70
+ :key="t"
71
+ class="ss-opt"
72
+ :class="{ active: theme === t }"
73
+ @click="setTheme(t)"
74
+ >{{ THEME_LABELS[t] }}</button>
75
+ </div>
76
+ </div>
77
+ <div class="ss-group">
78
+ <div class="ss-label">正文字號</div>
79
+ <div class="ss-size-row">
80
+ <button class="ss-size-btn" @click="setMainFontSize(FONT_SIZES[Math.max(0, FONT_SIZES.indexOf(mainFontSize) - 1)] as FontSize)">−</button>
81
+ <span class="ss-size-val">{{ mainFontSize }}</span>
82
+ <button class="ss-size-btn" @click="setMainFontSize(FONT_SIZES[Math.min(FONT_SIZES.length - 1, FONT_SIZES.indexOf(mainFontSize) + 1)] as FontSize)">+</button>
83
+ </div>
84
+ </div>
85
+ <div class="ss-group">
86
+ <div class="ss-label">內文字號</div>
87
+ <div class="ss-size-row">
88
+ <button class="ss-size-btn" @click="setBodyFontSize(FONT_SIZES[Math.max(0, FONT_SIZES.indexOf(bodyFontSize) - 1)] as FontSize)">−</button>
89
+ <span class="ss-size-val">{{ bodyFontSize }}</span>
90
+ <button class="ss-size-btn" @click="setBodyFontSize(FONT_SIZES[Math.min(FONT_SIZES.length - 1, FONT_SIZES.indexOf(bodyFontSize) + 1)] as FontSize)">+</button>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </Transition>
95
+
96
+ <div v-if="settingsOpen" class="sn-overlay" @click="settingsOpen = false" />
97
+ </nav>
98
+ </template>
99
+
100
+ <style scoped>
101
+ .sidenav {
102
+ position: fixed;
103
+ top: 0; right: 0;
104
+ width: 56px; height: 100vh;
105
+ background: var(--paper);
106
+ border-left: 1px solid var(--border);
107
+ display: flex; flex-direction: column;
108
+ align-items: center;
109
+ padding: 12px 0;
110
+ z-index: 200;
111
+ gap: 8px;
112
+ }
113
+
114
+ .sn-brand {
115
+ width: 40px; height: 48px;
116
+ border: 2px solid var(--vermillion);
117
+ border-radius: 3px;
118
+ background: none;
119
+ display: flex; align-items: center; justify-content: center;
120
+ cursor: pointer;
121
+ transition: all 0.2s;
122
+ margin-bottom: 4px;
123
+ }
124
+ .sn-brand:hover { background: var(--vermillion); }
125
+ .sn-brand:hover .sn-seal { color: var(--paper); }
126
+ .sn-seal {
127
+ writing-mode: vertical-rl;
128
+ text-orientation: upright;
129
+ font-family: var(--serif);
130
+ font-size: 14px; font-weight: 900;
131
+ color: var(--vermillion);
132
+ transition: color 0.2s;
133
+ display: flex;
134
+ align-items: center;
135
+ letter-spacing: 2px;
136
+ line-height: 1;
137
+ }
138
+
139
+ .sn-btn {
140
+ width: 36px; height: 36px;
141
+ border: 1px solid var(--border);
142
+ border-radius: 4px;
143
+ background: none;
144
+ color: var(--ink-light);
145
+ display: flex; align-items: center; justify-content: center;
146
+ cursor: pointer;
147
+ transition: all 0.15s;
148
+ }
149
+ .sn-btn:hover { border-color: var(--ink); color: var(--ink); }
150
+ .sn-btn.active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
151
+
152
+ .sn-context {
153
+ writing-mode: vertical-rl;
154
+ font-size: 11px;
155
+ color: var(--ink-faint);
156
+ letter-spacing: 2px;
157
+ max-height: 120px;
158
+ overflow: hidden;
159
+ font-family: var(--sans);
160
+ text-align: center;
161
+ transition: opacity 0.3s ease, max-height 0.3s ease;
162
+ }
163
+
164
+ .sn-poem-info {
165
+ writing-mode: vertical-rl;
166
+ text-orientation: mixed;
167
+ display: flex;
168
+ flex-direction: column;
169
+ align-items: flex-start;
170
+ gap: 4px;
171
+ }
172
+ .sn-poem-title {
173
+ font-size: 18px;
174
+ font-weight: 900;
175
+ letter-spacing: 4px;
176
+ color: var(--ink);
177
+ padding-left: 8px;
178
+ border-left: 2px solid var(--vermillion);
179
+ line-height: 1.6;
180
+ }
181
+ .sn-poem-author {
182
+ font-size: 12px;
183
+ font-weight: 400;
184
+ color: var(--ink-light);
185
+ letter-spacing: 2px;
186
+ margin-left: 4px;
187
+ }
188
+
189
+ .title-in-enter-active, .title-in-leave-active {
190
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
191
+ }
192
+ .title-in-enter-from, .title-in-leave-to {
193
+ opacity: 0;
194
+ transform: translateX(20px);
195
+ }
196
+
197
+ .sn-spacer { flex: 1; }
198
+
199
+ .sn-layout-tag {
200
+ width: 24px; height: 24px;
201
+ border-radius: 50%;
202
+ background: var(--ink);
203
+ color: var(--paper);
204
+ font-size: 11px; font-weight: 700;
205
+ font-family: var(--sans);
206
+ display: flex; align-items: center; justify-content: center;
207
+ }
208
+
209
+ .sn-settings {
210
+ position: absolute;
211
+ top: 50%;
212
+ right: 64px;
213
+ transform: translateY(-50%);
214
+ width: 200px;
215
+ background: var(--surface);
216
+ border: 1px solid var(--border);
217
+ border-radius: 8px;
218
+ box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.16);
219
+ padding: 16px;
220
+ z-index: 210;
221
+ }
222
+
223
+ .slide-left-enter-active, .slide-left-leave-active {
224
+ transition: all 0.2s ease;
225
+ }
226
+ .slide-left-enter-from, .slide-left-leave-to {
227
+ opacity: 0;
228
+ transform: translateY(-50%) translateX(12px);
229
+ }
230
+
231
+ .ss-group { margin-bottom: 14px; }
232
+ .ss-group:last-child { margin-bottom: 0; }
233
+ .ss-label {
234
+ font-family: var(--sans);
235
+ font-size: 11px; font-weight: 600;
236
+ color: var(--ink-faint);
237
+ letter-spacing: 2px;
238
+ margin-bottom: 8px;
239
+ }
240
+ .ss-options { display: flex; gap: 6px; }
241
+ .ss-opt {
242
+ flex: 1;
243
+ padding: 6px 8px;
244
+ border: 1px solid var(--border);
245
+ border-radius: 4px;
246
+ background: none;
247
+ font-family: var(--sans);
248
+ font-size: 12px;
249
+ color: var(--ink-mid);
250
+ cursor: pointer;
251
+ transition: all 0.15s;
252
+ }
253
+ .ss-opt:hover { border-color: var(--ink); color: var(--ink); }
254
+ .ss-opt.active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
255
+ .ss-size-row {
256
+ display: flex; align-items: center; gap: 6px; justify-content: center;
257
+ }
258
+ .ss-size-btn {
259
+ width: 28px; height: 28px;
260
+ border: 1px solid var(--border);
261
+ border-radius: 4px;
262
+ background: none;
263
+ font-family: var(--sans);
264
+ font-size: 14px;
265
+ color: var(--ink-mid);
266
+ cursor: pointer;
267
+ display: flex; align-items: center; justify-content: center;
268
+ transition: all 0.15s;
269
+ }
270
+ .ss-size-btn:hover { border-color: var(--ink); color: var(--ink); }
271
+ .ss-size-val {
272
+ font-family: var(--sans);
273
+ font-size: 13px;
274
+ color: var(--ink);
275
+ min-width: 32px;
276
+ text-align: center;
277
+ }
278
+
279
+ .sn-overlay {
280
+ position: fixed; inset: 0;
281
+ z-index: -1;
282
+ }
283
+
284
+ @media (max-width: 768px) {
285
+ .sidenav { width: 44px; padding: 8px 0; gap: 6px; }
286
+ .sn-brand { width: 32px; height: 32px; }
287
+ .sn-seal { font-size: 15px; }
288
+ .sn-btn { width: 30px; height: 30px; }
289
+ .sn-context { font-size: 10px; max-height: 80px; }
290
+ }
291
+ </style>