this repo has no descr,ription vt3e.cat

feat: options for opendyslexic, removing transitions, rounded cornres

vt3e.cat 305eca32 a700e88b

verified
+915 -167
+1 -1
pkgs/web/src/components/Card/CardLayout.vue
··· 243 243 } 244 244 } 245 245 246 - .header-actions:hover .esc-hint, 246 + .header-actions:has(.close-button:hover) .esc-hint, 247 247 .esc-hint.is-visible { 248 248 opacity: 1; 249 249 transform: translateX(0);
+160 -143
pkgs/web/src/router/index.ts
··· 1 - import type { VNode } from 'vue' 2 - import { createRouter, createWebHistory } from 'vue-router' 1 + import type { VNode } from "vue"; 2 + import { createRouter, createWebHistory } from "vue-router"; 3 3 import { 4 - IconFolderOutlineRounded, 5 - IconLaptopWindowsOutlineRounded, 6 - IconAutoAwesomeMosaicOutline 7 - } from '@iconify-prerendered/vue-material-symbols' 4 + IconFolderOutlineRounded, 5 + IconLaptopWindowsOutlineRounded, 6 + IconAutoAwesomeMosaicOutline, 7 + } from "@iconify-prerendered/vue-material-symbols"; 8 8 9 - import { AccentColour } from '@/stores/theme' 10 - import { useUIStore } from '@/stores/ui' 9 + import { AccentColour } from "@/stores/theme"; 10 + import { useUIStore } from "@/stores/ui"; 11 11 12 - const HomeView = () => import('../views/HomeView.vue') 13 - const ProjectsView = () => import('../views/ProjectsView.vue') 14 - const UsesView = () => import('../views/UsesView.vue') 15 - const AboutView = () => import('../views/AboutView.vue') 16 - const GalleryView = () => import('../views/GalleryView.vue') 12 + const HomeView = () => import("../views/HomeView.vue"); 13 + const ProjectsView = () => import("../views/ProjectsView.vue"); 14 + const UsesView = () => import("../views/UsesView.vue"); 15 + const AboutView = () => import("../views/AboutView.vue"); 16 + const GalleryView = () => import("../views/GalleryView.vue"); 17 + const SettingsView = () => import("../views/SettingsView.vue"); 17 18 18 - declare module 'vue-router' { 19 - interface RouteMeta { 20 - title?: string 21 - icon?: VNode 22 - excerpt?: string 23 - gridArea?: string 24 - bg?: AccentColour 25 - isCard?: boolean 26 - } 19 + declare module "vue-router" { 20 + interface RouteMeta { 21 + title?: string; 22 + icon?: VNode; 23 + excerpt?: string; 24 + gridArea?: string; 25 + bg?: AccentColour; 26 + isCard?: boolean; 27 + } 27 28 } 28 29 29 - const FocusMap = new Map<string, string>() 30 + const FocusMap = new Map<string, string>(); 30 31 31 32 function isFocusable(el: HTMLElement): boolean { 32 - const focusableSelectors = 'a[href],button,textarea,input,select,[tabindex]' 33 - return el.matches(focusableSelectors) 33 + const focusableSelectors = "a[href],button,textarea,input,select,[tabindex]"; 34 + return el.matches(focusableSelectors); 34 35 } 35 36 36 37 function makeRestoreSelectorFor(el: Element | null): string | null { 37 - if (!el || !(el instanceof HTMLElement)) return null 38 - if (el.id) return `#${CSS.escape(el.id)}` 38 + if (!el || !(el instanceof HTMLElement)) return null; 39 + if (el.id) return `#${CSS.escape(el.id)}`; 39 40 40 - const selectorParts: string[] = [] 41 - let currentEl: HTMLElement | null = el 41 + const selectorParts: string[] = []; 42 + let currentEl: HTMLElement | null = el; 42 43 43 - while (currentEl && currentEl !== document.body) { 44 - let part = currentEl.tagName.toLowerCase() 44 + while (currentEl && currentEl !== document.body) { 45 + let part = currentEl.tagName.toLowerCase(); 45 46 46 - if (currentEl.classList.length > 0) { 47 - part += '.' + Array.from(currentEl.classList) 48 - .map(cls => CSS.escape(cls)) 49 - .join('.') 50 - } 47 + if (currentEl.classList.length > 0) { 48 + part += 49 + "." + 50 + Array.from(currentEl.classList) 51 + .map((cls) => CSS.escape(cls)) 52 + .join("."); 53 + } 51 54 52 - const parent = currentEl.parentElement as HTMLElement 53 - if (parent) { 54 - const siblings = Array.from(parent.children) 55 - .filter(child => child.tagName === currentEl!.tagName) 55 + const parent = currentEl.parentElement as HTMLElement; 56 + if (parent) { 57 + const siblings = Array.from(parent.children).filter( 58 + (child) => child.tagName === currentEl!.tagName, 59 + ); 56 60 57 - if (siblings.length > 1) { 58 - const index = siblings.indexOf(currentEl) + 1 59 - part += `:nth-of-type(${index})` 60 - } 61 - } 61 + if (siblings.length > 1) { 62 + const index = siblings.indexOf(currentEl) + 1; 63 + part += `:nth-of-type(${index})`; 64 + } 65 + } 62 66 63 - selectorParts.unshift(part) 64 - if (isFocusable(currentEl)) break 67 + selectorParts.unshift(part); 68 + if (isFocusable(currentEl)) break; 65 69 66 - currentEl = parent 67 - } 70 + currentEl = parent; 71 + } 68 72 69 - const selector = selectorParts.join(' > ') 70 - return isFocusable(el) ? selector : null 73 + const selector = selectorParts.join(" > "); 74 + return isFocusable(el) ? selector : null; 71 75 } 72 76 73 77 function focusElementBySelector(selector?: string | null): boolean { 74 - if (!selector) return false 78 + if (!selector) return false; 75 79 76 - const el = document.querySelector(selector) as HTMLElement | null 77 - if (!el) return false 80 + const el = document.querySelector(selector) as HTMLElement | null; 81 + if (!el) return false; 78 82 79 - const hadTab = el.hasAttribute('tabindex') 80 - const prevTab = el.getAttribute('tabindex') 83 + const hadTab = el.hasAttribute("tabindex"); 84 + const prevTab = el.getAttribute("tabindex"); 81 85 82 - if (!el.matches('a[href],button,textarea,input,select,[tabindex]')) el.setAttribute('tabindex', '-1') 83 - el.focus({ preventScroll: false }) 86 + if (!el.matches("a[href],button,textarea,input,select,[tabindex]")) 87 + el.setAttribute("tabindex", "-1"); 88 + el.focus({ preventScroll: false }); 84 89 85 - if (!hadTab) el.removeAttribute('tabindex') 86 - else if (prevTab !== null) el.setAttribute('tabindex', prevTab) 90 + if (!hadTab) el.removeAttribute("tabindex"); 91 + else if (prevTab !== null) el.setAttribute("tabindex", prevTab); 87 92 88 - return true 93 + return true; 89 94 } 90 95 91 96 const router = createRouter({ 92 - history: createWebHistory(import.meta.env.BASE_URL), 93 - routes: [ 94 - { 95 - path: '/', 96 - name: 'home', 97 - component: HomeView 98 - }, 99 - { 100 - path: '/about', 101 - name: 'about', 102 - component: AboutView, 103 - meta: { 104 - title: 'about', 105 - bg: AccentColour.Rosewater 106 - } 107 - }, 108 - { 109 - path: '/projects', 110 - name: 'projects', 111 - component: ProjectsView, 112 - meta: { 113 - isCard: true, 114 - title: 'projects', 115 - icon: IconFolderOutlineRounded(), 116 - gridArea: 'area-projects', 117 - bg: AccentColour.Flamingo 118 - } 119 - }, 120 - { 121 - path: '/uses', 122 - name: 'uses', 123 - component: UsesView, 124 - meta: { 125 - isCard: true, 126 - title: '/uses', 127 - icon: IconLaptopWindowsOutlineRounded(), 128 - gridArea: 'area-uses', 129 - bg: AccentColour.Sky 130 - } 131 - }, 132 - { 133 - path: '/gallery', 134 - name: 'gallery', 135 - component: GalleryView, 136 - meta: { 137 - isCard: true, 138 - title: 'gallery', 139 - icon: IconAutoAwesomeMosaicOutline(), 140 - gridArea: 'area-gallery', 141 - bg: AccentColour.Lavender 142 - } 143 - } 144 - ], 145 - scrollBehavior() { 146 - return { top: 0, behavior: 'smooth' } 147 - } 148 - }) 97 + history: createWebHistory(import.meta.env.BASE_URL), 98 + routes: [ 99 + { 100 + path: "/", 101 + name: "home", 102 + component: HomeView, 103 + }, 104 + { 105 + path: "/about", 106 + name: "about", 107 + component: AboutView, 108 + meta: { 109 + title: "about", 110 + bg: AccentColour.Rosewater, 111 + }, 112 + }, 113 + { 114 + path: "/projects", 115 + name: "projects", 116 + component: ProjectsView, 117 + meta: { 118 + isCard: true, 119 + title: "projects", 120 + icon: IconFolderOutlineRounded(), 121 + gridArea: "area-projects", 122 + bg: AccentColour.Flamingo, 123 + }, 124 + }, 125 + { 126 + path: "/settings", 127 + name: "settings", 128 + component: SettingsView, 129 + meta: { 130 + isCard: false, 131 + title: "settings", 132 + icon: IconFolderOutlineRounded(), 133 + gridArea: "area-projects", 134 + bg: AccentColour.Flamingo, 135 + }, 136 + }, 137 + { 138 + path: "/uses", 139 + name: "uses", 140 + component: UsesView, 141 + meta: { 142 + isCard: true, 143 + title: "/uses", 144 + icon: IconLaptopWindowsOutlineRounded(), 145 + gridArea: "area-uses", 146 + bg: AccentColour.Sky, 147 + }, 148 + }, 149 + { 150 + path: "/gallery", 151 + name: "gallery", 152 + component: GalleryView, 153 + meta: { 154 + isCard: true, 155 + title: "gallery", 156 + icon: IconAutoAwesomeMosaicOutline(), 157 + gridArea: "area-gallery", 158 + bg: AccentColour.Lavender, 159 + }, 160 + }, 161 + ], 162 + scrollBehavior() { 163 + return { top: 0, behavior: "smooth" }; 164 + }, 165 + }); 149 166 150 167 router.beforeEach((_to, from, next) => { 151 - const active = document.activeElement as Element | null 168 + const active = document.activeElement as Element | null; 152 169 153 - if (active && from.fullPath) { 154 - const selector = makeRestoreSelectorFor(active) 155 - if (selector) { 156 - console.debug(`saving focus for ${from.fullPath} as ${selector}`) 157 - FocusMap.set(from.fullPath, selector) 158 - } 159 - } 170 + if (active && from.fullPath) { 171 + const selector = makeRestoreSelectorFor(active); 172 + if (selector) { 173 + console.debug(`saving focus for ${from.fullPath} as ${selector}`); 174 + FocusMap.set(from.fullPath, selector); 175 + } 176 + } 160 177 161 - next() 162 - }) 178 + next(); 179 + }); 163 180 164 181 router.afterEach((to) => { 165 - const ui = useUIStore() 166 - document.title = `vt3e - ${to.meta.title || to.name?.toString().toLowerCase()}` 182 + const ui = useUIStore(); 183 + document.title = `vt3e - ${to.meta.title || to.name?.toString().toLowerCase()}`; 167 184 168 - if (to.path === '/') ui.setBack() 169 - else if (to.meta.bg) ui.setForward(to.meta.bg) 185 + if (to.path === "/") ui.setBack(); 186 + else if (to.meta.bg) ui.setForward(to.meta.bg); 170 187 171 - const selector = FocusMap.get(to.fullPath) 172 - if (selector) { 173 - requestAnimationFrame(() => { 174 - if (focusElementBySelector(selector)) { 175 - console.debug(`restoring focus for ${to.fullPath} to ${selector}`) 176 - FocusMap.delete(to.fullPath) 177 - } 178 - }) 179 - } 180 - }) 188 + const selector = FocusMap.get(to.fullPath); 189 + if (selector) { 190 + requestAnimationFrame(() => { 191 + if (focusElementBySelector(selector)) { 192 + console.debug(`restoring focus for ${to.fullPath} to ${selector}`); 193 + FocusMap.delete(to.fullPath); 194 + } 195 + }); 196 + } 197 + }); 181 198 182 - export default router 199 + export default router;
+58 -3
pkgs/web/src/stores/theme.ts
··· 1 1 import { defineStore } from "pinia"; 2 - import { ref, computed, watch } from "vue"; 3 - import { useEnvironmentStore } from "./environment"; 2 + import { computed, ref, watch } from "vue"; 4 3 5 4 import KEYS from "@/utils/keys"; 5 + import { useEnvironmentStore } from "./environment"; 6 6 7 7 export interface ThemeDefinition { 8 8 id: string; ··· 177 177 const currentMode = ref<"light" | "dark">("dark"); 178 178 const preferredAccent = ref<AccentColour>(AccentColour.Mauve); 179 179 180 + const squareMode = ref(false); 181 + const dyslexicFont = ref(false); 182 + const transitionsEnabled = ref(true); 183 + 180 184 const activeTheme = computed(() => { 181 185 let targetId: string; 182 186 ··· 193 197 194 198 return themes.find((t) => t.id === targetId) || mocha; 195 199 }); 200 + 201 + function setSquareMode(val: boolean) { 202 + squareMode.value = val; 203 + } 204 + function setDyslexicFont(val: boolean) { 205 + dyslexicFont.value = val; 206 + } 207 + function setTransitionsEnabled(val: boolean) { 208 + transitionsEnabled.value = val; 209 + } 196 210 197 211 function setFollowSystem(val: boolean) { 198 212 followSystem.value = val; 199 213 } 214 + function setCurrentMode(mode: "light" | "dark") { 215 + currentMode.value = mode; 216 + } 200 217 201 218 function setPreferredLight(themeId: string) { 202 219 if (themes.find((t) => t.id === themeId && t.type === "light")) { ··· 236 253 if (metaThemeColor) { 237 254 metaThemeColor.setAttribute("content", `hsl(${theme.variables.mantle})`); 238 255 } 256 + 257 + const body = document.body; 258 + if (squareMode.value) body.classList.add("square"); 259 + else body.classList.remove("square"); 260 + 261 + if (dyslexicFont.value) body.classList.add("dyslexic"); 262 + else body.classList.remove("dyslexic"); 263 + 264 + if (transitionsEnabled.value) body.classList.remove("no-transitions"); 265 + else body.classList.add("no-transitions"); 239 266 } 240 267 241 268 function init() { 269 + const storedBoxy = localStorage.getItem(STORAGE_KEYS.BOXY_MODE); 270 + const storedDyslexic = localStorage.getItem(STORAGE_KEYS.DYSLEXIC_FONT); 271 + const storedTransitions = localStorage.getItem(STORAGE_KEYS.TRANSITIONS_ENABLED); 272 + if (storedBoxy !== null) squareMode.value = storedBoxy === "true"; 273 + if (storedDyslexic !== null) dyslexicFont.value = storedDyslexic === "true"; 274 + if (storedTransitions !== null) transitionsEnabled.value = storedTransitions === "true"; 275 + 242 276 const storedFollow = localStorage.getItem(STORAGE_KEYS.FOLLOW_SYSTEM_THEME); 243 277 if (storedFollow !== null) followSystem.value = storedFollow === "true"; 244 278 ··· 286 320 watch(preferredAccent, (val) => 287 321 localStorage.setItem(STORAGE_KEYS.ACCENT_COLOUR, val), 288 322 ); 323 + watch(squareMode, (val) => 324 + localStorage.setItem(STORAGE_KEYS.BOXY_MODE, String(val)), 325 + 326 + ); 327 + watch(dyslexicFont, (val) => 328 + localStorage.setItem(STORAGE_KEYS.DYSLEXIC_FONT, String(val)), 329 + ); 330 + watch(transitionsEnabled, (val) => { 331 + localStorage.setItem( 332 + STORAGE_KEYS.TRANSITIONS_ENABLED, 333 + String(val), 334 + ); 335 + }) 289 336 290 337 watch( 291 - [activeTheme, preferredAccent, () => env.prefersDarkScheme], 338 + [activeTheme, preferredAccent, () => env.prefersDarkScheme, squareMode, dyslexicFont, transitionsEnabled], 292 339 () => { 293 340 applyTheme(); 294 341 }, ··· 297 344 } 298 345 299 346 return { 347 + setSquareMode, 348 + setDyslexicFont, 349 + setTransitionsEnabled, 350 + squareMode, 351 + dyslexicFont, 352 + transitionsEnabled, 353 + 300 354 themes, 301 355 AccentColours, 302 356 followSystem, ··· 304 358 preferredDark, 305 359 preferredAccent, 306 360 activeTheme, 361 + setCurrentMode, 307 362 setFollowSystem, 308 363 setPreferredLight, 309 364 setPreferredDark,
+21
pkgs/web/src/styles/main.scss
··· 93 93 text-rendering: optimizeLegibility; 94 94 -webkit-font-smoothing: antialiased; 95 95 overflow-y: hidden; 96 + 97 + &.dyslexic { 98 + font-family: 99 + 'OpenDyslexic', 100 + -apple-system, 101 + BlinkMacSystemFont, 102 + 'Segoe UI', 103 + Roboto, 104 + 'Helvetica Neue', 105 + Arial, 106 + sans-serif, 107 + 'Apple Color Emoji', 108 + 'Segoe UI Emoji', 109 + 'Segoe UI Symbol'; 110 + } 111 + &.square * { 112 + border-radius: 0 !important; 113 + } 114 + &.no-transitions * { 115 + transition: none !important; 116 + } 96 117 } 97 118 98 119 ::selection {
+3
pkgs/web/src/utils/keys.ts
··· 18 18 'PREFERRED_DARK_THEME', 19 19 'CURRENT_MODE', 20 20 'ACCENT_COLOUR', 21 + 'BOXY_MODE', 22 + 'DYSLEXIC_FONT', 23 + 'TRANSITIONS_ENABLED', 21 24 ]) 22 25 } 23 26
+34 -20
pkgs/web/src/views/HomeView.vue
··· 1 1 <script setup lang="ts"> 2 2 import { computed } from 'vue' 3 3 import { useRouter } from 'vue-router' 4 - import { IconArrowOutwardRounded } from '@iconify-prerendered/vue-material-symbols' 4 + import { 5 + IconArrowOutwardRounded, 6 + IconSettingsRounded, 7 + } from '@iconify-prerendered/vue-material-symbols' 5 8 6 9 import SvgComponent from '@/components/SvgComponent.vue' 7 10 import ImageComponent from '@/components/ImageComponent.vue' ··· 42 45 </div> 43 46 44 47 <div class="profile-footer"> 45 - <div class="social-row"> 48 + <div class="row social-row"> 46 49 <a 47 50 v-for="link in socials" 48 51 :key="link.label" ··· 57 60 </a> 58 61 </div> 59 62 60 - <RouterLink to="/about" class="about-btn"> 61 - <span>more about me</span> 62 - <IconArrowOutwardRounded class="icon" /> 63 - </RouterLink> 63 + <div class="row"> 64 + <RouterLink to="/settings" class="social-pill" title="settings"> 65 + <IconSettingsRounded class="social-pill_icon" /> 66 + </RouterLink> 67 + <RouterLink to="/about" class="about-btn"> 68 + <span>more about me</span> 69 + <IconArrowOutwardRounded class="icon" /> 70 + </RouterLink> 71 + </div> 64 72 </div> 65 73 </div> 66 74 ··· 262 270 margin-top: 1rem; 263 271 } 264 272 273 + .row { 274 + display: flex; 275 + gap: 0.5rem; 276 + align-items: center; 277 + } 278 + 265 279 .social-row { 266 280 display: flex; 267 281 gap: 0.5rem; 282 + } 268 283 269 - .social-pill { 270 - display: flex; 271 - align-items: center; 272 - justify-content: center; 273 - background-clip: padding-box; 274 - width: 2.8rem; 275 - height: 2.8rem; 276 - border-radius: 50%; 277 - font-size: 1.25rem; 284 + .social-pill { 285 + display: flex; 286 + align-items: center; 287 + justify-content: center; 288 + background-clip: padding-box; 289 + width: 2.8rem; 290 + height: 2.8rem; 291 + border-radius: 50%; 292 + font-size: 1.25rem; 278 293 279 - &_icon { 280 - width: 1.5rem; 281 - height: 1.5rem; 282 - display: block; 283 - } 294 + &_icon { 295 + width: 1.5rem; 296 + height: 1.5rem; 297 + display: block; 284 298 } 285 299 } 286 300
+638
pkgs/web/src/views/SettingsView.vue
··· 1 + <script setup lang="ts"> 2 + import { IconCheckRounded } from '@iconify-prerendered/vue-material-symbols' 3 + import { computed } from 'vue' 4 + 5 + import CardLayout from '@/components/Card/CardLayout.vue' 6 + import { useThemeStore, AccentColour, themes } from '@/stores/theme' 7 + import { useEnvironmentStore } from '@/stores/environment' 8 + 9 + const themeStore = useThemeStore() 10 + const env = useEnvironmentStore() 11 + 12 + const lightThemes = computed(() => themes.filter((t) => t.type === 'light')) 13 + const darkThemes = computed(() => themes.filter((t) => t.type === 'dark')) 14 + 15 + const effectiveMode = computed(() => { 16 + if (themeStore.followSystem) return env.prefersDarkScheme ? 'dark' : 'light' 17 + return themeStore.activeTheme.type 18 + }) 19 + 20 + const accentColours = Object.values(AccentColour) 21 + </script> 22 + 23 + <template> 24 + <CardLayout title="settings"> 25 + <template #intro> 26 + <p>customise the look of this site.</p> 27 + </template> 28 + 29 + <div class="settings-sections"> 30 + <section class="settings-section"> 31 + <div class="section-header"> 32 + <div class="title-group"> 33 + <span class="badge">appearance</span> 34 + <h2 class="section-title">system theme</h2> 35 + </div> 36 + <p class="section-description"> 37 + when enabled, the theme will automatically switch between light and dark based on your 38 + system preferences. 39 + </p> 40 + </div> 41 + <div class="section-content"> 42 + <button 43 + class="toggle-button" 44 + :class="{ active: themeStore.followSystem }" 45 + @click="themeStore.setFollowSystem(!themeStore.followSystem)" 46 + :aria-pressed="themeStore.followSystem" 47 + role="switch" 48 + > 49 + <span class="toggle-track"> 50 + <span class="toggle-thumb" /> 51 + </span> 52 + <span class="toggle-label"> follow system theme </span> 53 + </button> 54 + 55 + <button 56 + class="toggle-button" 57 + :class="{ active: effectiveMode === 'dark' }" 58 + @click="() => themeStore.setCurrentMode(effectiveMode === 'light' ? 'dark' : 'light')" 59 + :aria-pressed="themeStore.activeTheme.type === 'dark'" 60 + role="switch" 61 + :disabled="themeStore.followSystem" 62 + > 63 + <span class="toggle-track"> 64 + <span class="toggle-thumb" /> 65 + </span> 66 + <span class="toggle-label">dark mode</span> 67 + </button> 68 + </div> 69 + </section> 70 + 71 + <section class="settings-section"> 72 + <div class="section-header"> 73 + <div class="title-group"> 74 + <span class="badge">appearnce</span> 75 + <h2 class="section-title">other</h2> 76 + </div> 77 + <p class="section-description"></p> 78 + </div> 79 + <div class="section-content"> 80 + <button 81 + class="toggle-button" 82 + :class="{ active: themeStore.squareMode }" 83 + @click="themeStore.setSquareMode(!themeStore.squareMode)" 84 + :aria-pressed="themeStore.squareMode" 85 + role="switch" 86 + > 87 + <span class="toggle-track"> 88 + <span class="toggle-thumb" /> 89 + </span> 90 + <span class="toggle-label"> boxy corners </span> 91 + </button> 92 + 93 + <button 94 + class="toggle-button" 95 + :class="{ active: themeStore.dyslexicFont }" 96 + @click="() => themeStore.setDyslexicFont(!themeStore.dyslexicFont)" 97 + :aria-pressed="themeStore.dyslexicFont" 98 + role="switch" 99 + > 100 + <span class="toggle-track"> 101 + <span class="toggle-thumb" /> 102 + </span> 103 + <span class="toggle-label">use open dyslexic</span> 104 + </button> 105 + 106 + <button 107 + class="toggle-button" 108 + :class="{ active: themeStore.transitionsEnabled }" 109 + @click="() => themeStore.setTransitionsEnabled(!themeStore.transitionsEnabled)" 110 + :aria-pressed="themeStore.transitionsEnabled" 111 + role="switch" 112 + > 113 + <span class="toggle-track"> 114 + <span class="toggle-thumb" /> 115 + </span> 116 + <span class="toggle-label">enable transitions</span> 117 + </button> 118 + </div> 119 + </section> 120 + 121 + <section 122 + class="settings-section" 123 + :class="{ disabled: effectiveMode !== 'light' }" 124 + :inert="effectiveMode !== 'light'" 125 + > 126 + <div class="section-header"> 127 + <div class="title-group"> 128 + <span class="badge">appearance</span> 129 + <h2 class="section-title">light theme</h2> 130 + </div> 131 + <p class="section-description">choose which theme to use in light mode.</p> 132 + </div> 133 + <div class="section-content"> 134 + <div class="theme-picker" role="radiogroup" aria-label="Light theme selection"> 135 + <button 136 + v-for="theme in lightThemes" 137 + :key="theme.id" 138 + class="theme-option" 139 + :class="{ selected: themeStore.preferredLight === theme.id }" 140 + :aria-checked="themeStore.preferredLight === theme.id" 141 + role="radio" 142 + @click="themeStore.setPreferredLight(theme.id)" 143 + > 144 + <div 145 + class="theme-preview" 146 + :style="{ 147 + '--preview-base': theme.variables.base, 148 + '--preview-surface0': theme.variables.surface0, 149 + '--preview-surface1': theme.variables.surface1, 150 + '--preview-surface2': theme.variables.surface2, 151 + '--preview-text': theme.variables.text, 152 + '--preview-accent': 153 + theme.variables[themeStore.preferredAccent] || theme.variables.mauve, 154 + '--preview-subtext0': theme.variables.subtext0, 155 + }" 156 + > 157 + <div class="preview-bar"> 158 + <div class="preview-title" /> 159 + <div class="preview-dot" /> 160 + </div> 161 + <div class="preview-body"> 162 + <div class="preview-line wide" /> 163 + <div class="preview-line medium" /> 164 + <div class="preview-line narrow" /> 165 + </div> 166 + </div> 167 + <span class="theme-name">{{ theme.name }}</span> 168 + </button> 169 + </div> 170 + </div> 171 + </section> 172 + 173 + <section 174 + class="settings-section" 175 + :class="{ disabled: effectiveMode !== 'dark' }" 176 + :inert="effectiveMode !== 'dark'" 177 + > 178 + <div class="section-header"> 179 + <div class="title-group"> 180 + <span class="badge">appearance</span> 181 + <h2 class="section-title">dark theme</h2> 182 + </div> 183 + <p class="section-description">choose which theme to use in dark mode.</p> 184 + </div> 185 + <div class="section-content"> 186 + <div class="theme-picker" role="radiogroup" aria-label="Dark theme selection"> 187 + <button 188 + v-for="theme in darkThemes" 189 + :key="theme.id" 190 + class="theme-option" 191 + :class="{ selected: themeStore.preferredDark === theme.id }" 192 + :aria-checked="themeStore.preferredDark === theme.id" 193 + role="radio" 194 + @click="themeStore.setPreferredDark(theme.id)" 195 + > 196 + <div 197 + class="theme-preview" 198 + :style="{ 199 + '--preview-base': theme.variables.base, 200 + '--preview-surface0': theme.variables.surface0, 201 + '--preview-surface1': theme.variables.surface1, 202 + '--preview-surface2': theme.variables.surface2, 203 + '--preview-text': theme.variables.text, 204 + '--preview-accent': 205 + theme.variables[themeStore.preferredAccent] || theme.variables.mauve, 206 + '--preview-subtext0': theme.variables.subtext0, 207 + }" 208 + > 209 + <div class="preview-bar"> 210 + <div class="preview-title" /> 211 + <div class="preview-dot" /> 212 + </div> 213 + <div class="preview-body"> 214 + <div class="preview-line wide" /> 215 + <div class="preview-line medium" /> 216 + <div class="preview-line narrow" /> 217 + </div> 218 + </div> 219 + <span class="theme-name">{{ theme.name }}</span> 220 + </button> 221 + </div> 222 + </div> 223 + </section> 224 + 225 + <!-- Accent Colour --> 226 + <section class="settings-section"> 227 + <div class="section-header"> 228 + <div class="title-group"> 229 + <span class="badge">appearance</span> 230 + <h2 class="section-title">accent colour</h2> 231 + </div> 232 + <p class="section-description">choose the accent colour used throughout the site.</p> 233 + </div> 234 + <div class="section-content"> 235 + <div class="accent-picker" role="radiogroup" aria-label="Accent colour selection"> 236 + <button 237 + v-for="colour in accentColours" 238 + :key="colour" 239 + class="accent-swatch" 240 + :class="{ selected: themeStore.preferredAccent === colour }" 241 + :style="{ '--swatch-colour': `var(--${colour})` }" 242 + :aria-checked="themeStore.preferredAccent === colour" 243 + :aria-label="colour" 244 + :title="colour" 245 + role="radio" 246 + @click="themeStore.setAccent(colour)" 247 + > 248 + <span class="swatch-fill" /> 249 + <span class="swatch-check" aria-hidden="true"> 250 + <IconCheckRounded /> 251 + </span> 252 + </button> 253 + </div> 254 + <p class="accent-label"> 255 + selected: <strong>{{ themeStore.preferredAccent }}</strong> 256 + </p> 257 + </div> 258 + </section> 259 + </div> 260 + </CardLayout> 261 + </template> 262 + 263 + <style lang="scss" scoped> 264 + @use '@/styles/variables.scss' as *; 265 + 266 + .settings-sections { 267 + display: flex; 268 + flex-direction: column; 269 + gap: 0.25rem; 270 + } 271 + 272 + .settings-section { 273 + display: grid; 274 + grid-template-columns: 40% 60%; 275 + overflow: hidden; 276 + background-color: hsla(var(--surface0) / 1); 277 + padding: 0.75rem; 278 + gap: 1rem; 279 + border-radius: 0.5rem; 280 + 281 + &:first-child { 282 + border-radius: 1rem 1rem 0.5rem 0.5rem; 283 + } 284 + &:last-child { 285 + border-radius: 0.5rem 0.5rem 1rem 1rem; 286 + } 287 + 288 + &.disabled { 289 + opacity: 0.5; 290 + pointer-events: none; 291 + } 292 + 293 + .section-header { 294 + .title-group { 295 + display: flex; 296 + flex-direction: column; 297 + 298 + span.badge { 299 + align-self: flex-start; 300 + background-color: hsla(var(--accent) / 0.1); 301 + color: hsl(var(--accent)); 302 + padding: 0.25rem 0.5rem; 303 + font-size: 0.75rem; 304 + font-weight: 700; 305 + border-radius: 5rem; 306 + user-select: none; 307 + } 308 + 309 + h2.section-title { 310 + font-size: 1.5rem; 311 + font-weight: 900; 312 + } 313 + } 314 + .section-description { 315 + font-size: 0.8rem; 316 + color: hsl(var(--subtext1)); 317 + 318 + .active-indicator { 319 + display: inline-block; 320 + font-size: 0.75rem; 321 + font-weight: 700; 322 + color: hsl(var(--green)); 323 + background: hsla(var(--green) / 0.1); 324 + padding: 0.1rem 0.5rem; 325 + border-radius: 5rem; 326 + margin-left: 0.25rem; 327 + vertical-align: middle; 328 + } 329 + } 330 + } 331 + 332 + .section-content { 333 + display: flex; 334 + flex-direction: column; 335 + justify-content: flex-end; 336 + gap: 0.5rem; 337 + align-items: flex-start; 338 + } 339 + 340 + @media (max-width: 800px) { 341 + grid-template-columns: 1fr; 342 + gap: 0.5rem; 343 + } 344 + } 345 + 346 + .toggle-button { 347 + display: inline-flex; 348 + align-items: center; 349 + gap: 0.75rem; 350 + background: none; 351 + border: none; 352 + cursor: pointer; 353 + font-family: inherit; 354 + font-size: 1rem; 355 + color: hsl(var(--text)); 356 + border-radius: 0.75rem; 357 + outline: none; 358 + 359 + &:disabled { 360 + opacity: 0.5; 361 + cursor: not-allowed; 362 + } 363 + 364 + &:hover .toggle-track { 365 + box-shadow: 0 0 0 0.2rem hsla(var(--accent) / 0.3); 366 + } 367 + 368 + &:focus-visible .toggle-track { 369 + box-shadow: 0 0 0 0.2rem hsla(var(--accent) / 1); 370 + } 371 + 372 + .toggle-track { 373 + position: relative; 374 + width: 3rem; 375 + height: 1.75rem; 376 + background: hsla(var(--surface2) / 0.8); 377 + border-radius: 1rem; 378 + flex-shrink: 0; 379 + 380 + transition-timing-function: $ease-spring; 381 + transition-duration: 0.3s; 382 + } 383 + 384 + .toggle-thumb { 385 + position: absolute; 386 + top: 0.2rem; 387 + left: 0.2rem; 388 + width: 1.35rem; 389 + height: 1.35rem; 390 + background: hsl(var(--base)); 391 + border-radius: 50%; 392 + box-shadow: 0 1px 3px hsla(var(--crust) / 0.2); 393 + 394 + transition-timing-function: $ease-spring; 395 + transition-duration: 0.3s; 396 + } 397 + 398 + .toggle-label { 399 + font-weight: 600; 400 + color: hsl(var(--subtext0)); 401 + user-select: none; 402 + } 403 + 404 + &.active { 405 + .toggle-track { 406 + background: hsl(var(--accent)); 407 + } 408 + .toggle-thumb { 409 + transform: translateX(1.25rem); 410 + } 411 + .toggle-label { 412 + color: hsl(var(--text)); 413 + } 414 + } 415 + } 416 + 417 + .current-hint { 418 + font-size: 0.875rem; 419 + color: hsl(var(--subtext0)); 420 + padding-left: 0.5rem; 421 + 422 + strong { 423 + font-weight: 700; 424 + color: hsl(var(--text)); 425 + } 426 + } 427 + 428 + .theme-picker { 429 + display: flex; 430 + flex-wrap: wrap; 431 + gap: 1rem; 432 + 433 + .theme-option { 434 + display: flex; 435 + flex-direction: column; 436 + align-items: center; 437 + gap: 0.5rem; 438 + padding: 0.4rem; 439 + background: none; 440 + border: none; 441 + cursor: pointer; 442 + font-family: inherit; 443 + border-radius: 1rem; 444 + outline: none; 445 + 446 + box-shadow: 0 0 0 0.15rem transparent; 447 + transition-timing-function: $ease-spring; 448 + transition-duration: 0.3s; 449 + 450 + box-shadow: 0 0 0 0.2rem hsla(var(--accent) / 0.05); 451 + 452 + &:hover, 453 + &:focus-visible { 454 + box-shadow: 0 0 0 0.2rem hsla(var(--accent) / 0.2); 455 + background: hsla(var(--overlay1) / 0.2); 456 + } 457 + 458 + &:active, 459 + &.active { 460 + transform: scale(0.95); 461 + } 462 + 463 + &.selected { 464 + box-shadow: 0 0 0 0.2rem hsla(var(--accent) / 1); 465 + background: hsla(var(--accent) / 0.1); 466 + 467 + .theme-name { 468 + color: hsl(var(--accent)); 469 + font-weight: 800; 470 + } 471 + } 472 + 473 + .theme-name { 474 + font-size: 0.8rem; 475 + font-weight: 600; 476 + color: hsl(var(--subtext0)); 477 + } 478 + } 479 + 480 + .theme-preview { 481 + width: 7rem; 482 + height: 5rem; 483 + border-radius: 0.6rem; 484 + overflow: hidden; 485 + background: hsl(var(--preview-base)); 486 + display: flex; 487 + flex-direction: column; 488 + 489 + .preview-bar { 490 + display: flex; 491 + align-items: center; 492 + justify-content: space-between; 493 + gap: 0.2rem; 494 + padding: 0.3rem 0.4rem; 495 + background: hsl(var(--preview-surface0)); 496 + 497 + .preview-title { 498 + width: 50%; 499 + height: 0.4rem; 500 + border-radius: 0.2rem; 501 + background: hsl(var(--preview-accent)); 502 + } 503 + .preview-dot { 504 + width: 0.35rem; 505 + height: 0.35rem; 506 + border-radius: 50%; 507 + background: hsl(var(--preview-surface2)); 508 + 509 + &:first-child { 510 + background: hsl(var(--preview-accent)); 511 + } 512 + } 513 + } 514 + 515 + .preview-body { 516 + flex: 1; 517 + padding: 0.35rem 0.4rem; 518 + display: flex; 519 + flex-direction: column; 520 + gap: 0.2rem; 521 + 522 + .preview-line { 523 + height: 0.3rem; 524 + border-radius: 0.15rem; 525 + background: hsl(var(--preview-subtext0)); 526 + 527 + &.wide { 528 + width: 80%; 529 + height: 0.35rem; 530 + } 531 + &.medium { 532 + width: 60%; 533 + background: hsl(var(--preview-subtext0)); 534 + } 535 + &.narrow { 536 + width: 40%; 537 + } 538 + } 539 + } 540 + } 541 + } 542 + 543 + .accent-picker { 544 + display: flex; 545 + flex-wrap: wrap; 546 + gap: 0.4rem; 547 + 548 + .accent-swatch { 549 + position: relative; 550 + width: 2.5rem; 551 + height: 2.5rem; 552 + border: none; 553 + border-radius: 50%; 554 + cursor: pointer; 555 + padding: 0; 556 + background: none; 557 + outline: none; 558 + 559 + box-shadow: 0 0 0 0.15rem transparent; 560 + transition-timing-function: $ease-spring; 561 + transition-duration: 0.3s; 562 + 563 + .swatch-fill { 564 + display: block; 565 + width: 100%; 566 + height: 100%; 567 + border-radius: 50%; 568 + background: hsl(var(--swatch-colour)); 569 + 570 + transition-timing-function: $ease-spring; 571 + transition-duration: 0.3s; 572 + } 573 + 574 + .swatch-check { 575 + position: absolute; 576 + inset: 0; 577 + display: flex; 578 + align-items: center; 579 + justify-content: center; 580 + font-size: 1rem; 581 + font-weight: 900; 582 + color: hsl(var(--crust)); 583 + opacity: 0; 584 + transform: scale(0.5); 585 + 586 + transition-timing-function: $ease-spring; 587 + transition-duration: 0.3s; 588 + 589 + pointer-events: none; 590 + } 591 + 592 + &:hover { 593 + box-shadow: 0 0 0 0.2rem hsla(var(--swatch-colour) / 0.5); 594 + 595 + .swatch-fill { 596 + transform: scale(0.85); 597 + } 598 + } 599 + 600 + &:focus-visible { 601 + box-shadow: 0 0 0 0.25rem hsla(var(--swatch-colour) / 1); 602 + 603 + .swatch-fill { 604 + transform: scale(0.85); 605 + } 606 + } 607 + 608 + &:active, 609 + &.active { 610 + transform: scale(0.9); 611 + } 612 + 613 + &.selected { 614 + box-shadow: 0 0 0 0.2rem hsla(var(--swatch-colour) / 1); 615 + 616 + .swatch-fill { 617 + transform: scale(0.75); 618 + } 619 + 620 + .swatch-check { 621 + opacity: 1; 622 + transform: scale(1); 623 + } 624 + } 625 + } 626 + } 627 + 628 + .accent-label { 629 + font-size: 0.875rem; 630 + color: hsl(var(--subtext0)); 631 + padding-left: 0.25rem; 632 + 633 + strong { 634 + font-weight: 700; 635 + color: hsl(var(--accent)); 636 + } 637 + } 638 + </style>