Skip to content

Commit 2bdb146

Browse files
authored
Merge PR #1049: PAPER-03: Wire Paper shell (Sidebar + TopBar) into AppShell
PAPER-03: Wire Paper shell (Sidebar + TopBar) into AppShell
2 parents cbbacc5 + 906962b commit 2bdb146

6 files changed

Lines changed: 179 additions & 31 deletions

File tree

frontend/taskdeck-web/src/components/paper/PaperSidebar.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,4 +538,10 @@ defineExpose({
538538
min-height: 44px;
539539
}
540540
}
541+
542+
@media (prefers-reduced-motion: reduce) {
543+
.paper-sidebar {
544+
transition: none;
545+
}
546+
}
541547
</style>

frontend/taskdeck-web/src/components/shell/AppShell.vue

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,4 +337,34 @@ onUnmounted(() => {
337337
overflow-x: hidden; /* mobile safeguard — prevents horizontal scroll from wide content */
338338
}
339339
}
340+
341+
/* ─── Paper mode overrides ─── */
342+
.td-shell--paper {
343+
background: var(--paper);
344+
}
345+
346+
.td-shell--paper .td-content {
347+
background: var(--paper);
348+
}
349+
350+
@media (max-width: 640px) {
351+
.td-shell--paper .td-mobile-topbar {
352+
background: var(--paper-2);
353+
border-bottom-color: var(--line);
354+
}
355+
356+
.td-shell--paper .td-mobile-topbar__hamburger {
357+
color: var(--ink);
358+
}
359+
360+
.td-shell--paper .td-mobile-topbar__hamburger:hover {
361+
background: var(--paper-card);
362+
}
363+
364+
.td-shell--paper .td-mobile-topbar__title {
365+
font-family: var(--serif);
366+
font-weight: 500;
367+
color: var(--ink-deep);
368+
}
369+
}
340370
</style>

frontend/taskdeck-web/src/router/index.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ declare module 'vue-router' {
1515
requiresShell?: boolean
1616
automationSurface?: string
1717
requiresFlag?: keyof FeatureFlags
18+
/** Human-readable breadcrumb label for the Paper TopBar. */
19+
breadcrumb?: string
1820
}
1921
}
2022

@@ -96,52 +98,52 @@ const router = createRouter({
9698
path: '/workspace/home',
9799
name: 'workspace-home',
98100
component: HomeView,
99-
meta: { requiresShell: true },
101+
meta: { requiresShell: true, breadcrumb: 'Home' },
100102
},
101103
{
102104
path: '/workspace/today',
103105
name: 'workspace-today',
104106
component: TodayView,
105-
meta: { requiresShell: true },
107+
meta: { requiresShell: true, breadcrumb: 'Today' },
106108
},
107109
{
108110
path: '/workspace/boards',
109111
name: 'workspace-boards',
110112
component: BoardsListView,
111-
meta: { requiresShell: true },
113+
meta: { requiresShell: true, breadcrumb: 'Boards' },
112114
},
113115
{
114116
path: '/workspace/boards/:id',
115117
name: 'workspace-board',
116118
component: BoardView,
117119
props: true,
118-
meta: { requiresShell: true },
120+
meta: { requiresShell: true, breadcrumb: 'Board' },
119121
},
120122

121123
// Activity routes
122124
{
123125
path: '/workspace/activity',
124126
name: 'workspace-activity',
125127
component: ActivityView,
126-
meta: { requiresShell: true, requiresFlag: 'newActivity' },
128+
meta: { requiresShell: true, requiresFlag: 'newActivity', breadcrumb: 'Activity' },
127129
},
128130
{
129131
path: '/workspace/activity/board/:boardId',
130132
name: 'workspace-activity-board',
131133
component: ActivityView,
132-
meta: { requiresShell: true, requiresFlag: 'newActivity' },
134+
meta: { requiresShell: true, requiresFlag: 'newActivity', breadcrumb: 'Activity' },
133135
},
134136
{
135137
path: '/workspace/activity/entity/:entityType/:entityId',
136138
name: 'workspace-activity-entity',
137139
component: ActivityView,
138-
meta: { requiresShell: true, requiresFlag: 'newActivity' },
140+
meta: { requiresShell: true, requiresFlag: 'newActivity', breadcrumb: 'Activity' },
139141
},
140142
{
141143
path: '/workspace/activity/user',
142144
name: 'workspace-activity-user',
143145
component: ActivityView,
144-
meta: { requiresShell: true, requiresFlag: 'newActivity' },
146+
meta: { requiresShell: true, requiresFlag: 'newActivity', breadcrumb: 'Activity' },
145147
},
146148
{
147149
path: '/workspace/activity/user/:userId',
@@ -153,23 +155,23 @@ const router = createRouter({
153155
path: '/workspace/metrics',
154156
name: 'workspace-metrics',
155157
component: MetricsView,
156-
meta: { requiresShell: true },
158+
meta: { requiresShell: true, breadcrumb: 'Metrics' },
157159
},
158160

159161
// Integrations route
160162
{
161163
path: '/workspace/integrations',
162164
name: 'workspace-integrations',
163165
component: IntegrationsView,
164-
meta: { requiresShell: true },
166+
meta: { requiresShell: true, breadcrumb: 'Integrations' },
165167
},
166168

167169
// Calendar/timeline planning route
168170
{
169171
path: '/workspace/calendar',
170172
name: 'workspace-calendar',
171173
component: CalendarView,
172-
meta: { requiresShell: true },
174+
meta: { requiresShell: true, breadcrumb: 'Calendar' },
173175
},
174176

175177
// Automation routes
@@ -185,13 +187,13 @@ const router = createRouter({
185187
path: '/workspace/automations/queue',
186188
name: 'workspace-automations-queue',
187189
component: AutomationQueueView,
188-
meta: { requiresShell: true, automationSurface: 'queue', requiresFlag: 'newAutomation' },
190+
meta: { requiresShell: true, automationSurface: 'queue', requiresFlag: 'newAutomation', breadcrumb: 'Queue' },
189191
},
190192
{
191193
path: '/workspace/review',
192194
name: 'workspace-review',
193195
component: ReviewView,
194-
meta: { requiresShell: true, automationSurface: 'review', requiresFlag: 'newAutomation' },
196+
meta: { requiresShell: true, automationSurface: 'review', requiresFlag: 'newAutomation', breadcrumb: 'Review' },
195197
},
196198
{
197199
path: '/workspace/automations/proposals',
@@ -205,35 +207,35 @@ const router = createRouter({
205207
path: '/workspace/automations/chat',
206208
name: 'workspace-automations-chat',
207209
component: AutomationChatView,
208-
meta: { requiresShell: true, requiresFlag: 'newAutomation' },
210+
meta: { requiresShell: true, requiresFlag: 'newAutomation', breadcrumb: 'Chat' },
209211
},
210212

211213
// Ops routes
212214
{
213215
path: '/workspace/ops/cli',
214216
name: 'workspace-ops-cli',
215217
component: OpsConsoleView,
216-
meta: { requiresShell: true, requiresFlag: 'newOps' },
218+
meta: { requiresShell: true, requiresFlag: 'newOps', breadcrumb: 'Ops' },
217219
},
218220
{
219221
path: '/workspace/ops/endpoints',
220222
name: 'workspace-ops-endpoints',
221223
component: OpsConsoleView,
222-
meta: { requiresShell: true, requiresFlag: 'newOps' },
224+
meta: { requiresShell: true, requiresFlag: 'newOps', breadcrumb: 'Endpoints' },
223225
},
224226
{
225227
path: '/workspace/ops/logs',
226228
name: 'workspace-ops-logs',
227229
component: OpsConsoleView,
228-
meta: { requiresShell: true, requiresFlag: 'newOps' },
230+
meta: { requiresShell: true, requiresFlag: 'newOps', breadcrumb: 'Logs' },
229231
},
230232

231233
// Settings routes
232234
{
233235
path: '/workspace/settings/profile',
234236
name: 'workspace-settings-profile',
235237
component: ProfileSettingsView,
236-
meta: { requiresShell: true, requiresFlag: 'newAuth' },
238+
meta: { requiresShell: true, requiresFlag: 'newAuth', breadcrumb: 'Profile' },
237239
},
238240
{
239241
path: '/workspace/settings/access/:boardId?',
@@ -242,85 +244,85 @@ const router = createRouter({
242244
props: (route) => ({
243245
boardId: typeof route.params.boardId === 'string' ? route.params.boardId : null,
244246
}),
245-
meta: { requiresShell: true, requiresFlag: 'newAccess' },
247+
meta: { requiresShell: true, requiresFlag: 'newAccess', breadcrumb: 'Access' },
246248
},
247249
{
248250
path: '/workspace/settings/export-import',
249251
name: 'workspace-settings-export-import',
250252
component: ExportImportView,
251-
meta: { requiresShell: true },
253+
meta: { requiresShell: true, breadcrumb: 'Export & Import' },
252254
},
253255
{
254256
path: '/workspace/settings/preferences',
255257
name: 'workspace-settings-preferences',
256258
component: NotificationPreferencesView,
257-
meta: { requiresShell: true },
259+
meta: { requiresShell: true, breadcrumb: 'Preferences' },
258260
},
259261
{
260262
path: '/workspace/settings/api-keys',
261263
name: 'workspace-settings-api-keys',
262264
component: ApiKeySettingsView,
263-
meta: { requiresShell: true },
265+
meta: { requiresShell: true, breadcrumb: 'API Keys' },
264266
},
265267

266268
// Archive route
267269
{
268270
path: '/workspace/archive',
269271
name: 'workspace-archive',
270272
component: ArchiveView,
271-
meta: { requiresShell: true, requiresFlag: 'newArchive' },
273+
meta: { requiresShell: true, requiresFlag: 'newArchive', breadcrumb: 'Archive' },
272274
},
273275
{
274276
path: '/workspace/views',
275277
name: 'workspace-views',
276278
component: SavedViewsView,
277-
meta: { requiresShell: true },
279+
meta: { requiresShell: true, breadcrumb: 'Views' },
278280
},
279281
{
280282
path: '/workspace/views/:viewId',
281283
name: 'workspace-views-detail',
282284
component: SavedViewsView,
283-
meta: { requiresShell: true },
285+
meta: { requiresShell: true, breadcrumb: 'Views' },
284286
},
285287
{
286288
path: '/workspace/inbox',
287289
name: 'workspace-inbox',
288290
component: InboxView,
289-
meta: { requiresShell: true },
291+
meta: { requiresShell: true, breadcrumb: 'Inbox' },
290292
},
291293
{
292294
path: '/workspace/notifications',
293295
name: 'workspace-notifications',
294296
component: NotificationInboxView,
295-
meta: { requiresShell: true },
297+
meta: { requiresShell: true, breadcrumb: 'Notifications' },
296298
},
297299

298300
// Agent surfaces (visible in agent workspace mode)
299301
{
300302
path: '/workspace/agents',
301303
name: 'workspace-agents',
302304
component: AgentsView,
303-
meta: { requiresShell: true },
305+
meta: { requiresShell: true, breadcrumb: 'Agents' },
304306
},
305307
{
306308
path: '/workspace/agents/:agentId/runs',
307309
name: 'workspace-agent-runs',
308310
component: AgentRunsView,
309-
meta: { requiresShell: true },
311+
meta: { requiresShell: true, breadcrumb: 'Runs' },
310312
},
311313
{
312314
path: '/workspace/agents/:agentId/runs/:runId',
313315
name: 'workspace-agent-run-detail',
314316
component: AgentRunDetailView,
315-
meta: { requiresShell: true },
317+
meta: { requiresShell: true, breadcrumb: 'Run Detail' },
316318
},
317319

318320
// Internal dev tooling (trace replay + scenario editor)
319321
{
320322
path: '/workspace/dev-tools',
321323
name: 'workspace-dev-tools',
322324
component: DevToolsView,
323-
meta: { requiresShell: true, requiresFlag: 'devTools' },
325+
meta: { requiresShell: true, requiresFlag: 'devTools', breadcrumb: 'Dev Tools' },
324326
},
325327
],
326328
})

frontend/taskdeck-web/src/tests/components/AppShell.paperVariant.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,56 @@ describe('AppShell — paper variant routing', () => {
178178

179179
expect(wrapper.find('.paper-sidebar--mobile-open').exists()).toBe(true)
180180
})
181+
182+
it('does not render both sidebars simultaneously when toggling paper mode', () => {
183+
// Paper on: only Paper sidebar
184+
mockPaperTheme.mode = 'paper'
185+
mockPaperTheme.isOn = true
186+
mockPaperTheme.activeClass = 'paper'
187+
wrapper = mountShell()
188+
expect(wrapper.find('[data-paper-sidebar]').exists()).toBe(true)
189+
expect(wrapper.find('.td-sidebar').exists()).toBe(false)
190+
191+
wrapper.unmount()
192+
193+
// Paper off: only Obsidian sidebar
194+
mockPaperTheme.mode = 'off'
195+
mockPaperTheme.isOn = false
196+
mockPaperTheme.activeClass = null
197+
wrapper = mountShell()
198+
expect(wrapper.find('.td-sidebar').exists()).toBe(true)
199+
expect(wrapper.find('[data-paper-sidebar]').exists()).toBe(false)
200+
})
201+
202+
it('wires the Paper sidebar open-shortcuts event to the keyboard help overlay', async () => {
203+
mockPaperTheme.mode = 'paper'
204+
mockPaperTheme.isOn = true
205+
mockPaperTheme.activeClass = 'paper'
206+
wrapper = mountShell()
207+
208+
// The Shortcuts pseudo-link in the Paper sidebar triggers the overlay
209+
const shortcutsLink = wrapper.findAll('a.paper-sidebar__item')
210+
.find((l) => l.text().includes('Shortcuts'))
211+
expect(shortcutsLink).toBeDefined()
212+
await shortcutsLink?.trigger('click')
213+
await wrapper.vm.$nextTick()
214+
// Verify the overlay is now visible via its prop — not just present in the DOM
215+
const overlay = wrapper.findComponent({ name: 'PaperShortcutsOverlay' })
216+
expect(overlay.exists()).toBe(true)
217+
expect(overlay.props('visible')).toBe(true)
218+
})
219+
220+
it('wires the Paper sidebar logout to session store', async () => {
221+
mockPaperTheme.mode = 'paper'
222+
mockPaperTheme.isOn = true
223+
mockPaperTheme.activeClass = 'paper'
224+
wrapper = mountShell()
225+
226+
const logoutLink = wrapper.findAll('a.paper-sidebar__item')
227+
.find((l) => l.text().includes('Logout'))
228+
await logoutLink?.trigger('click')
229+
230+
expect(mockSession.logout).toHaveBeenCalledTimes(1)
231+
expect(mockRouter.push).toHaveBeenCalledWith('/login')
232+
})
181233
})

frontend/taskdeck-web/src/tests/components/paper/PaperSidebar.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,38 @@ describe('PaperSidebar', () => {
225225
expect(exposed.availableNavItems.some((item) => item.path.startsWith('#'))).toBe(false)
226226
})
227227

228+
it('renders the inbox badge with the · prefix when inboxBadgeCount > 0', () => {
229+
mockWorkspace.inboxBadgeCount = 5
230+
const wrapper = mountSidebar()
231+
const badges = wrapper.findAll('.paper-sidebar__badge')
232+
const inboxBadge = badges.find((b) => b.text().includes('5'))
233+
expect(inboxBadge?.text()).toMatch(/·\s*5/)
234+
expect(inboxBadge?.attributes('aria-label')).toBe('Inbox: 5 pending')
235+
})
236+
237+
it('renders sidebar groups with data-group attributes for styling hooks', () => {
238+
const wrapper = mountSidebar()
239+
expect(wrapper.find('[data-group="primary"]').exists()).toBe(true)
240+
expect(wrapper.find('[data-group="workbench"]').exists()).toBe(true)
241+
expect(wrapper.find('[data-group="meta"]').exists()).toBe(true)
242+
})
243+
244+
it('renders mono glyphs for sidebar items', () => {
245+
const wrapper = mountSidebar()
246+
const glyphs = wrapper.findAll('.paper-sidebar__glyph')
247+
// Glyphs are single-letter mono characters
248+
expect(glyphs.length).toBeGreaterThan(0)
249+
expect(glyphs[0].text()).toHaveLength(1)
250+
})
251+
252+
it('applies muted styling to meta group items', () => {
253+
const wrapper = mountSidebar()
254+
const metaGroup = wrapper.find('[data-group="meta"]')
255+
expect(metaGroup.classes()).toContain('paper-sidebar__group--muted')
256+
const metaItems = metaGroup.findAll('.paper-sidebar__item--muted')
257+
expect(metaItems.length).toBeGreaterThan(0)
258+
})
259+
228260
it('exposes and closes the mobile menu controls', async () => {
229261
const wrapper = mountSidebar()
230262
const exposed = wrapper.vm as unknown as {

0 commit comments

Comments
 (0)