-
Notifications
You must be signed in to change notification settings - Fork 0
feat(paper): reachable in-app Appearance theme toggle (off/paper/paper-night/auto) #1221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Chris0Jeky
wants to merge
12
commits into
main
Choose a base branch
from
feat/paper-appearance-settings-toggle
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
6c432a1
feat(paper): add AppearanceSettingsView with off/paper/paper-night/au…
Chris0Jeky 3358798
feat(paper): route /workspace/settings/appearance to AppearanceSettin…
Chris0Jeky 07debb2
feat(paper): link Appearance settings from the Legacy ShellSidebar
Chris0Jeky 8b9769a
feat(paper): link Appearance settings from the PaperSidebar meta nav
Chris0Jeky 4524537
test(paper): cover AppearanceSettingsView theme toggle (render, aria-…
Chris0Jeky cd43ba6
fix(paper): a11y + clarity on AppearanceSettingsView — aria-labelledb…
Chris0Jeky c425d11
fix(paper): give Appearance a distinct glyph 'E' (was 'T', collided w…
Chris0Jeky 2e6775e
fix(paper): give Appearance a distinct icon 'E' (was 'T', collided wi…
Chris0Jeky 4a1d06b
test(paper): select theme segments by data-mode hook, not label subst…
Chris0Jeky 33a08ec
fix(paper): grid layout for even segment wrapping + explicit focus-vi…
Chris0Jeky 91338ef
fix(paper): add visible Appearance link to Legacy ShellSidebar footer…
Chris0Jeky 0460582
test(paper): cover the footer Appearance link (renders + href + ungat…
Chris0Jeky File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
frontend/taskdeck-web/src/tests/views/AppearanceSettingsView.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
155
frontend/taskdeck-web/src/views/AppearanceSettingsView.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
|
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> | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.