Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const metaItems: PaperNavItem[] = [
{ id: 'settings', label: 'Settings', glyph: 'S', path: '/workspace/settings/profile', flag: 'newAuth', workbenchBypassesFlag: true, keywords: 'settings profile password account' },
{ id: 'api-keys', label: 'API Keys', glyph: 'K', path: '/workspace/settings/api-keys', keywords: 'api keys mcp tokens authentication' },
{ id: 'preferences', label: 'Preferences', glyph: 'P', path: '/workspace/settings/preferences', keywords: 'preferences notifications' },
{ id: 'appearance', label: 'Appearance', glyph: 'E', path: '/workspace/settings/appearance', keywords: 'appearance theme paper night dark light obsidian legacy' },
{ id: 'shortcuts', label: 'Shortcuts', glyph: '?', path: '#shortcuts' },
{ id: 'logout', label: 'Logout', glyph: '→', path: '#logout' },
]
Expand Down
27 changes: 25 additions & 2 deletions frontend/taskdeck-web/src/components/shell/ShellSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,16 @@ const navCatalog: NavItem[] = [
secondaryModes: ['guided', 'agent'],
keywords: 'preferences notifications',
},
{
id: 'appearance',
label: 'Appearance',
icon: 'E',
path: '/workspace/settings/appearance',
flag: null,
Comment thread
Chris0Jeky marked this conversation as resolved.
primaryModes: ['workbench'],
secondaryModes: ['guided', 'agent'],
keywords: 'appearance theme paper night dark light obsidian legacy',
},
{
id: 'access',
label: 'Access',
Expand Down Expand Up @@ -405,13 +415,26 @@ defineExpose({
v-if="featureFlags.isEnabled('newAuth')"
to="/workspace/settings/profile"
class="td-nav-item td-nav-item--secondary"
:class="{ 'td-nav-item--active': isActiveRoute('/workspace/settings') }"
:aria-current="isActiveRoute('/workspace/settings') ? 'page' : undefined"
:class="{ 'td-nav-item--active': isActiveRoute('/workspace/settings/profile') }"
:aria-current="isActiveRoute('/workspace/settings/profile') ? 'page' : undefined"
@click="closeMobileMenu"
>
<span class="td-nav-item__icon">S</span>
<span v-if="!sidebarCollapsed" class="td-nav-item__label">Settings</span>
</router-link>
<!-- Appearance/theme is the one settings page worth a visible link: it is
the only way for a default-'off' user to discover Paper from the
Legacy shell (the rest of the settings cluster stays Ctrl+K-only). -->
<router-link
to="/workspace/settings/appearance"
class="td-nav-item td-nav-item--secondary"
:class="{ 'td-nav-item--active': isActiveRoute('/workspace/settings/appearance') }"
:aria-current="isActiveRoute('/workspace/settings/appearance') ? 'page' : undefined"
@click="closeMobileMenu"
>
<span class="td-nav-item__icon">E</span>
<span v-if="!sidebarCollapsed" class="td-nav-item__label">Appearance</span>
</router-link>
</nav>

<div class="td-sidebar__footer">
Expand Down
7 changes: 7 additions & 0 deletions frontend/taskdeck-web/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const ExportImportView = () => import('../views/ExportImportView.vue')
const ArchiveView = () => import('../views/ArchiveView.vue')
const NotificationInboxView = () => import('../views/NotificationInboxView.vue')
const NotificationPreferencesView = () => import('../views/NotificationPreferencesView.vue')
const AppearanceSettingsView = () => import('../views/AppearanceSettingsView.vue')
const InboxView = () => import('../views/InboxView.vue')
const HomeView = () => import('../views/HomeView.vue')
const TodayView = () => import('../views/TodayView.vue')
Expand Down Expand Up @@ -276,6 +277,12 @@ const router = createRouter({
component: NotificationPreferencesView,
meta: { requiresShell: true, breadcrumb: 'Preferences' },
},
{
path: '/workspace/settings/appearance',
name: 'workspace-settings-appearance',
component: AppearanceSettingsView,
meta: { requiresShell: true, breadcrumb: 'Appearance' },
Comment thread
Chris0Jeky marked this conversation as resolved.
},
{
path: '/workspace/settings/api-keys',
name: 'workspace-settings-api-keys',
Expand Down
17 changes: 17 additions & 0 deletions frontend/taskdeck-web/src/tests/components/ShellSidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ describe('ShellSidebar', () => {
expect(wrapper.text()).toContain('Settings')
})

it('renders a visible Appearance link in the footer pointing at the theme settings', () => {
const wrapper = mountSidebar({
stub: { template: '<a :data-to="to"><slot /></a>', props: ['to'] },
})
const appearanceLink = wrapper.findAll('a').find((a) => a.text().includes('Appearance'))
expect(appearanceLink).toBeTruthy()
expect(appearanceLink!.attributes('data-to')).toBe('/workspace/settings/appearance')
})

it('keeps the Appearance link visible even when newAuth is disabled (theme is auth-independent)', () => {
mockFeatureFlags.isEnabled = vi.fn((flag: string) => flag !== 'newAuth')
const wrapper = mountSidebar()
expect(wrapper.text()).toContain('Appearance')
// The Settings (profile) link is gated on newAuth; Appearance is not.
expect(wrapper.text()).not.toContain('Settings')
})

it('does not show demoted items (Metrics, Activity, Ops, etc.) in the sidebar', () => {
const wrapper = mountSidebar()
// These items were demoted from sidebar and are now command-palette-only
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { beforeEach, afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import AppearanceSettingsView from '../../views/AppearanceSettingsView.vue'
import { usePaperThemeStore } from '../../store/paperThemeStore'

const STORAGE_KEY = 'td.paper.mode'

// Select by the stable data-mode hook rather than label text, so the tests are
// resilient to label wording changes.
function segmentByMode(wrapper: ReturnType<typeof mount>, mode: string) {
return wrapper.find(`[data-mode="${mode}"]`)
}

describe('AppearanceSettingsView', () => {
beforeEach(() => {
setActivePinia(createPinia())
window.localStorage.clear()
document.body.classList.remove('paper', 'paper-night')
})

afterEach(() => {
document.body.classList.remove('paper', 'paper-night')
})

it('renders all four theme options', () => {
const wrapper = mount(AppearanceSettingsView)
const labels = wrapper.findAll('.td-theme-segment').map((b) => b.text())
expect(labels).toHaveLength(4)
expect(labels.some((l) => l.includes('Off (Legacy / Obsidian)'))).toBe(true)
expect(labels.some((l) => l.includes('Paper (Light)'))).toBe(true)
expect(labels.some((l) => l.includes('Paper Night (Dark)'))).toBe(true)
expect(labels.some((l) => l.includes('Auto (match system)'))).toBe(true)
})

it('reflects the current mode via aria-pressed (default off)', () => {
const wrapper = mount(AppearanceSettingsView)
expect(segmentByMode(wrapper, 'off').attributes('aria-pressed')).toBe('true')
expect(segmentByMode(wrapper, 'paper').attributes('aria-pressed')).toBe('false')
expect(segmentByMode(wrapper, 'auto').attributes('aria-pressed')).toBe('false')
})

it('selecting a mode calls setMode and persists to localStorage', async () => {
const wrapper = mount(AppearanceSettingsView)
const store = usePaperThemeStore()

await segmentByMode(wrapper, 'paper-night').trigger('click')

expect(store.mode).toBe('paper-night')
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('paper-night')
expect(document.body.classList.contains('paper-night')).toBe(true)
expect(segmentByMode(wrapper, 'paper-night').attributes('aria-pressed')).toBe('true')
})

it('Auto stays Auto (does not collapse to the resolved class)', async () => {
const wrapper = mount(AppearanceSettingsView)
const store = usePaperThemeStore()

await segmentByMode(wrapper, 'auto').trigger('click')

expect(store.mode).toBe('auto')
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('auto')
expect(segmentByMode(wrapper, 'auto').attributes('aria-pressed')).toBe('true')
})

it('Off restores Legacy (no paper body class, store off)', async () => {
const wrapper = mount(AppearanceSettingsView)
const store = usePaperThemeStore()

await segmentByMode(wrapper, 'paper').trigger('click')
expect(store.isOn).toBe(true)

await segmentByMode(wrapper, 'off').trigger('click')

expect(store.mode).toBe('off')
expect(store.isOn).toBe(false)
expect(document.body.classList.contains('paper')).toBe(false)
expect(document.body.classList.contains('paper-night')).toBe(false)
})
})
155 changes: 155 additions & 0 deletions frontend/taskdeck-web/src/views/AppearanceSettingsView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePaperThemeStore, type PaperMode } from '../store/paperThemeStore'

const paperTheme = usePaperThemeStore()

interface ThemeOption {
mode: PaperMode
label: string
hint: string
}

// Single source of truth for the four selectable modes. `off` is the Legacy
// (Obsidian) escape hatch: it removes the Paper body class so AppShell renders
// the classic `.td-*` shell, not just a light palette.
const options: ThemeOption[] = [
{
mode: 'off',
label: 'Off (Legacy / Obsidian)',
hint: 'The original Obsidian shell. Choosing this returns the whole interface to Legacy, not just the colours.',
},
{
mode: 'paper',
label: 'Paper (Light)',
hint: 'The canonical Paper theme — cream paper, ink, and a single ember accent.',
},
{
mode: 'paper-night',
label: 'Paper Night (Dark)',
hint: 'Paper after dark — the same layout in a low-light palette.',
},
{
mode: 'auto',
label: 'Auto (match system)',
hint: 'Follows your operating system’s light/dark preference and updates live when it changes.',
},
]

const activeMode = computed(() => paperTheme.mode)
const activeHint = computed(
() => options.find((option) => option.mode === activeMode.value)?.hint ?? '',
)

function selectMode(mode: PaperMode) {
// Pure UI: the store persists to localStorage and re-applies the body class.
paperTheme.setMode(mode)
}
</script>

<template>
<div class="td-appearance-settings">
<h1 class="td-page-title">Appearance</h1>
<p class="td-description">
Choose how Taskdeck looks. Paper is the canonical theme; Off keeps the original Legacy (Obsidian) shell.
</p>

<section class="td-panel">
<div id="td-appearance-theme-label" class="td-section-title">Theme</div>
<!--
Single-select segmented control. Kept as <button> + aria-pressed to match
the project-wide convention (PaperStyleGuideView, Today/Review rails, etc.
all use aria-pressed; no role="radiogroup" exists anywhere in the app). The
group is labelled by the visible "Theme" heading via aria-labelledby.
-->
<div class="td-theme-segments" role="group" aria-labelledby="td-appearance-theme-label">
<button
v-for="option in options"
:key="option.mode"
type="button"
class="td-theme-segment"
:class="{ 'td-theme-segment--active': activeMode === option.mode }"
:data-mode="option.mode"
:aria-pressed="activeMode === option.mode"
@click="selectMode(option.mode)"
>
{{ option.label }}
</button>
</div>
<p class="td-theme-hint">{{ activeHint }}</p>
</section>
</div>
</template>

<style scoped>
/*
* Intentionally styled with the Legacy/Obsidian --td-* design tokens, matching
* every other /workspace/settings/* view. Paper does not redefine --td-*, so
* inside the Paper shell this panel renders in the Obsidian palette (readable,
* just not Paper-skinned) until the settings surfaces are Paper-tokenized in a
* later archive-pivot wave. Deliberately not re-skinned in this slice.
*/
.td-appearance-settings {
max-width: 640px;
}

.td-description {
color: var(--td-text-secondary);
margin-bottom: var(--td-space-4);
}

.td-panel {
display: flex;
flex-direction: column;
gap: var(--td-space-3);
background: var(--td-surface-primary);
border: 1px solid var(--td-border-default);
border-radius: var(--td-radius-lg);
padding: var(--td-space-5);
}

.td-section-title {
font-weight: 700;
color: var(--td-text-primary);
}

.td-theme-segments {
/* Grid (not flex-wrap) so segments keep equal widths and wrap cleanly
instead of stretching a lone item to full width on the next row. */
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--td-space-2);
}

.td-theme-segment {
padding: var(--td-space-2) var(--td-space-3);
border-radius: var(--td-radius-md);
border: 1px solid var(--td-border-default);
background: var(--td-surface-primary);
color: var(--td-text-secondary);
cursor: pointer;
font: inherit;
}

.td-theme-segment:hover {
border-color: var(--td-color-primary);
}
Comment thread
Chris0Jeky marked this conversation as resolved.

.td-theme-segment:focus-visible {
outline: none;
box-shadow: var(--td-focus-ring);
}

.td-theme-segment--active {
background: var(--td-color-primary);
color: var(--td-text-inverse);
border-color: var(--td-color-primary);
font-weight: 600;
}

.td-theme-hint {
color: var(--td-text-secondary);
margin: 0;
min-height: 1.25rem;
}
</style>
Loading