a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: settings screen

+603 -219
+2
src/app/router/index.ts
··· 44 44 }, 45 45 { path: "activity/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") }, 46 46 { path: "profile", component: () => import("@/features/profile/ProfilePage.vue") }, 47 + { path: "profile/settings", redirect: "/tabs/settings" }, 48 + { path: "settings", component: () => import("@/features/profile/SettingsPage.vue") }, 47 49 ], 48 50 }, 49 51 ];
+211 -187
src/components/repo/MarkdownRenderer.vue
··· 3 3 </template> 4 4 5 5 <script setup lang="ts"> 6 - import { onBeforeUnmount, ref, watch } from "vue"; 7 - import markdownit from "markdown-it"; 8 - import { fromHighlighter } from "@shikijs/markdown-it"; 9 - import { getHighlighter } from "@/lib/syntax.js"; 10 - import { sanitizeRichHtml } from "@/lib/html.js"; 11 - import { resolveRepoImageUrl, type RepoAssetContext } from "@/services/tangled/repo-assets.js"; 6 + import { onBeforeUnmount, ref, watch } from "vue"; 7 + import markdownit from "markdown-it"; 8 + import { fromHighlighter } from "@shikijs/markdown-it"; 9 + import { getHighlighter } from "@/lib/syntax.js"; 10 + import { sanitizeRichHtml } from "@/lib/html.js"; 11 + import { resolveRepoImageUrl, type RepoAssetContext } from "@/services/tangled/repo-assets.js"; 12 12 13 - const props = defineProps<{ content: string; repoContext?: RepoAssetContext }>(); 13 + const props = defineProps<{ content: string; repoContext?: RepoAssetContext }>(); 14 14 15 - let mdPromise: Promise<ReturnType<typeof markdownit>> | null = null; 16 - let activeObjectUrls: string[] = []; 15 + let mdPromise: Promise<ReturnType<typeof markdownit>> | null = null; 16 + let activeObjectUrls: string[] = []; 17 17 18 - // Languages commonly found in README code blocks — load upfront so aliases (e.g. "sh") resolve. 19 - const PRELOAD_LANGS = [ 20 - "bash", "javascript", "typescript", "python", "json", "yaml", 21 - "html", "css", "rust", "go", "sql", "markdown", "tsx", "jsx", 22 - ] as const; 18 + const PRELOAD_LANGS = [ 19 + "bash", 20 + "javascript", 21 + "typescript", 22 + "python", 23 + "json", 24 + "yaml", 25 + "html", 26 + "css", 27 + "rust", 28 + "go", 29 + "sql", 30 + "markdown", 31 + "tsx", 32 + "jsx", 33 + ] as const; 23 34 24 - async function getMd() { 25 - if (!mdPromise) { 26 - mdPromise = (async () => { 27 - const md = markdownit({ html: true, linkify: true, typographer: true }); 28 - const hl = await getHighlighter(); 29 - await Promise.all(PRELOAD_LANGS.map((l) => hl.loadLanguage(l).catch(() => null))); 30 - md.use(fromHighlighter(hl, { 31 - themes: { light: "github-light", dark: "github-dark" }, 32 - })); 33 - return md; 34 - })(); 35 + async function getMd() { 36 + if (!mdPromise) { 37 + mdPromise = (async () => { 38 + const md = markdownit({ html: true, linkify: true, typographer: true }); 39 + const hl = await getHighlighter(); 40 + await Promise.all(PRELOAD_LANGS.map((l) => hl.loadLanguage(l).catch(() => null))); 41 + md.use(fromHighlighter(hl, { themes: { light: "github-light", dark: "github-dark" } })); 42 + return md; 43 + })(); 44 + } 45 + return mdPromise; 35 46 } 36 - return mdPromise; 37 - } 38 47 39 - const renderedHtml = ref(""); 48 + const renderedHtml = ref(""); 40 49 41 - function revokeObjectUrls(urls: string[] = activeObjectUrls) { 42 - for (const url of urls) { 43 - URL.revokeObjectURL(url); 44 - } 50 + function revokeObjectUrls(urls: string[] = activeObjectUrls) { 51 + for (const url of urls) { 52 + URL.revokeObjectURL(url); 53 + } 45 54 46 - if (urls === activeObjectUrls) { 47 - activeObjectUrls = []; 55 + if (urls === activeObjectUrls) { 56 + activeObjectUrls = []; 57 + } 48 58 } 49 - } 50 59 51 - async function resolveImageSources(html: string, repoContext?: RepoAssetContext): Promise<{ html: string; objectUrls: string[] }> { 52 - if (!repoContext) return { html, objectUrls: [] }; 60 + async function resolveImageSources( 61 + html: string, 62 + repoContext?: RepoAssetContext, 63 + ): Promise<{ html: string; objectUrls: string[] }> { 64 + if (!repoContext) return { html, objectUrls: [] }; 53 65 54 - const parser = new DOMParser(); 55 - const doc = parser.parseFromString(`<body>${html}</body>`, "text/html"); 56 - const objectUrls: string[] = []; 57 - const images = Array.from(doc.body.querySelectorAll("img[src]")); 66 + const parser = new DOMParser(); 67 + const doc = parser.parseFromString(`<body>${html}</body>`, "text/html"); 68 + const objectUrls: string[] = []; 69 + const images = Array.from(doc.body.querySelectorAll("img[src]")); 58 70 59 - await Promise.all(images.map(async (image) => { 60 - const src = image.getAttribute("src")?.trim(); 61 - if (!src) return; 71 + await Promise.all( 72 + images.map(async (image) => { 73 + const src = image.getAttribute("src")?.trim(); 74 + if (!src) return; 62 75 63 - const resolved = await resolveRepoImageUrl(repoContext, src); 64 - if (!resolved) return; 76 + const resolved = await resolveRepoImageUrl(repoContext, src); 77 + if (!resolved) return; 65 78 66 - image.setAttribute("src", resolved.url); 67 - if (resolved.revoke) objectUrls.push(resolved.url); 68 - })); 79 + image.setAttribute("src", resolved.url); 80 + if (resolved.revoke) objectUrls.push(resolved.url); 81 + }), 82 + ); 69 83 70 - return { html: doc.body.innerHTML, objectUrls }; 71 - } 84 + return { html: doc.body.innerHTML, objectUrls }; 85 + } 72 86 73 - watch( 74 - () => [props.content, props.repoContext] as const, 75 - async ([content, repoContext], _, onCleanup) => { 76 - let cancelled = false; 77 - const previousUrls = activeObjectUrls; 78 - const nextUrls: string[] = []; 87 + watch( 88 + () => [props.content, props.repoContext] as const, 89 + async ([content, repoContext], _, onCleanup) => { 90 + let cancelled = false; 91 + const previousUrls = activeObjectUrls; 92 + const nextUrls: string[] = []; 79 93 80 - onCleanup(() => { 81 - cancelled = true; 82 - revokeObjectUrls(nextUrls); 83 - }); 94 + onCleanup(() => { 95 + cancelled = true; 96 + revokeObjectUrls(nextUrls); 97 + }); 84 98 85 - const md = await getMd(); 86 - const raw = md.render(content); 87 - const sanitized = sanitizeRichHtml(raw); 88 - const resolved = await resolveImageSources(sanitized, repoContext); 89 - nextUrls.push(...resolved.objectUrls); 99 + const md = await getMd(); 100 + const raw = md.render(content); 101 + const sanitized = sanitizeRichHtml(raw); 102 + const resolved = await resolveImageSources(sanitized, repoContext); 103 + nextUrls.push(...resolved.objectUrls); 90 104 91 - if (cancelled) return; 105 + if (cancelled) return; 92 106 93 - activeObjectUrls = nextUrls; 94 - revokeObjectUrls(previousUrls); 95 - renderedHtml.value = resolved.html; 96 - }, 97 - { immediate: true }, 98 - ); 107 + activeObjectUrls = nextUrls; 108 + revokeObjectUrls(previousUrls); 109 + renderedHtml.value = resolved.html; 110 + }, 111 + { immediate: true }, 112 + ); 99 113 100 - onBeforeUnmount(() => { 101 - revokeObjectUrls(); 102 - }); 114 + onBeforeUnmount(() => { 115 + revokeObjectUrls(); 116 + }); 103 117 </script> 104 118 105 119 <style scoped> 106 - .markdown-body { 107 - padding: 0 16px 24px; 108 - color: var(--t-text-primary); 109 - font-size: 14px; 110 - line-height: 1.6; 111 - word-break: break-word; 112 - } 120 + .markdown-body { 121 + padding: 0 16px 24px; 122 + color: var(--t-text-primary); 123 + font-size: 14px; 124 + line-height: 1.6; 125 + word-break: break-word; 126 + } 113 127 114 - .markdown-body :deep(h1), 115 - .markdown-body :deep(h2), 116 - .markdown-body :deep(h3), 117 - .markdown-body :deep(h4), 118 - .markdown-body :deep(h5), 119 - .markdown-body :deep(h6) { 120 - font-weight: 600; 121 - line-height: 1.25; 122 - margin: 24px 0 16px; 123 - color: var(--t-text-primary); 124 - } 125 - 126 - .markdown-body :deep(h1) { font-size: 2em; border-bottom: 1px solid var(--t-border); padding-bottom: 0.3em; } 127 - .markdown-body :deep(h2) { font-size: 1.5em; border-bottom: 1px solid var(--t-border); padding-bottom: 0.3em; } 128 - .markdown-body :deep(h3) { font-size: 1.25em; } 129 - 130 - .markdown-body :deep(p) { 131 - margin: 0 0 16px; 132 - } 128 + .markdown-body :deep(h1), 129 + .markdown-body :deep(h2), 130 + .markdown-body :deep(h3), 131 + .markdown-body :deep(h4), 132 + .markdown-body :deep(h5), 133 + .markdown-body :deep(h6) { 134 + font-weight: 600; 135 + line-height: 1.25; 136 + margin: 24px 0 16px; 137 + color: var(--t-text-primary); 138 + } 133 139 134 - .markdown-body :deep(a) { 135 - color: var(--t-accent); 136 - text-decoration: none; 137 - } 140 + .markdown-body :deep(h1) { 141 + font-size: 2em; 142 + border-bottom: 1px solid var(--t-border); 143 + padding-bottom: 0.3em; 144 + } 145 + .markdown-body :deep(h2) { 146 + font-size: 1.5em; 147 + border-bottom: 1px solid var(--t-border); 148 + padding-bottom: 0.3em; 149 + } 150 + .markdown-body :deep(h3) { 151 + font-size: 1.25em; 152 + } 138 153 139 - .markdown-body :deep(a:hover) { 140 - text-decoration: underline; 141 - } 154 + .markdown-body :deep(p) { 155 + margin: 0 0 16px; 156 + } 142 157 143 - .markdown-body :deep(code) { 144 - font-family: var(--t-mono); 145 - font-size: 0.875em; 146 - background: var(--t-surface-raised); 147 - border: 1px solid var(--t-border); 148 - border-radius: 4px; 149 - padding: 0.1em 0.4em; 150 - } 158 + .markdown-body :deep(a) { 159 + color: var(--t-accent); 160 + text-decoration: none; 161 + } 151 162 152 - .markdown-body :deep(pre) { 153 - margin: 0 0 16px; 154 - border-radius: var(--t-radius-md); 155 - border: 1px solid var(--t-border); 156 - overflow-x: auto; 157 - } 163 + .markdown-body :deep(a:hover) { 164 + text-decoration: underline; 165 + } 158 166 159 - .markdown-body :deep(pre code) { 160 - background: transparent; 161 - border: none; 162 - padding: 0; 163 - font-size: 12px; 164 - } 167 + .markdown-body :deep(code) { 168 + font-family: var(--t-mono); 169 + font-size: 0.875em; 170 + background: var(--t-surface-raised); 171 + border: 1px solid var(--t-border); 172 + border-radius: 4px; 173 + padding: 0.1em 0.4em; 174 + } 165 175 166 - .markdown-body :deep(.shiki) { 167 - padding: 16px; 168 - background: var(--t-surface-raised) !important; 169 - font-family: var(--t-mono); 170 - font-size: 12px; 171 - line-height: 1.6; 172 - tab-size: 2; 173 - } 176 + .markdown-body :deep(pre) { 177 + margin: 0 0 16px; 178 + border-radius: var(--t-radius-md); 179 + border: 1px solid var(--t-border); 180 + overflow-x: auto; 181 + } 174 182 175 - .markdown-body :deep(.shiki span) { 176 - color: var(--shiki-light); 177 - } 183 + .markdown-body :deep(pre code) { 184 + background: transparent; 185 + border: none; 186 + padding: 0; 187 + font-size: 12px; 188 + } 178 189 179 - @media (prefers-color-scheme: dark) { 180 190 .markdown-body :deep(.shiki) { 191 + padding: 16px; 181 192 background: var(--t-surface-raised) !important; 193 + font-family: var(--t-mono); 194 + font-size: 12px; 195 + line-height: 1.6; 196 + tab-size: 2; 182 197 } 183 198 184 199 .markdown-body :deep(.shiki span) { 185 - color: var(--shiki-dark); 200 + color: var(--shiki-light); 201 + } 202 + 203 + @media (prefers-color-scheme: dark) { 204 + .markdown-body :deep(.shiki) { 205 + background: var(--t-surface-raised) !important; 206 + } 207 + 208 + .markdown-body :deep(.shiki span) { 209 + color: var(--shiki-dark); 210 + } 186 211 } 187 - } 188 212 189 - .markdown-body :deep(blockquote) { 190 - margin: 0 0 16px; 191 - padding: 0 16px; 192 - border-left: 4px solid var(--t-border); 193 - color: var(--t-text-secondary); 194 - } 213 + .markdown-body :deep(blockquote) { 214 + margin: 0 0 16px; 215 + padding: 0 16px; 216 + border-left: 4px solid var(--t-border); 217 + color: var(--t-text-secondary); 218 + } 195 219 196 - .markdown-body :deep(ul), 197 - .markdown-body :deep(ol) { 198 - margin: 0 0 16px; 199 - padding-left: 2em; 200 - } 220 + .markdown-body :deep(ul), 221 + .markdown-body :deep(ol) { 222 + margin: 0 0 16px; 223 + padding-left: 2em; 224 + } 201 225 202 - .markdown-body :deep(li) { 203 - margin: 4px 0; 204 - } 226 + .markdown-body :deep(li) { 227 + margin: 4px 0; 228 + } 205 229 206 - .markdown-body :deep(table) { 207 - width: 100%; 208 - border-collapse: collapse; 209 - margin: 0 0 16px; 210 - font-size: 13px; 211 - overflow-x: auto; 212 - display: block; 213 - } 230 + .markdown-body :deep(table) { 231 + width: 100%; 232 + border-collapse: collapse; 233 + margin: 0 0 16px; 234 + font-size: 13px; 235 + overflow-x: auto; 236 + display: block; 237 + } 214 238 215 - .markdown-body :deep(th), 216 - .markdown-body :deep(td) { 217 - padding: 6px 12px; 218 - border: 1px solid var(--t-border); 219 - text-align: left; 220 - } 239 + .markdown-body :deep(th), 240 + .markdown-body :deep(td) { 241 + padding: 6px 12px; 242 + border: 1px solid var(--t-border); 243 + text-align: left; 244 + } 221 245 222 - .markdown-body :deep(th) { 223 - background: var(--t-surface-raised); 224 - font-weight: 600; 225 - } 246 + .markdown-body :deep(th) { 247 + background: var(--t-surface-raised); 248 + font-weight: 600; 249 + } 226 250 227 - .markdown-body :deep(tr:nth-child(even) td) { 228 - background: var(--t-surface-raised); 229 - } 251 + .markdown-body :deep(tr:nth-child(even) td) { 252 + background: var(--t-surface-raised); 253 + } 230 254 231 - .markdown-body :deep(img) { 232 - max-width: 100%; 233 - border-radius: var(--t-radius-md); 234 - } 255 + .markdown-body :deep(img) { 256 + max-width: 100%; 257 + border-radius: var(--t-radius-md); 258 + } 235 259 236 - .markdown-body :deep(hr) { 237 - border: none; 238 - border-top: 1px solid var(--t-border); 239 - margin: 24px 0; 240 - } 260 + .markdown-body :deep(hr) { 261 + border: none; 262 + border-top: 1px solid var(--t-border); 263 + margin: 24px 0; 264 + } 241 265 </style>
+8
src/core/query/cache.ts
··· 1 + import { queryClient } from "./client.js"; 2 + import { createIdbPersister } from "./persister.js"; 3 + 4 + export async function clearAppCache() { 5 + await queryClient.cancelQueries(); 6 + queryClient.clear(); 7 + await createIdbPersister().removeClient(); 8 + }
+87
src/core/theme/preferences.ts
··· 1 + import { computed, readonly, ref } from "vue"; 2 + 3 + export type ThemePreference = "system" | "light" | "dark"; 4 + 5 + type ResolvedTheme = "light" | "dark"; 6 + 7 + const STORAGE_KEY = "twisted-theme-preference"; 8 + const DARK_CLASS = "ion-palette-dark"; 9 + const SYSTEM_DARK_QUERY = "(prefers-color-scheme: dark)"; 10 + 11 + const themePreference = ref<ThemePreference>(readStoredThemePreference()); 12 + const resolvedTheme = ref<ResolvedTheme>(resolveThemePreference(themePreference.value)); 13 + 14 + let hasRegisteredMediaListener = false; 15 + 16 + export function useThemePreference() { 17 + return { 18 + themePreference: readonly(themePreference), 19 + resolvedTheme: readonly(resolvedTheme), 20 + isSystemTheme: computed(() => themePreference.value === "system"), 21 + setThemePreference, 22 + }; 23 + } 24 + 25 + export function initializeThemePreference() { 26 + applyResolvedTheme(resolveThemePreference(themePreference.value)); 27 + 28 + if (hasRegisteredMediaListener || typeof window === "undefined" || typeof window.matchMedia !== "function") { 29 + return; 30 + } 31 + 32 + const mediaQuery = window.matchMedia(SYSTEM_DARK_QUERY); 33 + const handleChange = () => { 34 + if (themePreference.value !== "system") return; 35 + applyResolvedTheme(mediaQuery.matches ? "dark" : "light"); 36 + }; 37 + 38 + if (typeof mediaQuery.addEventListener === "function") { 39 + mediaQuery.addEventListener("change", handleChange); 40 + } else if (typeof (mediaQuery as MediaQueryList & { addListener?: typeof handleChange }).addListener === "function") { 41 + (mediaQuery as MediaQueryList & { addListener?: typeof handleChange }).addListener?.(handleChange); 42 + } 43 + 44 + hasRegisteredMediaListener = true; 45 + } 46 + 47 + export function setThemePreference(value: ThemePreference) { 48 + themePreference.value = value; 49 + resolvedTheme.value = resolveThemePreference(value); 50 + applyResolvedTheme(resolvedTheme.value); 51 + 52 + if (typeof window !== "undefined") { 53 + window.localStorage.setItem(STORAGE_KEY, value); 54 + } 55 + } 56 + 57 + function readStoredThemePreference(): ThemePreference { 58 + if (typeof window === "undefined") return "system"; 59 + 60 + const storedValue = window.localStorage.getItem(STORAGE_KEY); 61 + return isThemePreference(storedValue) ? storedValue : "system"; 62 + } 63 + 64 + function resolveThemePreference(value: ThemePreference): ResolvedTheme { 65 + if (value === "dark" || value === "light") return value; 66 + 67 + if (typeof window !== "undefined" && typeof window.matchMedia === "function") { 68 + return window.matchMedia(SYSTEM_DARK_QUERY).matches ? "dark" : "light"; 69 + } 70 + 71 + return "light"; 72 + } 73 + 74 + function applyResolvedTheme(value: ResolvedTheme) { 75 + resolvedTheme.value = value; 76 + 77 + if (typeof document === "undefined") return; 78 + 79 + const root = document.documentElement; 80 + root.classList.toggle(DARK_CLASS, value === "dark"); 81 + root.dataset.theme = value; 82 + root.style.colorScheme = value; 83 + } 84 + 85 + function isThemePreference(value: string | null): value is ThemePreference { 86 + return value === "system" || value === "light" || value === "dark"; 87 + }
+262
src/features/profile/SettingsPage.vue
··· 1 + <template> 2 + <ion-page> 3 + <ion-header :translucent="true"> 4 + <ion-toolbar> 5 + <ion-title>Settings</ion-title> 6 + </ion-toolbar> 7 + </ion-header> 8 + 9 + <ion-content :fullscreen="true"> 10 + <ion-header collapse="condense"> 11 + <ion-toolbar> 12 + <ion-title size="large">Settings</ion-title> 13 + </ion-toolbar> 14 + </ion-header> 15 + 16 + <section class="hero"> 17 + <p class="eyebrow">Display & Storage</p> 18 + <h1 class="hero-title">Tune how Twisted looks and how much local data it keeps around.</h1> 19 + <p class="hero-copy"> 20 + Theme changes apply immediately. Clearing the cache removes saved query data from this device and forces a 21 + fresh fetch the next time those screens load. 22 + </p> 23 + </section> 24 + 25 + <section class="settings-card"> 26 + <div class="section-head"> 27 + <div> 28 + <p class="section-label">Theme</p> 29 + <h2 class="section-title">Appearance</h2> 30 + </div> 31 + <div class="theme-chip">{{ resolvedThemeLabel }}</div> 32 + </div> 33 + 34 + <ion-segment :value="themePreference" class="theme-segment" @ionChange="handleThemeChange"> 35 + <ion-segment-button value="system"> 36 + <ion-label>System</ion-label> 37 + </ion-segment-button> 38 + <ion-segment-button value="light"> 39 + <ion-label>Light</ion-label> 40 + </ion-segment-button> 41 + <ion-segment-button value="dark"> 42 + <ion-label>Dark</ion-label> 43 + </ion-segment-button> 44 + </ion-segment> 45 + 46 + <p class="helper-copy">{{ themeHelperCopy }}</p> 47 + </section> 48 + 49 + <section class="settings-card danger-card"> 50 + <div class="section-head"> 51 + <div> 52 + <p class="section-label">Cache</p> 53 + <h2 class="section-title">Local data</h2> 54 + </div> 55 + <ion-icon class="danger-icon" :icon="trashOutline" /> 56 + </div> 57 + 58 + <p class="helper-copy"> 59 + This clears the persisted TanStack Query cache for Twisted on this device. Your account and app code stay 60 + untouched. 61 + </p> 62 + 63 + <ion-button 64 + class="danger-button" 65 + color="danger" 66 + expand="block" 67 + @click="showClearConfirm = true" 68 + :disabled="isClearing"> 69 + {{ isClearing ? "Clearing cache..." : "Clear cache" }} 70 + </ion-button> 71 + </section> 72 + 73 + <ion-alert 74 + :is-open="showClearConfirm" 75 + header="Clear local cache?" 76 + message="Saved repo, profile, and activity query data will be removed from this device." 77 + :buttons="alertButtons" 78 + @didDismiss="showClearConfirm = false" /> 79 + 80 + <ion-toast :is-open="isToastOpen" :message="toastMessage" :duration="2200" @didDismiss="isToastOpen = false" /> 81 + </ion-content> 82 + </ion-page> 83 + </template> 84 + 85 + <script setup lang="ts"> 86 + import { computed, ref } from "vue"; 87 + import { 88 + IonPage, 89 + IonHeader, 90 + IonToolbar, 91 + IonTitle, 92 + IonContent, 93 + IonSegment, 94 + IonSegmentButton, 95 + IonLabel, 96 + IonButton, 97 + IonIcon, 98 + IonAlert, 99 + IonToast, 100 + } from "@ionic/vue"; 101 + import { trashOutline } from "ionicons/icons"; 102 + import { clearAppCache } from "@/core/query/cache.js"; 103 + import { setThemePreference, useThemePreference } from "@/core/theme/preferences.js"; 104 + import type { ThemePreference } from "@/core/theme/preferences.js"; 105 + 106 + const { themePreference, resolvedTheme, isSystemTheme } = useThemePreference(); 107 + 108 + const showClearConfirm = ref(false); 109 + const isClearing = ref(false); 110 + const isToastOpen = ref(false); 111 + const toastMessage = ref(""); 112 + 113 + const resolvedThemeLabel = computed(() => `${resolvedTheme.value === "dark" ? "Dark" : "Light"} active`); 114 + const themeHelperCopy = computed(() => { 115 + if (isSystemTheme.value) { 116 + return `Following your device right now. Twisted is currently rendering in ${resolvedTheme.value} mode.`; 117 + } 118 + 119 + return `Twisted is locked to the ${resolvedTheme.value} theme until you switch it again.`; 120 + }); 121 + 122 + const alertButtons = [ 123 + { text: "Cancel", role: "cancel" }, 124 + { 125 + text: "Clear", 126 + role: "destructive", 127 + handler: () => { 128 + void handleClearCache(); 129 + }, 130 + }, 131 + ]; 132 + 133 + function handleThemeChange(event: CustomEvent<{ value?: string | number }>) { 134 + const nextValue = event.detail.value; 135 + if (!isThemePreference(nextValue)) return; 136 + setThemePreference(nextValue); 137 + } 138 + 139 + async function handleClearCache() { 140 + showClearConfirm.value = false; 141 + if (isClearing.value) return; 142 + 143 + isClearing.value = true; 144 + 145 + try { 146 + await clearAppCache(); 147 + toastMessage.value = "Cache cleared."; 148 + } catch (error) { 149 + toastMessage.value = error instanceof Error ? error.message : "Could not clear the cache."; 150 + } finally { 151 + isClearing.value = false; 152 + isToastOpen.value = true; 153 + } 154 + } 155 + 156 + function isThemePreference(value: unknown): value is ThemePreference { 157 + return value === "system" || value === "light" || value === "dark"; 158 + } 159 + </script> 160 + 161 + <style scoped> 162 + .hero { 163 + padding: 24px 20px 12px; 164 + } 165 + 166 + .eyebrow { 167 + margin: 0 0 10px; 168 + font-size: 12px; 169 + font-weight: 700; 170 + letter-spacing: 0.08em; 171 + text-transform: uppercase; 172 + color: var(--t-accent); 173 + } 174 + 175 + .hero-title { 176 + margin: 0; 177 + font-size: 28px; 178 + line-height: 1.15; 179 + color: var(--t-text-primary); 180 + } 181 + 182 + .hero-copy { 183 + margin: 12px 0 0; 184 + font-size: 14px; 185 + line-height: 1.6; 186 + color: var(--t-text-secondary); 187 + max-width: 38rem; 188 + } 189 + 190 + .settings-card { 191 + margin: 0 16px 16px; 192 + padding: 18px 16px 16px; 193 + border: 1px solid var(--t-border); 194 + border-radius: var(--t-radius-lg); 195 + background: 196 + radial-gradient(circle at top right, var(--t-accent-dim), transparent 42%), 197 + linear-gradient(180deg, var(--t-surface-raised), var(--t-surface)); 198 + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.08); 199 + } 200 + 201 + .danger-card { 202 + background: 203 + radial-gradient(circle at top right, var(--t-red-dim), transparent 42%), 204 + linear-gradient(180deg, var(--t-surface-raised), var(--t-surface)); 205 + } 206 + 207 + .section-head { 208 + display: flex; 209 + align-items: flex-start; 210 + justify-content: space-between; 211 + gap: 12px; 212 + } 213 + 214 + .section-label { 215 + margin: 0 0 4px; 216 + font-size: 12px; 217 + font-weight: 700; 218 + letter-spacing: 0.08em; 219 + text-transform: uppercase; 220 + color: var(--t-text-muted); 221 + } 222 + 223 + .section-title { 224 + margin: 0; 225 + font-size: 20px; 226 + line-height: 1.2; 227 + color: var(--t-text-primary); 228 + } 229 + 230 + .theme-chip { 231 + padding: 8px 10px; 232 + border: 1px solid var(--t-border-strong); 233 + border-radius: 999px; 234 + background: rgba(255, 255, 255, 0.05); 235 + font-size: 12px; 236 + font-weight: 700; 237 + letter-spacing: 0.04em; 238 + text-transform: uppercase; 239 + color: var(--t-text-primary); 240 + } 241 + 242 + .theme-segment { 243 + margin-top: 18px; 244 + --background: transparent; 245 + } 246 + 247 + .helper-copy { 248 + margin: 14px 0 0; 249 + font-size: 13px; 250 + line-height: 1.6; 251 + color: var(--t-text-secondary); 252 + } 253 + 254 + .danger-icon { 255 + font-size: 22px; 256 + color: var(--t-red); 257 + } 258 + 259 + .danger-button { 260 + margin-top: 16px; 261 + } 262 + </style>
+4 -8
src/lib/syntax.ts
··· 5 5 6 6 export function getHighlighter(): Promise<Highlighter> { 7 7 if (!promise) { 8 - promise = createHighlighter({ 9 - themes: ["github-light", "github-dark"], 10 - langs: [], 11 - }); 8 + promise = createHighlighter({ themes: ["github-light", "github-dark"], langs: [] }); 12 9 } 13 10 return promise; 14 11 } ··· 107 104 } 108 105 } 109 106 110 - return sanitizeRichHtml(hl.codeToHtml(code, { 111 - lang, 112 - themes: { light: "github-light", dark: "github-dark" }, 113 - })); 107 + return sanitizeRichHtml( 108 + hl.codeToHtml(code, { lang, themes: { light: "catppuccin-latte", dark: "catppuccin-mocha" } }), 109 + ); 114 110 }
+4 -2
src/main.ts
··· 8 8 import { queryClient } from "./core/query/client.js"; 9 9 import { persistQueryClient } from "@tanstack/query-persist-client-core"; 10 10 import { createIdbPersister } from "./core/query/persister.js"; 11 + import { initializeThemePreference } from "./core/theme/preferences.js"; 11 12 12 13 import "@ionic/vue/css/core.css"; 13 14 import "@ionic/vue/css/normalize.css"; ··· 28 29 */ 29 30 30 31 /* @import '@ionic/vue/css/palettes/dark.always.css'; */ 31 - /* @import '@ionic/vue/css/palettes/dark.class.css'; */ 32 - import "@ionic/vue/css/palettes/dark.system.css"; 32 + import "@ionic/vue/css/palettes/dark.class.css"; 33 33 34 34 /* Theme variables */ 35 35 import "./theme/variables.css"; 36 + 37 + initializeThemePreference(); 36 38 37 39 if (import.meta.env.DEV) { 38 40 void createIdbPersister().removeClient();
+19 -20
src/theme/variables.css
··· 3 3 4 4 /* Twisted design tokens — light */ 5 5 :root { 6 + color-scheme: light; 6 7 --t-accent: #0ea5e9; 7 8 --t-accent-dim: rgba(14, 165, 233, 0.1); 8 9 --t-amber: #d97706; ··· 29 30 --t-radius-lg: 14px; 30 31 } 31 32 32 - /* Twisted design tokens — dark */ 33 - @media (prefers-color-scheme: dark) { 34 - :root { 35 - --t-accent: #22d3ee; 36 - --t-accent-dim: rgba(34, 211, 238, 0.08); 37 - --t-amber: #fbbf24; 38 - --t-amber-dim: rgba(251, 191, 36, 0.1); 39 - --t-green: #34d399; 40 - --t-green-dim: rgba(52, 211, 153, 0.1); 41 - --t-red: #f87171; 42 - --t-red-dim: rgba(248, 113, 113, 0.1); 43 - --t-purple: #a78bfa; 33 + :root.ion-palette-dark { 34 + color-scheme: dark; 35 + --t-accent: #22d3ee; 36 + --t-accent-dim: rgba(34, 211, 238, 0.08); 37 + --t-amber: #fbbf24; 38 + --t-amber-dim: rgba(251, 191, 36, 0.1); 39 + --t-green: #34d399; 40 + --t-green-dim: rgba(52, 211, 153, 0.1); 41 + --t-red: #f87171; 42 + --t-red-dim: rgba(248, 113, 113, 0.1); 43 + --t-purple: #a78bfa; 44 44 45 - --t-surface: #161b22; 46 - --t-surface-raised: #1c2128; 47 - --t-border: rgba(255, 255, 255, 0.07); 48 - --t-border-strong: rgba(255, 255, 255, 0.12); 45 + --t-surface: #161b22; 46 + --t-surface-raised: #1c2128; 47 + --t-border: rgba(255, 255, 255, 0.07); 48 + --t-border-strong: rgba(255, 255, 255, 0.12); 49 49 50 - --t-text-primary: #e6edf3; 51 - --t-text-secondary: #8b949e; 52 - --t-text-muted: #484f58; 53 - } 50 + --t-text-primary: #e6edf3; 51 + --t-text-secondary: #8b949e; 52 + --t-text-muted: #484f58; 54 53 }
+6 -2
src/views/TabsPage.vue
··· 19 19 <ion-icon :icon="personOutline" /> 20 20 <ion-label>Profile</ion-label> 21 21 </ion-tab-button> 22 + <ion-tab-button tab="settings" href="/tabs/settings"> 23 + <ion-icon :icon="settingsOutline" /> 24 + <ion-label>Settings</ion-label> 25 + </ion-tab-button> 22 26 </ion-tab-bar> 23 27 </ion-tabs> 24 28 </ion-page> 25 29 </template> 26 30 27 31 <script setup lang="ts"> 28 - import { IonPage, IonTabs, IonRouterOutlet, IonTabBar, IonTabButton, IonIcon, IonLabel } from "@ionic/vue"; 29 - import { homeOutline, searchOutline, pulseOutline, personOutline } from "ionicons/icons"; 32 + import { IonPage, IonTabs, IonRouterOutlet, IonTabBar, IonTabButton, IonIcon, IonLabel } from "@ionic/vue"; 33 + import { homeOutline, searchOutline, pulseOutline, personOutline, settingsOutline } from "ionicons/icons"; 30 34 </script>