popilot 0.7.0 → 0.8.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 (56) hide show
  1. package/package.json +1 -1
  2. package/scaffold/mcp-notification-server/package.json +18 -0
  3. package/scaffold/mcp-notification-server/src/index.ts +275 -0
  4. package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
  5. package/scaffold/mcp-notification-server/tsconfig.json +14 -0
  6. package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
  7. package/scaffold/pm-api/sql/002-notifications.sql +18 -0
  8. package/scaffold/pm-api/sql/003-content.sql +66 -0
  9. package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
  10. package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
  11. package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
  12. package/scaffold/spec-site/package-lock.json +852 -0
  13. package/scaffold/spec-site/package.json +12 -1
  14. package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
  15. package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
  16. package/scaffold/spec-site/src/components/DocComments.vue +137 -0
  17. package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
  18. package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
  19. package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
  20. package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
  21. package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
  22. package/scaffold/spec-site/src/components/Icon.vue +58 -0
  23. package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
  24. package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
  25. package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
  26. package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
  27. package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
  28. package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
  29. package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
  30. package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
  31. package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
  32. package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
  33. package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
  34. package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
  35. package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
  36. package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
  37. package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
  38. package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
  39. package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
  40. package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
  41. package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
  42. package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
  43. package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
  44. package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
  45. package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
  46. package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
  47. package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
  48. package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
  49. package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
  50. package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
  51. package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
  52. package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
  53. package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
  54. package/scaffold/spec-site/src/styles/buttons.css +124 -0
  55. package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
  56. package/scaffold/spec-site/src/utils/timezone.ts +18 -0
@@ -0,0 +1,857 @@
1
+ <script setup lang="ts">
2
+ import Icon from '@/components/Icon.vue'
3
+ import { ref, computed, onMounted, watch, nextTick } from 'vue'
4
+ import { useRoute, useRouter } from 'vue-router'
5
+ import { apiGet, apiPost, apiPatch } from '@/composables/useTurso'
6
+ import MentionInput from '@/components/MentionInput.vue'
7
+ import { renderMarkdown } from '@/utils/markdown'
8
+ import MemoRelations from '@/components/MemoRelations.vue'
9
+ import MemoTimeline from '@/components/MemoTimeline.vue'
10
+ import MemoChecklist from '@/components/MemoChecklist.vue'
11
+ import MemoGraph from '@/components/MemoGraph.vue'
12
+ import { useAuth } from '@/composables/useAuth'
13
+
14
+ const { authUser } = useAuth()
15
+
16
+ interface Memo {
17
+ id: number
18
+ page_id: string
19
+ content: string
20
+ status: string
21
+ created_by: string
22
+ assigned_to: string | null
23
+ created_at: string
24
+ }
25
+
26
+ interface Reply {
27
+ id: number
28
+ memo_id: number
29
+ content: string
30
+ created_by: string
31
+ review_type: string
32
+ created_at: string
33
+ }
34
+
35
+ const route = useRoute()
36
+ const router = useRouter()
37
+ const memos = ref<Memo[]>([])
38
+ const replies = ref<Record<number, Reply[]>>({})
39
+ const loading = ref(true)
40
+ const selectedId = computed(() => route.params.id ? Number(route.params.id) : null)
41
+
42
+ // Search / Filter
43
+ const searchKeyword = ref('')
44
+ const filterStatus = ref('')
45
+ const filterAuthor = ref('')
46
+ const memberList = ref<string[]>([])
47
+ const showNewMemo = ref(false)
48
+ const newMemoContent = ref('')
49
+ const newMemoChannel = ref('general')
50
+ const memoTemplates = ref<any[]>([])
51
+ const showTemplateModal = ref(false)
52
+
53
+ async function loadMemoTemplates() {
54
+ const { data } = await apiGet('/api/v2/memos/templates')
55
+ memoTemplates.value = (data as any)?.templates || []
56
+ }
57
+
58
+ function applyTemplate(tmpl: any) {
59
+ const fields = JSON.parse(tmpl.fields || '[]')
60
+ let content = `# ${tmpl.name}\n\n`
61
+ for (const f of fields) {
62
+ content += `## ${f.label}\n\n`
63
+ }
64
+ newMemoContent.value = content
65
+ showTemplateModal.value = false
66
+ }
67
+ const newMemoType = ref('memo')
68
+ const newMemoAssignees = ref<string[]>([])
69
+ const pageSize = 20
70
+ const currentOffset = ref(0)
71
+ const totalMemos = ref(0)
72
+ const hasMore = computed(() => currentOffset.value + pageSize < totalMemos.value)
73
+ const loadingMore = ref(false)
74
+
75
+ async function loadMemos(append = false) {
76
+ if (append) { loadingMore.value = true } else { loading.value = true; currentOffset.value = 0 }
77
+ const params = new URLSearchParams({ limit: String(pageSize), offset: String(currentOffset.value) })
78
+ if (searchKeyword.value) params.set('keyword', searchKeyword.value)
79
+ if (filterStatus.value) params.set('status', filterStatus.value)
80
+ if (filterAuthor.value) params.set('author', filterAuthor.value)
81
+ const { data, error } = await apiGet(`/api/v2/memos/all?${params}`)
82
+ if (!error && data?.memos) {
83
+ if (append) {
84
+ memos.value = [...memos.value, ...(data.memos as Memo[])]
85
+ } else {
86
+ memos.value = data.memos as Memo[]
87
+ }
88
+ totalMemos.value = Number(data.total) || 0
89
+ // Load all replies
90
+ const ids = memos.value.map(m => m.id)
91
+ if (ids.length) {
92
+ const { data: rData } = await apiGet(`/api/v2/memos/replies?memoIds=${ids.join(',')}`)
93
+ if (rData?.replies) {
94
+ const grouped: Record<number, Reply[]> = {}
95
+ for (const r of rData.replies as Reply[]) {
96
+ if (!grouped[r.memo_id]) grouped[r.memo_id] = []
97
+ grouped[r.memo_id].push(r)
98
+ }
99
+ replies.value = grouped
100
+ }
101
+ }
102
+ }
103
+ loading.value = false
104
+ loadingMore.value = false
105
+
106
+ // Deep link: fetch individually if not in list
107
+ if (selectedId.value && !memos.value.find(m => m.id === selectedId.value)) {
108
+ const { data: singleData } = await apiGet(`/api/v2/memos/by-id/${selectedId.value}`)
109
+ const singleMemos = singleData?.memos as Memo[] | undefined
110
+ if (singleMemos?.length) {
111
+ memos.value = [singleMemos[0], ...memos.value]
112
+ }
113
+ }
114
+ // Load replies for deep-linked memo
115
+ if (selectedId.value && !replies.value[selectedId.value]) {
116
+ const { data: rData } = await apiGet(`/api/v2/memos/replies?memoIds=${selectedId.value}`)
117
+ if (rData?.replies) {
118
+ const grouped: Record<number, Reply[]> = { ...replies.value }
119
+ for (const r of rData.replies as Reply[]) {
120
+ if (!grouped[r.memo_id]) grouped[r.memo_id] = []
121
+ grouped[r.memo_id].push(r)
122
+ }
123
+ replies.value = grouped
124
+ }
125
+ }
126
+ }
127
+
128
+ function loadMore() {
129
+ currentOffset.value += pageSize
130
+ loadMemos(true)
131
+ }
132
+
133
+ const activeTab = ref<'thread' | 'timeline'>('thread')
134
+ const showGraph = ref(false)
135
+
136
+ // Channels
137
+ const channels = ref<any[]>([])
138
+ const activeChannel = ref('')
139
+
140
+ // Read receipts + Presence
141
+ const readers = ref<any[]>([])
142
+ const presentUsers = ref<any[]>([])
143
+ const readMemoIds = ref<Set<number>>(new Set())
144
+ let presenceTimer: ReturnType<typeof setInterval> | null = null
145
+
146
+ async function loadMyReads() {
147
+ const { data } = await apiGet(`/api/v2/memos/my-reads?userName=${encodeURIComponent(currentUser.value)}`)
148
+ const ids = (data as any)?.memoIds || []
149
+ readMemoIds.value = new Set(ids)
150
+ }
151
+
152
+ async function markRead(memoId: number) {
153
+ await apiPost(`/api/v2/memos/${memoId}/read`, {})
154
+ readMemoIds.value.add(memoId)
155
+ }
156
+
157
+ async function loadReaders(memoId: number) {
158
+ const { data } = await apiGet(`/api/v2/memos/${memoId}/readers`)
159
+ readers.value = (data as any)?.readers || []
160
+ }
161
+
162
+ async function sendPresence(memoId: number) {
163
+ await apiPost(`/api/v2/memos/${memoId}/presence`, {})
164
+ const { data } = await apiGet(`/api/v2/memos/${memoId}/presence`)
165
+ presentUsers.value = (data as any)?.present || []
166
+ }
167
+
168
+ // Typing indicator
169
+ const typingUsers = ref<string[]>([])
170
+ let typingTimer: ReturnType<typeof setInterval> | null = null
171
+
172
+ async function sendTyping(memoId: number) {
173
+ await apiPost(`/api/v2/memos/${memoId}/typing`, {})
174
+ }
175
+
176
+ async function pollTyping(memoId: number) {
177
+ const { data } = await apiGet(`/api/v2/memos/${memoId}/typing`)
178
+ typingUsers.value = ((data as any)?.typing || []).map((t: any) => t.user_name)
179
+ }
180
+
181
+ function startTypingPoll(memoId: number) {
182
+ if (typingTimer) clearInterval(typingTimer)
183
+ typingTimer = setInterval(() => pollTyping(memoId), 3000)
184
+ }
185
+
186
+ function stopTypingPoll() {
187
+ if (typingTimer) { clearInterval(typingTimer); typingTimer = null }
188
+ typingUsers.value = []
189
+ }
190
+
191
+ function startPresence(memoId: number) {
192
+ if (presenceTimer) clearInterval(presenceTimer)
193
+ sendPresence(memoId)
194
+ presenceTimer = setInterval(() => sendPresence(memoId), 5000)
195
+ }
196
+
197
+ function stopPresence() {
198
+ if (presenceTimer) { clearInterval(presenceTimer); presenceTimer = null }
199
+ presentUsers.value = []
200
+ }
201
+
202
+ const unreadCounts = ref<Record<string, number>>({})
203
+ const showMobileSearch = ref(false)
204
+
205
+ async function loadChannels() {
206
+ const { data } = await apiGet('/api/v2/memos/channels')
207
+ channels.value = (data as any)?.channels || []
208
+ // Unread counts
209
+ const { data: ucData } = await apiGet('/api/v2/memos/channels/unread-counts')
210
+ unreadCounts.value = (ucData as any)?.counts || {}
211
+ }
212
+
213
+ // Extended filters
214
+ const currentUser = ref(localStorage.getItem('spec-user-name') || '')
215
+ const filterAssignedTo = ref('')
216
+ const filterDateFrom = ref('')
217
+
218
+ const filteredMemos = computed(() => {
219
+ let result = memos.value
220
+ if (activeChannel.value) result = result.filter(m => (m as any).channel_id === activeChannel.value)
221
+ if (filterStatus.value) result = result.filter(m => m.status === filterStatus.value)
222
+ if (filterAssignedTo.value === 'me') result = result.filter(m => m.assigned_to === currentUser.value)
223
+ else if (filterAssignedTo.value) result = result.filter(m => m.assigned_to === filterAssignedTo.value)
224
+ if (filterDateFrom.value) result = result.filter(m => m.created_at >= filterDateFrom.value)
225
+ return result
226
+ })
227
+
228
+ // Presets
229
+ function presetMyMemos() { filterAssignedTo.value = 'me'; filterStatus.value = '' }
230
+ function presetOpen() { filterStatus.value = 'open'; filterAssignedTo.value = '' }
231
+ function presetThisWeek() {
232
+ const now = new Date()
233
+ const day = now.getDay()
234
+ const diff = day === 0 ? 6 : day - 1
235
+ now.setDate(now.getDate() - diff)
236
+ filterDateFrom.value = now.toISOString().split('T')[0]
237
+ filterStatus.value = ''; filterAssignedTo.value = ''
238
+ }
239
+ function clearFilters() { filterStatus.value = ''; filterAssignedTo.value = ''; filterDateFrom.value = '' }
240
+
241
+ // Saved views
242
+ const savedViews = ref<any[]>([])
243
+ async function loadViews() {
244
+ const { data } = await apiGet('/api/v2/memos/views')
245
+ savedViews.value = (data as any)?.views || []
246
+ }
247
+ async function saveView() {
248
+ const name = prompt('View name:')
249
+ if (!name) return
250
+ await apiPost('/api/v2/memos/views', { name, filters: { status: filterStatus.value, assignedTo: filterAssignedTo.value, dateFrom: filterDateFrom.value } })
251
+ await loadViews()
252
+ }
253
+ function applyView(v: any) {
254
+ const f = JSON.parse(v.filters || '{}')
255
+ filterStatus.value = f.status || ''; filterAssignedTo.value = f.assignedTo || ''; filterDateFrom.value = f.dateFrom || ''
256
+ }
257
+ const linkedDocs = ref<any[]>([])
258
+
259
+ async function loadLinkedDocs(memoId: number) {
260
+ const { data } = await apiGet(`/api/v2/memos/${memoId}/linked-docs`)
261
+ linkedDocs.value = (data as any)?.links || []
262
+ }
263
+
264
+ async function createDocFromMemo() {
265
+ if (!selectedMemo.value) return
266
+ const { data } = await apiPost(`/api/v2/memos/${selectedMemo.value.id}/create-doc`, {})
267
+ if ((data as any)?.docId) {
268
+ alert('Document has been created.')
269
+ await loadLinkedDocs(selectedMemo.value.id)
270
+ }
271
+ }
272
+
273
+ const selectedMemo = computed(() =>
274
+ selectedId.value ? memos.value.find(m => m.id === selectedId.value) : null
275
+ )
276
+
277
+ const selectedReplies = computed(() =>
278
+ selectedId.value ? (replies.value[selectedId.value] || []) : []
279
+ )
280
+
281
+ function selectMemo(id: number) {
282
+ router.push(`/memos/${id}`)
283
+ loadLinkedDocs(id)
284
+ markRead(id)
285
+ loadReaders(id)
286
+ startPresence(id)
287
+ startTypingPoll(id)
288
+ nextTick(() => {
289
+ const main = document.querySelector('.app-main')
290
+ if (main) main.scrollTo({ top: 0, behavior: 'smooth' })
291
+ else window.scrollTo({ top: 0, behavior: 'smooth' })
292
+ })
293
+ }
294
+
295
+ function goBackClean() {
296
+ stopPresence(); stopTypingPoll()
297
+ router.push('/memos')
298
+ }
299
+
300
+ function goBack() {
301
+ router.push('/memos')
302
+ }
303
+
304
+ function formatDate(d: string) {
305
+ // DB stores UTC without Z suffix
306
+ const date = new Date(d.endsWith('Z') ? d : d + 'Z')
307
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
308
+ }
309
+
310
+ function statusLabel(s: string) {
311
+ const map: Record<string, string> = {
312
+ open: 'OPEN', resolved: 'RESOLVED', 'request-changes': 'REQUEST_CHANGES'
313
+ }
314
+ return map[s] || s
315
+ }
316
+
317
+ function statusClass(s: string) {
318
+ return `status-${s}`
319
+ }
320
+
321
+ // Reply input
322
+ const replyContent = ref('')
323
+ async function submitReply() {
324
+ if (!selectedId.value || !replyContent.value.trim()) return
325
+ await apiPost('/api/v2/memos/replies', {
326
+ memoId: selectedId.value,
327
+ content: replyContent.value.trim(),
328
+ })
329
+ replyContent.value = ''
330
+ await loadMemos()
331
+ // Auto scroll
332
+ await nextTick()
333
+ const repliesEl = document.querySelector('.replies-section')
334
+ if (repliesEl) repliesEl.scrollTop = repliesEl.scrollHeight
335
+ }
336
+
337
+ function highlightMentions(text: string): string {
338
+ return text.replace(/@([^\s@][^\n@]*?)(?=\s|$|@)/g, '<span class="mention-chip">@$1</span>')
339
+ }
340
+
341
+ async function deleteReply(replyId: number) {
342
+ if (!confirm('Delete this reply?')) return
343
+ const { apiDelete } = await import('@/composables/useTurso')
344
+ await apiDelete(`/api/v2/memos/replies/${replyId}`)
345
+ await loadMemos()
346
+ }
347
+
348
+ async function quickResolve(id: number) {
349
+ await apiPatch(`/api/v2/memos/${id}/resolve`, {})
350
+ const memo = memos.value.find(m => m.id === id)
351
+ if (memo) memo.status = 'resolved'
352
+ }
353
+
354
+ async function resolveMemo() {
355
+ if (!selectedMemo.value) return
356
+ await apiPatch(`/api/v2/memos/${selectedMemo.value.id}/resolve`, {})
357
+ selectedMemo.value.status = 'resolved'
358
+ }
359
+
360
+ async function reopenMemo() {
361
+ if (!selectedMemo.value) return
362
+ await apiPatch(`/api/v2/memos/${selectedMemo.value.id}`, { status: 'open' })
363
+ selectedMemo.value.status = 'open'
364
+ }
365
+
366
+ async function convertToStory() {
367
+ if (!selectedMemo.value) return
368
+ const title = selectedMemo.value.content.split('\n')[0].slice(0, 100)
369
+ const ok = confirm(`Create story "${title}"?`)
370
+ if (!ok) return
371
+ const { error } = await apiPost('/api/v2/pm/stories', {
372
+ title,
373
+ description: selectedMemo.value.content,
374
+ status: 'backlog',
375
+ })
376
+ if (error) { alert(error); return }
377
+ // resolve memo
378
+ await apiPatch(`/api/v2/memos/${selectedMemo.value.id}/resolve`, {})
379
+ await loadMemos()
380
+ }
381
+
382
+ async function convertToInitiative() {
383
+ if (!selectedMemo.value) return
384
+ const title = selectedMemo.value.content.split('\n')[0].slice(0, 100)
385
+ const ok = confirm(`Create initiative "${title}"?`)
386
+ if (!ok) return
387
+ const { error } = await apiPost('/api/v2/initiatives', {
388
+ title,
389
+ content: selectedMemo.value.content,
390
+ source_context: `Memo #${selectedMemo.value.id}`,
391
+ })
392
+ if (error) { alert(error); return }
393
+ await apiPatch(`/api/v2/memos/${selectedMemo.value.id}/resolve`, {})
394
+ await loadMemos()
395
+ }
396
+
397
+ async function loadMembers() {
398
+ const { data } = await apiGet('/api/v2/admin/members')
399
+ if (data?.members) memberList.value = (data.members as any[]).filter(m => m.is_active).map(m => m.display_name)
400
+ }
401
+
402
+ async function createMemo() {
403
+ if (!newMemoContent.value.trim()) return
404
+ if (!newMemoAssignees.value.length) {
405
+ if (!confirm('No recipients specified. Post anyway?')) return
406
+ }
407
+ await apiPost('/api/v2/memos', {
408
+ pageId: 'general',
409
+ content: newMemoContent.value,
410
+ memoType: newMemoType.value,
411
+ assignedTo: newMemoAssignees.value.join(', ') || null,
412
+ })
413
+ newMemoContent.value = ''; newMemoAssignees.value = []; showNewMemo.value = false
414
+ await loadMemos()
415
+ }
416
+
417
+ onMounted(() => { loadMemos(); loadMembers(); loadViews(); loadChannels(); loadMemoTemplates(); loadMyReads() })
418
+ watch(() => route.params.id, () => {
419
+ if (route.params.id && !memos.value.length) loadMemos()
420
+ })
421
+ </script>
422
+
423
+ <template>
424
+ <div class="memos-page">
425
+ <div class="memos-header">
426
+ <h1>Memos</h1>
427
+ </div>
428
+
429
+ <!-- New memo modal -->
430
+ <div v-if="showNewMemo" class="new-memo-card">
431
+ <button v-if="memoTemplates.length" class="btn btn--xs btn--ghost" @click="showTemplateModal = true"><Icon name="sprint" :size="14" /> Template</button>
432
+ <select v-model="newMemoChannel" class="channel-select">
433
+ <option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.icon }} {{ ch.name }}</option>
434
+ </select>
435
+ <MentionInput v-model="newMemoContent" placeholder="Memo content..." :rows="3" @submit="createMemo" />
436
+ <div class="new-memo-actions">
437
+ <select v-model="newMemoType" class="filter-select">
438
+ <option value="memo">Memo</option>
439
+ <option value="decision">Decision</option>
440
+ <option value="request">Request</option>
441
+ <option value="feature_request">Feature Request</option>
442
+ <option value="policy_request">Policy Request</option>
443
+ </select>
444
+ <div class="assignee-multi">
445
+ <div v-if="newMemoAssignees.length" class="assignee-tags">
446
+ <span v-for="a in newMemoAssignees" :key="a" class="assignee-tag">
447
+ {{ a }} <button class="tag-remove" @click="newMemoAssignees = newMemoAssignees.filter(x => x !== a)">x</button>
448
+ </span>
449
+ </div>
450
+ <select class="filter-select" @change="(e: Event) => { const v = (e.target as HTMLSelectElement).value; if (v && !newMemoAssignees.includes(v)) newMemoAssignees.push(v); (e.target as HTMLSelectElement).value = '' }">
451
+ <option value="">+ Add recipient</option>
452
+ <option v-for="m in memberList.filter(n => !newMemoAssignees.includes(n))" :key="m" :value="m">{{ m }}</option>
453
+ </select>
454
+ </div>
455
+ <button class="btn btn--primary" @click="createMemo" :disabled="!newMemoContent.trim()">Submit</button>
456
+ <button class="btn" @click="showNewMemo = false">Cancel</button>
457
+ </div>
458
+ </div>
459
+
460
+ <!-- Detail view -->
461
+ <div v-if="selectedMemo" class="memo-detail">
462
+ <button class="btn-back" @click="goBack">Back to list</button>
463
+
464
+ <div class="memo-card detail-card">
465
+ <div class="memo-meta">
466
+ <span class="memo-id">#{{ selectedMemo.id }}</span>
467
+ <span :class="['memo-status', statusClass(selectedMemo.status)]">{{ statusLabel(selectedMemo.status) }}</span>
468
+ <span class="memo-author">{{ selectedMemo.created_by }}</span>
469
+ <span class="memo-date">{{ formatDate(selectedMemo.created_at) }}</span>
470
+ </div>
471
+ <div v-if="selectedMemo.assigned_to" class="memo-assigned">
472
+ To: {{ selectedMemo.assigned_to }}
473
+ </div>
474
+ <div class="memo-content" v-html="renderMarkdown(selectedMemo.content)"></div>
475
+ <div class="memo-actions">
476
+ <button v-if="selectedMemo.status === 'open'" class="btn btn--primary btn--sm" @click="resolveMemo"><Icon name="check" :size="14" /> Resolve</button>
477
+ <button v-if="selectedMemo.status === 'resolved'" class="btn btn--ghost btn--sm" @click="reopenMemo"><Icon name="refreshCw" :size="14" /> Reopen</button>
478
+ <button v-if="selectedMemo.status === 'open'" class="btn btn--convert" @click="convertToStory">Convert to Story</button>
479
+ <button v-if="selectedMemo.status === 'open'" class="btn btn--initiative" @click="convertToInitiative">Convert to Initiative</button>
480
+ </div>
481
+ </div>
482
+
483
+ <!-- Read receipts + Presence -->
484
+ <div v-if="readers.length || presentUsers.length" class="memo-presence-bar">
485
+ <div v-if="presentUsers.length" class="presence-now">
486
+ <span class="presence-dot" />
487
+ <span v-for="u in presentUsers" :key="u.user_name" class="presence-avatar" :title="u.user_name">{{ u.user_name.length <= 3 ? u.user_name : u.user_name.slice(0, 3) }}</span>
488
+ </div>
489
+ <div v-if="readers.length" class="readers-list">
490
+ Read by: {{ readers.map(r => r.user_name).join(', ') }}
491
+ </div>
492
+ </div>
493
+
494
+ <!-- Template modal is moved via Teleport -->
495
+
496
+ <!-- Typing indicator -->
497
+ <div v-if="typingUsers.length" class="typing-indicator">
498
+ {{ typingUsers.join(', ') }} {{ typingUsers.length === 1 ? 'is' : 'are' }} typing...
499
+ </div>
500
+
501
+ <!-- Checklist -->
502
+ <MemoChecklist v-if="selectedMemo" :memo-id="selectedMemo.id" :members="memberList" />
503
+
504
+ <!-- Related memos -->
505
+ <MemoRelations v-if="selectedMemo" :memo-id="selectedMemo.id" />
506
+
507
+ <!-- Linked documents -->
508
+ <div v-if="selectedMemo && linkedDocs.length" class="linked-docs">
509
+ <span class="linked-docs-title">Linked Documents</span>
510
+ <div v-for="d in linkedDocs" :key="d.doc_id" class="linked-doc-item" @click="$router.push(`/docs/${d.doc_id}`)">
511
+ {{ d.icon || '📄' }} {{ d.title }}
512
+ </div>
513
+ </div>
514
+ <button v-if="selectedMemo" class="btn btn--xs btn--ghost" @click="createDocFromMemo">📄 Create Document</button>
515
+
516
+ <!-- Tabs -->
517
+ <div class="memo-tabs">
518
+ <button class="memo-tab" :class="{ active: activeTab === 'thread' }" @click="activeTab = 'thread'">Thread</button>
519
+ <button class="memo-tab" :class="{ active: activeTab === 'timeline' }" @click="activeTab = 'timeline'">Timeline</button>
520
+ </div>
521
+
522
+ <!-- Timeline -->
523
+ <MemoTimeline v-if="activeTab === 'timeline' && selectedMemo" :memo-id="selectedMemo.id" />
524
+
525
+ <!-- Reply thread -->
526
+ <div v-if="activeTab === 'thread'" class="replies-section">
527
+ <h3>Replies ({{ selectedReplies.length }})</h3>
528
+ <div v-for="r in selectedReplies" :key="r.id" class="reply-card">
529
+ <div class="reply-meta">
530
+ <span class="reply-author">{{ r.created_by }}</span>
531
+ <span v-if="r.review_type !== 'comment'" :class="['reply-type', `type-${r.review_type}`]">{{ r.review_type }}</span>
532
+ <span class="reply-date">{{ formatDate(r.created_at) }}</span>
533
+ </div>
534
+ <div class="reply-content" v-html="renderMarkdown(highlightMentions(r.content))"></div>
535
+ <button v-if="r.created_by === authUser" class="reply-delete-btn" @click="deleteReply(r.id)">Delete</button>
536
+ </div>
537
+
538
+ <div class="reply-input">
539
+ <MentionInput v-model="replyContent" placeholder="Write a reply... (@ to mention)" @submit="submitReply" />
540
+ <button class="btn btn--primary" @click="submitReply" :disabled="!replyContent.trim()">Reply</button>
541
+ </div>
542
+ </div>
543
+ </div>
544
+
545
+ <!-- List view -->
546
+ <!-- Channels -->
547
+ <div class="channel-tabs">
548
+ <button class="channel-tab" :class="{ active: !activeChannel }" @click="activeChannel = ''">All</button>
549
+ <button v-for="ch in channels" :key="ch.id" class="channel-tab" :class="{ active: activeChannel === ch.id }" @click="activeChannel = ch.id">
550
+ {{ ch.icon }} {{ ch.name }}
551
+ <span v-if="unreadCounts[ch.id]" class="unread-badge">{{ unreadCounts[ch.id] }}</span>
552
+ </button>
553
+ </div>
554
+
555
+ <!-- Filters -->
556
+ <div class="memo-filter-bar">
557
+ <button class="btn btn--xs" :class="filterAssignedTo === 'me' ? 'btn--primary' : 'btn--ghost'" @click="presetMyMemos">My Memos</button>
558
+ <button class="btn btn--xs" :class="filterStatus === 'open' ? 'btn--primary' : 'btn--ghost'" @click="presetOpen">Unresolved</button>
559
+ <button class="btn btn--xs btn--ghost" @click="presetThisWeek">This Week</button>
560
+ <button v-if="filterStatus || filterAssignedTo || filterDateFrom" class="btn btn--xs btn--ghost" @click="clearFilters">✕ Reset</button>
561
+ <button class="btn btn--xs btn--ghost" @click="saveView">💾 Save View</button>
562
+ <select v-if="savedViews.length" class="filter-view-select" @change="applyView(savedViews[($event.target as HTMLSelectElement).selectedIndex - 1])">
563
+ <option value="">Saved Views</option>
564
+ <option v-for="v in savedViews" :key="v.id">{{ v.name }}</option>
565
+ </select>
566
+ </div>
567
+
568
+ <!-- Graph view toggle -->
569
+ <div class="graph-toggle">
570
+ <button class="btn btn--xs" :class="showGraph ? 'btn--primary' : 'btn--ghost'" @click="showGraph = !showGraph"><Icon name="link" :size="14" /> Graph</button>
571
+ </div>
572
+ <MemoGraph v-if="showGraph" :memos="memos" />
573
+
574
+ <div v-else class="memo-list">
575
+ <div class="memo-filters">
576
+ <button class="mobile-search-btn" @click="showMobileSearch = !showMobileSearch"><Icon name="search" :size="14" /></button>
577
+ <input
578
+ v-model="searchKeyword"
579
+ type="text"
580
+ placeholder="Search by keyword..."
581
+ class="filter-input" :class="{ 'mobile-search-open': showMobileSearch }"
582
+ @keydown.enter="loadMemos()"
583
+ />
584
+ <select v-model="filterStatus" class="filter-select" @change="loadMemos()">
585
+ <option value="">All Statuses</option>
586
+ <option value="open">OPEN</option>
587
+ <option value="resolved">RESOLVED</option>
588
+ <option value="request-changes">REQUEST_CHANGES</option>
589
+ </select>
590
+ <select v-model="filterAuthor" class="filter-select" @change="loadMemos()">
591
+ <option value="">All Authors</option>
592
+ <option v-for="m in memberList" :key="m" :value="m">{{ m }}</option>
593
+ </select>
594
+ <button class="btn btn--primary btn-search" @click="loadMemos()">Search</button>
595
+ <button class="btn-new-memo" @click="showNewMemo = true">+ New Memo</button>
596
+ </div>
597
+ <div v-if="loading" class="loading">Loading...</div>
598
+ <div v-else-if="!memos.length" class="empty">No memos found.</div>
599
+ <div
600
+ v-else
601
+ v-for="m in filteredMemos"
602
+ :key="m.id"
603
+ class="memo-card list-card" :class="{ unread: !readMemoIds.has(m.id) }"
604
+ @click="selectMemo(m.id)"
605
+ >
606
+ <div class="memo-meta">
607
+ <span class="memo-id">#{{ m.id }}</span>
608
+ <span :class="['memo-status', statusClass(m.status)]">{{ statusLabel(m.status) }}</span>
609
+ <span class="memo-author">{{ m.created_by }}</span>
610
+ <span class="memo-date">{{ formatDate(m.created_at) }}</span>
611
+ <span v-if="replies[m.id]?.length" class="reply-count">{{ replies[m.id].length }} {{ replies[m.id].length === 1 ? 'reply' : 'replies' }}</span>
612
+ <button v-if="m.status === 'open'" class="resolve-icon" @click.stop="quickResolve(m.id)" title="Resolve"><Icon name="check" :size="14" /></button>
613
+ </div>
614
+ <div class="memo-preview">{{ m.content.slice(0, 120) }}{{ m.content.length > 120 ? '...' : '' }}</div>
615
+ </div>
616
+ <div v-if="hasMore" class="load-more">
617
+ <button class="btn btn--primary" @click="loadMore" :disabled="loadingMore">
618
+ {{ loadingMore ? 'Loading...' : 'Load More' }}
619
+ </button>
620
+ </div>
621
+ <div v-if="memos.length" class="memo-count">
622
+ {{ memos.length }} / {{ totalMemos }} items
623
+ </div>
624
+ </div>
625
+ </div>
626
+
627
+ <!-- Template modal (Teleport) -->
628
+ <Teleport to="body">
629
+ <div v-if="showTemplateModal" class="tmpl-overlay" @click.self="showTemplateModal = false">
630
+ <div class="tmpl-modal">
631
+ <h4>Memo Templates</h4>
632
+ <div v-for="t in memoTemplates" :key="t.id" class="tmpl-item" @click="applyTemplate(t)">
633
+ {{ t.icon || '📝' }} {{ t.name }}
634
+ </div>
635
+ <div v-if="!memoTemplates.length" class="tmpl-empty">No templates available.</div>
636
+ </div>
637
+ </div>
638
+ </Teleport>
639
+ </template>
640
+
641
+ <style scoped>
642
+ .memos-page {
643
+ max-width: 800px;
644
+ margin: 0 auto;
645
+ padding: 24px 16px;
646
+ background: var(--bg-main, #f5f6f8);
647
+ min-height: calc(100vh - 60px);
648
+ }
649
+ .memos-header h1 {
650
+ font-size: 20px;
651
+ font-weight: 700;
652
+ margin-bottom: 16px;
653
+ }
654
+ .memo-card {
655
+ background: var(--bg-card, #fff);
656
+ border: none;
657
+ border-radius: var(--radius-lg, 12px);
658
+ padding: 16px;
659
+ margin-bottom: 8px;
660
+ }
661
+ .list-card { cursor: pointer; transition: background 0.1s; }
662
+ .list-card:hover { background: var(--bg-hover, #f0f1f3); }
663
+ .memo-meta {
664
+ display: flex;
665
+ align-items: center;
666
+ gap: 8px;
667
+ font-size: 12px;
668
+ color: var(--text-muted, #888);
669
+ margin-bottom: 8px;
670
+ flex-wrap: wrap;
671
+ }
672
+ .memo-id { font-weight: 700; color: var(--text-primary, #333); }
673
+ .memo-status {
674
+ padding: 2px 8px;
675
+ border-radius: 10px;
676
+ font-size: 10px;
677
+ font-weight: 600;
678
+ }
679
+ .status-open { background: #dbeafe; color: #1d4ed8; }
680
+ .status-resolved { background: #dcfce7; color: #16a34a; }
681
+ .status-request-changes { background: #fef3c7; color: #d97706; }
682
+ .memo-content { font-size: 14px; line-height: 1.6; padding: 16px; }
683
+ .memo-content :deep(pre) { background: #1e293b; color: #e2e8f0; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.6; margin: 12px 0; }
684
+ .memo-content :deep(code) { background: #f1f5f9; color: #1e293b; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
685
+ .memo-content :deep(pre code) { background: none; color: inherit; padding: 0; }
686
+ .memo-content :deep(blockquote) { border-left: 3px solid #3b82f6; padding: 8px 12px; margin: 12px 0; background: #f8fafc; border-radius: 0 8px 8px 0; }
687
+ .memo-content :deep(h1) { font-size: 20px; font-weight: 700; margin: 16px 0 8px; }
688
+ .memo-content :deep(h2) { font-size: 17px; font-weight: 600; margin: 14px 0 6px; }
689
+ .memo-content :deep(h3) { font-size: 15px; font-weight: 600; margin: 12px 0 4px; }
690
+ .memo-content :deep(ul), .memo-content :deep(ol) { padding-left: 20px; margin: 8px 0; }
691
+ .memo-content :deep(a) { color: #3b82f6; }
692
+ .memo-content :deep(hr) { border: none; border-top: 1px solid #e5e7eb; margin: 16px 0; }
693
+ .memo-content :deep(table) { border-collapse: collapse; width: 100%; margin: 12px 0; }
694
+ .memo-content :deep(th), .memo-content :deep(td) { border: 1px solid #e5e7eb; padding: 8px; text-align: left; }
695
+ .memo-content :deep(th) { background: #f9fafb; font-weight: 600; }
696
+ .memo-preview { font-size: 13px; color: var(--text-secondary, #666); }
697
+ .memo-assigned { font-size: 12px; color: var(--text-muted, #888); margin-top: 8px; }
698
+ .memo-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
699
+ .resolve-icon { border: none; background: none; cursor: pointer; font-size: 14px; opacity: 0.4; transition: opacity 0.15s; }
700
+ .resolve-icon:hover { opacity: 1; }
701
+ .memo-tabs { display: flex; gap: 4px; margin: 12px 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 4px; }
702
+ .memo-tab { border: none; background: none; padding: 6px 12px; font-size: 13px; color: #6b7280; cursor: pointer; border-radius: 6px 6px 0 0; }
703
+ .memo-tab.active { color: #3b82f6; border-bottom: 2px solid #3b82f6; font-weight: 600; }
704
+ .linked-docs { margin: 8px 0; padding: 8px; background: #f8fafc; border-radius: 6px; }
705
+ .linked-docs-title { font-size: 12px; font-weight: 600; color: #6b7280; display: block; margin-bottom: 4px; }
706
+ .linked-doc-item { padding: 4px 0; font-size: 13px; color: #3b82f6; cursor: pointer; }
707
+ .linked-doc-item:hover { text-decoration: underline; }
708
+ .mobile-search-btn { display: none; border: none; background: #f3f4f6; padding: 4px 8px; border-radius: 6px; font-size: 14px; cursor: pointer; }
709
+ .memo-filter-bar { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; align-items: center; }
710
+ .filter-view-select { border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; font-size: 11px; }
711
+ .channel-tabs { display: flex; gap: 4px; margin-bottom: 8px; overflow-x: auto; padding-bottom: 4px; }
712
+ .channel-tab { border: none; background: #f3f4f6; padding: 5px 12px; font-size: 12px; border-radius: 16px; cursor: pointer; white-space: nowrap; }
713
+ .channel-tab.active { background: #3b82f6; color: #fff; }
714
+ .channel-tab:hover:not(.active) { background: #e5e7eb; }
715
+ .unread-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; border-radius: 8px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; padding: 0 4px; margin-left: 4px; }
716
+ .channel-select { border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; font-size: 12px; margin-bottom: 4px; }
717
+ .memo-card.unread { border-left: 3px solid #3b82f6; font-weight: 600; }
718
+ .memo-presence-bar { display: flex; gap: 16px; align-items: center; padding: 8px 0; font-size: 12px; color: #6b7280; }
719
+ .presence-now { display: flex; align-items: center; gap: 4px; }
720
+ .presence-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 2s infinite; }
721
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
722
+ .presence-avatar, .reader-avatar { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; background: #eff6ff; color: #3b82f6; font-size: 10px; font-weight: 600; cursor: pointer; }
723
+ .reader-names { font-size: 11px; color: var(--text-muted); margin-left: 4px; }
724
+ .readers-list { display: flex; align-items: center; gap: 4px; }
725
+ .tmpl-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 9999; display: flex; align-items: center; justify-content: center; }
726
+ .tmpl-modal { background: #fff; border-radius: 12px; padding: 20px; width: 320px; }
727
+ .tmpl-modal h4 { margin: 0 0 12px; }
728
+ .tmpl-item { padding: 10px; cursor: pointer; border-radius: 6px; font-size: 14px; }
729
+ .tmpl-item:hover { background: #eff6ff; }
730
+ .tmpl-empty { color: #9ca3af; text-align: center; padding: 16px; }
731
+ .typing-indicator { font-size: 12px; color: #6b7280; font-style: italic; padding: 4px 0; animation: blink 1.5s infinite; }
732
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
733
+ .btn--convert { background: #fef3c7; color: #d97706; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; }
734
+ .btn--initiative { background: #dbeafe; color: #1d4ed8; border: 1px solid #bfdbfe; border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; }
735
+ .reply-count { background: #e0e7ff; color: #4338ca; padding: 1px 6px; border-radius: 8px; font-size: 10px; }
736
+
737
+ .btn-back {
738
+ background: none;
739
+ border: 1px solid rgba(0,0,0,0.15);
740
+ border-radius: 8px;
741
+ padding: 6px 12px;
742
+ font-size: 13px;
743
+ cursor: pointer;
744
+ margin-bottom: 16px;
745
+ }
746
+
747
+ .replies-section { margin-top: 24px; }
748
+ .replies-section h3 { font-size: 14px; margin-bottom: 12px; }
749
+ .reply-card {
750
+ background: rgba(255,255,255,0.15);
751
+ border: 1px solid rgba(255,255,255,0.3);
752
+ border-radius: 12px;
753
+ padding: 12px;
754
+ margin-bottom: 8px;
755
+ }
756
+ .reply-meta {
757
+ display: flex;
758
+ align-items: center;
759
+ gap: 8px;
760
+ font-size: 11px;
761
+ color: var(--text-muted, #888);
762
+ margin-bottom: 6px;
763
+ }
764
+ .reply-author { font-weight: 600; color: var(--text-primary, #333); }
765
+ .reply-type { padding: 1px 4px; border-radius: 3px; font-size: 10px; }
766
+ .type-approve { background: #dcfce7; color: #16a34a; }
767
+ .type-request-changes { background: #fef3c7; color: #d97706; }
768
+ .reply-content { font-size: 13px; line-height: 1.5; }
769
+ .reply-content :deep(pre) { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 12px; margin: 8px 0; }
770
+ .reply-content :deep(code) { background: #f1f5f9; color: #1e293b; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
771
+ .reply-content :deep(pre code) { background: none; color: inherit; padding: 0; }
772
+ .reply-content :deep(blockquote) { border-left: 2px solid #3b82f6; padding: 4px 8px; margin: 8px 0; background: #f8fafc; }
773
+ .reply-content :deep(h1), .reply-content :deep(h2), .reply-content :deep(h3) { font-size: 14px; font-weight: 600; margin: 8px 0 4px; }
774
+ .reply-content :deep(ul), .reply-content :deep(ol) { padding-left: 16px; margin: 4px 0; }
775
+ .reply-input { margin-top: 16px; }
776
+ .reply-input textarea {
777
+ width: 100%;
778
+ border: 1px solid rgba(0,0,0,0.15);
779
+ border-radius: 8px;
780
+ padding: 8px;
781
+ font-size: 13px;
782
+ resize: vertical;
783
+ margin-bottom: 8px;
784
+ box-sizing: border-box;
785
+ }
786
+ .btn--primary {
787
+ padding: 6px 16px;
788
+ border-radius: 8px;
789
+ font-size: 13px;
790
+ }
791
+ .memo-filters {
792
+ display: flex;
793
+ gap: 8px;
794
+ margin-bottom: 16px;
795
+ flex-wrap: wrap;
796
+ }
797
+ .filter-input {
798
+ flex: 1;
799
+ min-width: 120px;
800
+ border: 1px solid rgba(0,0,0,0.15);
801
+ border-radius: 8px;
802
+ padding: 6px 10px;
803
+ font-size: 13px;
804
+ }
805
+ .filter-author { max-width: 120px; }
806
+ .filter-select {
807
+ border: 1px solid rgba(0,0,0,0.15);
808
+ border-radius: 8px;
809
+ padding: 6px 10px;
810
+ font-size: 13px;
811
+ background: #fff;
812
+ }
813
+ .btn-search { padding: 6px 16px; white-space: nowrap; }
814
+ .assignee-multi { display: flex; flex-direction: column; gap: 4px; }
815
+ .assignee-tags { display: flex; flex-wrap: wrap; gap: 4px; }
816
+ .assignee-tag { background: #dbeafe; color: #1d4ed8; padding: 2px 8px; border-radius: 12px; font-size: 12px; display: flex; align-items: center; gap: 4px; }
817
+ .tag-remove { background: none; border: none; color: #1d4ed8; cursor: pointer; font-size: 12px; padding: 0; }
818
+ .btn-new-memo { background: #3b82f6; color: #fff; border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
819
+ .new-memo-card { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
820
+ .new-memo-actions { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
821
+ .load-more { text-align: center; padding: 16px 0; }
822
+ .memo-count { text-align: center; font-size: 12px; color: var(--text-muted, #888); padding: 8px 0; }
823
+ .mention-chip { background: #dbeafe; color: #1d4ed8; padding: 1px 4px; border-radius: 4px; font-weight: 500; }
824
+ .reply-delete-btn { background: none; border: none; color: #ef4444; font-size: 11px; cursor: pointer; padding: 2px 4px; }
825
+ .reply-delete-btn:hover { text-decoration: underline; }
826
+ .loading, .empty { text-align: center; color: var(--text-muted, #888); padding: 40px; }
827
+ @media (max-width: 767px) {
828
+ .memos-page { padding: 8px; }
829
+ .channel-tabs { overflow-x: auto; flex-wrap: nowrap; gap: 4px; padding-bottom: 2px; -webkit-overflow-scrolling: touch; }
830
+ .channel-tab { padding: 4px 10px; font-size: 12px; flex-shrink: 0; }
831
+ .memo-filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
832
+ .memo-filter-bar .btn { padding: 4px 10px; font-size: 11px; }
833
+ .filter-view-select { width: auto; font-size: 11px; }
834
+ .memo-filter-bar { flex-direction: row; flex-wrap: nowrap; overflow-x: auto; gap: 4px; -webkit-overflow-scrolling: touch; }
835
+ .filter-view-select { font-size: 10px; padding: 2px 6px; width: auto; }
836
+ .mobile-search-btn { display: inline-flex; }
837
+ .filter-input { display: none; }
838
+ .filter-input.mobile-search-open { display: block; width: 100%; }
839
+ .memo-filters { flex-direction: row; flex-wrap: wrap; gap: 4px; }
840
+ .memo-filters select { width: auto; min-width: 80px; font-size: 12px; padding: 4px 6px; min-height: 36px; }
841
+ .graph-toggle { display: none; }
842
+ .memo-graph { display: none; }
843
+ .memo-card { padding: 10px; margin-bottom: 6px; border-radius: 10px; }
844
+ .memo-meta { flex-wrap: wrap; gap: 4px; font-size: 11px; }
845
+ .memo-preview { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
846
+ .detail-card { padding: 10px; }
847
+ .memo-actions { flex-wrap: wrap; gap: 4px; }
848
+ .memo-actions .btn { min-height: 40px; }
849
+ .replies-section { padding: 0; }
850
+ .reply-card { padding: 8px; }
851
+ .memo-tabs { overflow-x: auto; flex-wrap: nowrap; }
852
+ .memo-presence-bar { flex-wrap: wrap; font-size: 11px; }
853
+ h1 { font-size: 18px; }
854
+ .btn { min-height: 40px; }
855
+ input, select, textarea { min-height: 40px; font-size: 16px; }
856
+ }
857
+ </style>