popilot 0.6.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 (165) hide show
  1. package/bin/cli.mjs +204 -2
  2. package/lib/doctor.mjs +38 -1
  3. package/lib/hydrate.mjs +15 -0
  4. package/lib/scaffold.mjs +5 -0
  5. package/lib/setup-wizard.mjs +35 -2
  6. package/package.json +1 -1
  7. package/scaffold/.context/project.yaml.example +19 -0
  8. package/scaffold/mcp-notification-server/package.json +18 -0
  9. package/scaffold/mcp-notification-server/src/index.ts +275 -0
  10. package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
  11. package/scaffold/mcp-notification-server/tsconfig.json +14 -0
  12. package/scaffold/mcp-pm/package.json +19 -0
  13. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  14. package/scaffold/mcp-pm/src/index.ts +660 -0
  15. package/scaffold/mcp-pm/tsconfig.json +14 -0
  16. package/scaffold/pm-api/package.json +21 -0
  17. package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
  18. package/scaffold/pm-api/sql/002-notifications.sql +18 -0
  19. package/scaffold/pm-api/sql/003-content.sql +66 -0
  20. package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
  21. package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
  22. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  23. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  24. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  25. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  26. package/scaffold/pm-api/src/auth.ts +28 -0
  27. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  28. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  29. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  30. package/scaffold/pm-api/src/db/turso.ts +147 -0
  31. package/scaffold/pm-api/src/index.ts +114 -0
  32. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  33. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  34. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  35. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  36. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  37. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  38. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  39. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  40. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  41. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  42. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  43. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  44. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  45. package/scaffold/pm-api/src/mcp.ts +871 -0
  46. package/scaffold/pm-api/src/nudge.ts +283 -0
  47. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  48. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  49. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  50. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  51. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  52. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  53. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  54. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  55. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  56. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  57. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  58. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  59. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  60. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  61. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  62. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  63. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  64. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  65. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  66. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  67. package/scaffold/pm-api/src/types.ts +11 -0
  68. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  69. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  70. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  71. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  72. package/scaffold/pm-api/src/utils/db.ts +45 -0
  73. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  74. package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
  75. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  76. package/scaffold/pm-api/tsconfig.json +15 -0
  77. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  78. package/scaffold/spec-site/package-lock.json +892 -0
  79. package/scaffold/spec-site/package.json +15 -1
  80. package/scaffold/spec-site/src/api/types.ts +6 -0
  81. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  82. package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
  83. package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
  84. package/scaffold/spec-site/src/components/DocComments.vue +137 -0
  85. package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
  86. package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
  87. package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
  88. package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
  89. package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
  90. package/scaffold/spec-site/src/components/Icon.vue +58 -0
  91. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  92. package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
  93. package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
  94. package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
  95. package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
  96. package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
  97. package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
  98. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  99. package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
  100. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  101. package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
  102. package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
  103. package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
  104. package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
  105. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  106. package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
  107. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  108. package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
  109. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  110. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  111. package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
  112. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  113. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  114. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  115. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  116. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  117. package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
  118. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  119. package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
  120. package/scaffold/spec-site/src/features.ts +108 -0
  121. package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
  122. package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
  123. package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
  124. package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
  125. package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
  126. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  127. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  128. package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
  129. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  130. package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
  131. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  132. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  133. package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
  134. package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
  135. package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
  136. package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
  137. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  138. package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
  139. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  140. package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
  141. package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
  142. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  143. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  144. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  145. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  146. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  147. package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
  148. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  149. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  150. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  151. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  152. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  153. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  154. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  155. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  156. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  157. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  158. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  159. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  160. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  161. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  162. package/scaffold/spec-site/src/router.ts +141 -0
  163. package/scaffold/spec-site/src/styles/buttons.css +124 -0
  164. package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
  165. package/scaffold/spec-site/src/utils/timezone.ts +18 -0
@@ -0,0 +1,121 @@
1
+ <script setup lang="ts">
2
+ import Icon from '@/components/Icon.vue'
3
+ import { ref, computed, onMounted } from 'vue'
4
+ import { useRouter } from 'vue-router'
5
+ import { apiGet, apiPost, apiDelete } from '@/composables/useTurso'
6
+
7
+ const router = useRouter()
8
+
9
+ interface MockupPage {
10
+ id: number; slug: string; title: string; category: string
11
+ viewport: string; version: number; created_by: string; updated_at: string
12
+ }
13
+
14
+ const mockups = ref<MockupPage[]>([])
15
+ const searchQuery = ref('')
16
+ const filteredMockups = computed(() => {
17
+ if (!searchQuery.value) return mockups.value
18
+ const q = searchQuery.value.toLowerCase()
19
+ return mockups.value.filter(m => m.title.toLowerCase().includes(q) || m.category.toLowerCase().includes(q))
20
+ })
21
+ const loading = ref(true)
22
+
23
+ async function deleteMockup(slug: string) {
24
+ if (!confirm('Are you sure you want to delete this?')) return
25
+ await apiDelete(`/api/v2/mockups/${slug}`)
26
+ await loadMockups()
27
+ }
28
+ const showCreate = ref(false)
29
+ const newSlug = ref('')
30
+ const newTitle = ref('')
31
+ const newViewport = ref('desktop')
32
+
33
+ async function loadMockups() {
34
+ loading.value = true
35
+ const { data } = await apiGet('/api/v2/mockups')
36
+ if (data?.mockups) mockups.value = data.mockups as MockupPage[]
37
+ loading.value = false
38
+ }
39
+
40
+ async function createMockup() {
41
+ if (!newSlug.value || !newTitle.value) return
42
+ const targetSlug = newSlug.value
43
+ await apiPost('/api/v2/mockups', { slug: targetSlug, title: newTitle.value, viewport: newViewport.value })
44
+ newSlug.value = ''; newTitle.value = ''; showCreate.value = false
45
+ router.push(`/mockup-editor/${targetSlug}`)
46
+ }
47
+
48
+ function formatDate(d: string) {
49
+ if (!d) return ''
50
+ const date = new Date(d.endsWith('Z') ? d : d + 'Z')
51
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
52
+ }
53
+
54
+ onMounted(loadMockups)
55
+ </script>
56
+
57
+ <template>
58
+ <div class="mockup-list-page">
59
+ <div class="list-header">
60
+ <h1>Mockups</h1>
61
+ <button class="btn-new" @click="showCreate = !showCreate">{{ showCreate ? 'Cancel' : '+ New Mockup' }}</button>
62
+ </div>
63
+
64
+ <!-- Create form -->
65
+ <div v-if="showCreate" class="create-form">
66
+ <input v-model="newTitle" class="input" placeholder="Mockup title" />
67
+ <input v-model="newSlug" class="input" placeholder="Path (slug)" />
68
+ <select v-model="newViewport" class="input">
69
+ <option value="desktop">Desktop</option>
70
+ <option value="mobile">Mobile</option>
71
+ </select>
72
+ <button class="btn btn--primary" @click="createMockup" :disabled="!newSlug || !newTitle">Create</button>
73
+ </div>
74
+
75
+ <input v-if="mockups.length" v-model="searchQuery" class="search-input" placeholder="Search by title or category..." />
76
+
77
+ <div v-if="loading" class="loading">Loading...</div>
78
+ <div v-else-if="!mockups.length" class="empty">No mockups yet. Create a new mockup to get started.</div>
79
+
80
+ <div v-else class="mockup-grid">
81
+ <div v-for="m in filteredMockups" :key="m.id" class="mockup-card" @click="router.push(`/mockup-viewer/${m.slug}`)">
82
+ <div class="card-viewport">{{ m.viewport === 'mobile' ? '📱' : '<Icon name="monitor" :size="14" />' }}</div>
83
+ <div class="card-info">
84
+ <h3>{{ m.title }}</h3>
85
+ <div class="card-meta">
86
+ <span>{{ m.category }}</span>
87
+ <span>v{{ m.version }}</span>
88
+ <span>{{ formatDate(m.updated_at) }}</span>
89
+ </div>
90
+ </div>
91
+ <button class="card-edit" @click.stop="router.push(`/mockup-editor/${m.slug}`)">Edit</button>
92
+ <button class="card-delete" @click.stop="deleteMockup(m.slug)"><Icon name="trash" :size="14" /></button>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </template>
97
+
98
+ <style scoped>
99
+ .mockup-list-page { max-width: 900px; margin: 0 auto; padding: 24px 16px; }
100
+ .search-input { width: 100%; border: 1px solid #d1d5db; border-radius: 8px; padding: 8px 12px; font-size: 14px; margin-bottom: 12px; box-sizing: border-box; }
101
+ .list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
102
+ .list-header h1 { font-size: 20px; font-weight: 700; }
103
+ .btn-new { background: #3b82f6; color: #fff; border: none; border-radius: 10px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; }
104
+
105
+ .create-form { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
106
+ .input { border: 1px solid rgba(0,0,0,0.15); border-radius: 8px; padding: 8px 12px; font-size: 13px; }
107
+
108
+ .mockup-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
109
+ .mockup-card { display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); cursor: pointer; transition: all 0.2s; }
110
+ .mockup-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
111
+ .card-delete { border: none; background: none; font-size: 16px; cursor: pointer; opacity: 0.4; transition: opacity 0.15s; padding: 4px; }
112
+ .card-delete:hover { opacity: 1; }
113
+ .card-viewport { font-size: 28px; }
114
+ .card-info { flex: 1; }
115
+ .card-info h3 { font-size: 15px; font-weight: 600; }
116
+ .card-meta { display: flex; gap: 8px; font-size: 11px; color: #888; margin-top: 4px; }
117
+ .card-edit { background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
118
+
119
+ .loading, .empty { text-align: center; color: #888; padding: 40px; }
120
+ @media (max-width: 640px) { .mockup-grid { grid-template-columns: 1fr; } .create-form { flex-direction: column; } }
121
+ </style>
@@ -0,0 +1,199 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, watch } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { apiGet } from '@/composables/useTurso'
5
+ import SplitPaneLayout from '@/layouts/SplitPaneLayout.vue'
6
+ import MockupShell from '@/components/MockupShell.vue'
7
+ import MockupCanvas from '@/mockup/MockupCanvas.vue'
8
+ import { provideViewport } from '@/composables/useViewport'
9
+ import { provideActiveSection } from '@/composables/useActiveSection'
10
+ import { renderMarkdown } from '@/utils/markdown'
11
+ import { getComponentDef } from '@/mockup/componentCatalog'
12
+ import { useScenarios } from '@/mockup/useScenarios'
13
+
14
+ const { setMode } = provideViewport()
15
+ const { activeSection } = provideActiveSection()
16
+
17
+ const route = useRoute()
18
+ const router = useRouter()
19
+ const slug = computed(() => route.params.slug as string)
20
+
21
+ interface CanvasComponent {
22
+ id: string; componentType: string; props: Record<string, unknown>; children: CanvasComponent[]
23
+ }
24
+
25
+ const components = ref<CanvasComponent[]>([])
26
+ const pageTitle = ref('')
27
+ const viewport = ref<'mobile' | 'desktop'>('desktop')
28
+ const loading = ref(true)
29
+ const selectedId = ref<string | null>(null)
30
+
31
+ // Scenarios
32
+ const token = localStorage.getItem('spec-auth-token') || ''
33
+ const { scenarios, activeScenarioId, overrides, fetchScenarios, selectScenario } = useScenarios(slug.value, token)
34
+
35
+ // Section tabs
36
+ const sections = computed(() => {
37
+ const cats = new Set<string>()
38
+ cats.add('All')
39
+ for (const c of components.value) {
40
+ const def = getComponentDef(c.componentType)
41
+ if (def?.category) cats.add(def.category)
42
+ }
43
+ return Array.from(cats)
44
+ })
45
+
46
+ const activeTab = ref('All')
47
+
48
+ const filteredComponents = computed(() => {
49
+ if (activeTab.value === 'All') return components.value
50
+ return components.value.filter(c => {
51
+ const def = getComponentDef(c.componentType)
52
+ return def?.category === activeTab.value
53
+ })
54
+ })
55
+
56
+ // Selected component spec
57
+ const selectedComp = computed(() => {
58
+ if (!selectedId.value) return null
59
+ return findComponent(components.value, selectedId.value)
60
+ })
61
+
62
+ const specTitle = computed(() => {
63
+ if (!selectedComp.value) return ''
64
+ const def = getComponentDef(selectedComp.value.componentType)
65
+ return `${def?.icon || ''} ${def?.name || selectedComp.value.componentType}`
66
+ })
67
+
68
+ const specDescription = computed(() => {
69
+ if (!selectedComp.value) return ''
70
+ return (selectedComp.value.props.specDescription as string) || ''
71
+ })
72
+
73
+ function findComponent(list: CanvasComponent[], id: string): CanvasComponent | null {
74
+ for (const c of list) {
75
+ if (c.id === id) return c
76
+ const found = findComponent(c.children || [], id)
77
+ if (found) return found
78
+ }
79
+ return null
80
+ }
81
+
82
+ function onSelect(id: string) {
83
+ selectedId.value = id
84
+ }
85
+
86
+ function copySpec() {
87
+ if (specDescription.value) {
88
+ navigator.clipboard.writeText(specDescription.value)
89
+ alert('Spec copied')
90
+ }
91
+ }
92
+
93
+ onMounted(async () => {
94
+ fetchScenarios()
95
+ const { data } = await apiGet(`/api/v2/mockups/${slug.value}`)
96
+ if (data?.page) {
97
+ pageTitle.value = (data.page as any).title || slug.value
98
+ viewport.value = (data.page as any).viewport === 'mobile' ? 'mobile' : 'desktop'
99
+ }
100
+ if (data?.components) {
101
+ components.value = buildTree(data.components as any[])
102
+ }
103
+ loading.value = false
104
+ if (viewport.value === 'mobile') setMode('mobile')
105
+ })
106
+
107
+ function buildTree(flat: any[]): CanvasComponent[] {
108
+ const map = new Map<number, CanvasComponent>()
109
+ const roots: CanvasComponent[] = []
110
+ for (const f of flat) {
111
+ map.set(f.id, { id: `c-${f.id}`, componentType: f.component_type, props: JSON.parse(f.props || '{}'), children: [] })
112
+ }
113
+ for (const f of flat) {
114
+ const comp = map.get(f.id)!
115
+ if (f.parent_id && map.has(f.parent_id)) map.get(f.parent_id)!.children.push(comp)
116
+ else roots.push(comp)
117
+ }
118
+ return roots
119
+ }
120
+ </script>
121
+
122
+ <template>
123
+ <div v-if="loading" style="text-align:center; padding:60px; color:#888;">Loading...</div>
124
+
125
+ <SplitPaneLayout v-else :spec-areas="[]">
126
+ <template #mockup>
127
+ <div class="viewer-toolbar">
128
+ <h2>{{ pageTitle }}</h2>
129
+ <button class="styled-btn styled-btn--secondary" @click="router.push(`/mockup-editor/${slug}`)">Edit</button>
130
+ <select v-if="scenarios.length" class="scenario-select" :value="activeScenarioId || ''" @change="selectScenario(($event.target as HTMLSelectElement).value ? Number(($event.target as HTMLSelectElement).value) : null)">
131
+ <option value="">Default</option>
132
+ <option v-for="s in scenarios" :key="s.id" :value="s.id">{{ s.name }}</option>
133
+ </select>
134
+ <button class="styled-btn styled-btn--ghost" @click="router.push('/mockups')">List</button>
135
+ </div>
136
+ <div class="section-tabs">
137
+ <button v-for="sec in sections" :key="sec" class="tab-btn" :class="{ active: activeTab === sec }" @click="activeTab = sec">{{ sec }}</button>
138
+ </div>
139
+ <MockupShell>
140
+ <MockupCanvas
141
+ :components="filteredComponents"
142
+ :selected-id="selectedId"
143
+ :viewport="viewport"
144
+ :view-mode="true"
145
+ @select="onSelect"
146
+ />
147
+ </MockupShell>
148
+ </template>
149
+
150
+ <template #spec>
151
+ <div class="spec-panel-v2">
152
+ <template v-if="selectedComp">
153
+ <div class="spec-header">
154
+ <span class="spec-comp-name">{{ specTitle }}</span>
155
+ <span class="spec-comp-type">{{ selectedComp.componentType }}</span>
156
+ </div>
157
+ <div v-if="specDescription" class="spec-body" v-html="renderMarkdown(specDescription)"></div>
158
+ <div v-else class="spec-empty">No spec description. Add one in the editor.</div>
159
+ <div class="spec-actions">
160
+ <button v-if="specDescription" class="styled-btn styled-btn--ghost" @click="copySpec">Copy</button>
161
+ </div>
162
+ </template>
163
+ <div v-else class="spec-guide">
164
+ <p>Click a component to view its spec.</p>
165
+ </div>
166
+ </div>
167
+ </template>
168
+ </SplitPaneLayout>
169
+ </template>
170
+
171
+ <style scoped>
172
+ .viewer-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--border, #e5e7eb); }
173
+ .viewer-toolbar h2 { font-size: 16px; font-weight: 600; flex: 1; }
174
+ .styled-btn { border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s; }
175
+ .styled-btn--secondary { background: #f3f4f6; color: #374151; }
176
+ .styled-btn--secondary:hover { background: #e5e7eb; }
177
+ .styled-btn--ghost { background: none; color: #6b7280; }
178
+ .styled-btn--ghost:hover { background: #f9fafb; }
179
+
180
+ .section-tabs { display: flex; gap: 4px; padding: 4px 16px; border-bottom: 1px solid var(--border, #e5e7eb); overflow-x: auto; }
181
+ .tab-btn { border: none; background: none; padding: 6px 12px; font-size: 13px; color: #6b7280; cursor: pointer; border-radius: 6px; white-space: nowrap; }
182
+ .tab-btn.active { background: #3b82f6; color: #fff; }
183
+ .tab-btn:hover:not(.active) { background: #f3f4f6; }
184
+
185
+ .scenario-select { border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; font-size: 12px; }
186
+ .search-input { width: 100%; border: 1px solid #d1d5db; border-radius: 8px; padding: 8px 12px; font-size: 14px; margin-bottom: 12px; box-sizing: border-box; }
187
+ .spec-panel-v2 { padding: 20px; height: 100%; overflow-y: auto; }
188
+ .spec-header { margin-bottom: 16px; }
189
+ .spec-comp-name { font-size: 16px; font-weight: 700; display: block; }
190
+ .spec-comp-type { font-size: 12px; color: #9ca3af; }
191
+ .spec-body { font-size: 14px; line-height: 1.7; color: var(--text-primary, #1c1c1e); }
192
+ .spec-body :deep(h1) { font-size: 18px; font-weight: 700; margin: 16px 0 8px; }
193
+ .spec-body :deep(h2) { font-size: 16px; font-weight: 600; margin: 12px 0 6px; }
194
+ .spec-body :deep(code) { background: #f1f3f5; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
195
+ .spec-body :deep(ul) { padding-left: 16px; }
196
+ .spec-empty { color: #9ca3af; font-size: 13px; font-style: italic; }
197
+ .spec-actions { margin-top: 16px; }
198
+ .spec-guide { display: flex; align-items: center; justify-content: center; height: 100%; color: #9ca3af; font-size: 14px; }
199
+ </style>