diff --git a/src/css/documentload.scss b/src/css/documentload.scss
index 7b66ac28..6ef051a3 100644
--- a/src/css/documentload.scss
+++ b/src/css/documentload.scss
@@ -37,8 +37,9 @@
@layer base, override;
@layer override {
- * {
- font-family: Rubik, sans-serif !important;
+ .legacy-root,
+ .legacy-root * {
+ font-family: var(--betterseqta-font-family, Rubik), sans-serif !important;
}
.iconFamily,
diff --git a/src/css/injected.scss b/src/css/injected.scss
index 978cd32b..f0a21ae9 100644
--- a/src/css/injected.scss
+++ b/src/css/injected.scss
@@ -119,7 +119,8 @@ select option {
#container {
background: var(--auto-background) !important;
}
-:root * {
+.legacy-root,
+.legacy-root * {
font-family: Rubik, sans-serif !important;
--theme-fg-parts: white;
}
diff --git a/src/interface/components/FontPickerModal.svelte b/src/interface/components/FontPickerModal.svelte
new file mode 100644
index 00000000..45486725
--- /dev/null
+++ b/src/interface/components/FontPickerModal.svelte
@@ -0,0 +1,141 @@
+
+
+
+
{
+ if (event.key === "Enter" || event.key === " ") handleBackdropClick(event as unknown as MouseEvent);
+ }}
+ role="presentation"
+ transition:fade={{ duration: 200 }}
+>
+
event.stopPropagation()}
+ onkeydown={(event) => event.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="font-picker-title"
+ >
+
+
+
+ {#each FONT_PRESETS as preset (preset.id)}
+
+ {/each}
+
+
+
diff --git a/src/interface/components/fontPickerModal.css b/src/interface/components/fontPickerModal.css
new file mode 100644
index 00000000..3647ab81
--- /dev/null
+++ b/src/interface/components/fontPickerModal.css
@@ -0,0 +1,311 @@
+/* Font picker — analytics design tokens & components */
+
+.bsplus-font-picker-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 50000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.25rem;
+ cursor: pointer;
+ background: color-mix(in srgb, #000 52%, transparent);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+}
+
+.bsplus-font-picker-root {
+ --bsplus-analytics-radius: 16px;
+ --bsplus-analytics-radius-sm: 12px;
+ --bsplus-analytics-ease: cubic-bezier(0.4, 0, 0.2, 1);
+ --bsplus-analytics-surface: var(--background-primary, #ffffff);
+ --bsplus-analytics-surface-2: var(--background-secondary, #f8fafc);
+ --bsplus-analytics-text: var(--text-primary, #1a1a1a);
+ --bsplus-analytics-muted: color-mix(
+ in srgb,
+ var(--bsplus-analytics-text) 55%,
+ transparent
+ );
+ --bsplus-analytics-border: color-mix(
+ in srgb,
+ var(--theme-offset-bg, var(--background-secondary, #e2e8f0)) 78%,
+ transparent
+ );
+ --bsplus-analytics-shadow: 0 5px 16px 6px rgba(0, 0, 0, 0.12);
+ --bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16);
+ --bsplus-analytics-accent: var(--better-main, #007bff);
+
+ font-family: Rubik, system-ui, sans-serif;
+ font-size: 0.875rem;
+ color: var(--bsplus-analytics-text);
+}
+
+.bsplus-font-picker-root.dark {
+ --bsplus-analytics-shadow: 0 5px 20px 6px rgba(0, 0, 0, 0.45);
+ --bsplus-analytics-shadow-hover: 0 10px 28px 10px rgba(0, 0, 0, 0.55);
+}
+
+@keyframes bsplus-font-picker-in {
+ from {
+ opacity: 0;
+ transform: translateY(18px) scale(0.985);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.bsplus-font-picker-dialog {
+ width: min(100%, 22rem);
+ max-height: min(88vh, 820px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ cursor: auto;
+ border-radius: var(--bsplus-analytics-radius);
+ background: var(--bsplus-analytics-surface);
+ border: 1px solid var(--bsplus-analytics-border);
+ box-shadow: var(--bsplus-analytics-shadow-hover);
+ animation: bsplus-font-picker-in 0.45s var(--bsplus-analytics-ease) forwards;
+}
+
+@media (min-width: 640px) {
+ .bsplus-font-picker-dialog {
+ width: min(92vw, 22rem);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bsplus-font-picker-dialog {
+ animation: none;
+ }
+}
+
+.bsplus-font-picker-header {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.85rem;
+ flex-shrink: 0;
+ padding: 1.15rem 1.25rem;
+ border-bottom: 1px solid var(--bsplus-analytics-border);
+}
+
+.bsplus-font-picker-header-actions {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.bsplus-font-picker-header-text {
+ min-width: 0;
+}
+
+.bsplus-font-picker-title {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--bsplus-analytics-text);
+}
+
+.bsplus-font-picker-desc {
+ margin: 0.35rem 0 0;
+ font-size: 0.8125rem;
+ color: var(--bsplus-analytics-muted);
+ line-height: 1.5;
+}
+
+.bsplus-font-picker-reset {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.65rem 1rem;
+ border-radius: var(--bsplus-analytics-radius-sm);
+ border: 2px solid var(--bsplus-analytics-border);
+ background: transparent;
+ font-family: inherit;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.2;
+ cursor: pointer;
+ color: var(--bsplus-analytics-text);
+ transition:
+ transform 0.2s var(--bsplus-analytics-ease),
+ background 0.2s var(--bsplus-analytics-ease),
+ border-color 0.2s var(--bsplus-analytics-ease),
+ opacity 0.2s ease;
+}
+
+.bsplus-font-picker-reset:hover:not(:disabled) {
+ transform: scale(1.02);
+ background: color-mix(
+ in srgb,
+ var(--bsplus-analytics-surface-2) 80%,
+ transparent
+ );
+}
+
+.bsplus-font-picker-reset:active:not(:disabled) {
+ transform: scale(0.98);
+}
+
+.bsplus-font-picker-reset:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px
+ color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
+}
+
+.bsplus-font-picker-reset:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.bsplus-font-picker-done {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ padding: 0.65rem 1.25rem;
+ border-radius: var(--bsplus-analytics-radius-sm);
+ border: none;
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1.2;
+ cursor: pointer;
+ background: var(--bsplus-analytics-accent);
+ color: var(--text-color, #ffffff);
+ box-shadow: 0 2px 8px
+ color-mix(in srgb, var(--bsplus-analytics-accent) 40%, transparent);
+ transition:
+ transform 0.2s var(--bsplus-analytics-ease),
+ box-shadow 0.2s var(--bsplus-analytics-ease);
+}
+
+.bsplus-font-picker-done:hover {
+ transform: scale(1.03);
+ box-shadow: 0 4px 14px
+ color-mix(in srgb, var(--bsplus-analytics-accent) 45%, transparent);
+}
+
+.bsplus-font-picker-done:active {
+ transform: scale(0.97);
+}
+
+.bsplus-font-picker-done:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px
+ color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
+}
+
+.bsplus-font-picker-list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 1rem 1rem 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent)
+ transparent;
+}
+
+.bsplus-font-picker-list::-webkit-scrollbar {
+ width: 8px;
+}
+
+.bsplus-font-picker-list::-webkit-scrollbar-thumb {
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
+}
+
+.bsplus-font-picker-option {
+ width: 100%;
+ padding: 0.9rem 1rem;
+ text-align: left;
+ border-radius: var(--bsplus-analytics-radius-sm);
+ border: 1px solid var(--bsplus-analytics-border);
+ background: var(--bsplus-analytics-surface);
+ box-shadow: var(--bsplus-analytics-shadow);
+ cursor: pointer;
+ font-family: Rubik, system-ui, sans-serif;
+ flex-shrink: 0;
+ transition:
+ transform 0.25s var(--bsplus-analytics-ease),
+ box-shadow 0.25s var(--bsplus-analytics-ease),
+ border-color 0.2s ease,
+ background 0.2s ease;
+}
+
+.bsplus-font-picker-option:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--bsplus-analytics-shadow-hover);
+ background: color-mix(
+ in srgb,
+ var(--bsplus-analytics-surface-2) 55%,
+ var(--bsplus-analytics-surface)
+ );
+}
+
+.bsplus-font-picker-option:focus-visible {
+ outline: none;
+ box-shadow:
+ var(--bsplus-analytics-shadow-hover),
+ 0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 30%, transparent);
+}
+
+.bsplus-font-picker-option.is-selected {
+ border-color: color-mix(
+ in srgb,
+ var(--bsplus-analytics-accent) 45%,
+ var(--bsplus-analytics-border)
+ );
+ background: color-mix(
+ in srgb,
+ var(--bsplus-analytics-accent) 10%,
+ var(--bsplus-analytics-surface)
+ );
+ box-shadow: 0 4px 16px
+ color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
+}
+
+.bsplus-font-picker-option-head {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0;
+}
+
+.bsplus-font-picker-root .bsplus-font-picker-option-name {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--bsplus-analytics-text);
+ text-align: left;
+ min-width: 0;
+}
+
+.bsplus-font-picker-badge {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ padding: 0.2rem 0.65rem;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ background: color-mix(
+ in srgb,
+ var(--bsplus-analytics-accent) 18%,
+ transparent
+ );
+ color: var(--bsplus-analytics-accent);
+}
diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte
index bcff0e80..db6aed8d 100644
--- a/src/interface/pages/settings.svelte
+++ b/src/interface/pages/settings.svelte
@@ -15,6 +15,7 @@
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte";
+ import FontPickerModal from "../components/FontPickerModal.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup";
@@ -47,6 +48,10 @@
showColourPicker = true;
};
+ const openFontPicker = () => {
+ showFontPicker = true;
+ };
+
const openChangelog = () => {
OpenWhatsNewPopup();
closeExtensionPopup();
@@ -69,6 +74,7 @@
let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state(false);
+ let showFontPicker = $state(false);
let showCloudPanel = $state(false);
const openCloudPanel = () => {
@@ -85,6 +91,7 @@
onMount(() => {
settingsPopup.addListener(() => {
showColourPicker = false;
+ showFontPicker = false;
showCloudPanel = false;
});
@@ -95,7 +102,7 @@
@@ -293,7 +300,7 @@
{
title: "Settings",
Content: Settings,
- props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
+ props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
},
{ title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme },
@@ -318,6 +325,14 @@
{/if}
+{#if showFontPicker}
+ {
+ showFontPicker = false;
+ }}
+ />
+{/if}
+
{#if showDisclaimerModal && disclaimerCallbacks}
void;
+ showFontPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>();
@@ -192,6 +193,16 @@
onClick: showColourPicker
}
},
+ {
+ title: "Interface Font",
+ description: "Choose the typeface used across SEQTA Learn",
+ id: 16,
+ Component: Button,
+ props: {
+ onClick: showFontPicker,
+ text: "Change"
+ }
+ },
{
title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout",
diff --git a/src/interface/pages/settings/theme.svelte b/src/interface/pages/settings/theme.svelte
index 2f14890f..fece578a 100644
--- a/src/interface/pages/settings/theme.svelte
+++ b/src/interface/pages/settings/theme.svelte
@@ -36,4 +36,4 @@
{/if}
-
\ No newline at end of file
+
diff --git a/src/interface/utils/portal.ts b/src/interface/utils/portal.ts
index d634d3be..6124639e 100644
--- a/src/interface/utils/portal.ts
+++ b/src/interface/utils/portal.ts
@@ -4,16 +4,21 @@ import type { Action } from "svelte/action";
* Svelte action that moves the element to a different DOM target.
* Defaults to the nearest ShadowRoot so styles remain intact when the app
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
- * Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts.
+ * Pass `document.body` to escape transformed/contained settings popups entirely.
*/
-export const portal: Action = (node, target) => {
+export const portal: Action = (
+ node,
+ target,
+) => {
const root = node.getRootNode();
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
dest.appendChild(node);
return {
update(newTarget) {
- (newTarget ?? dest).appendChild(node);
+ const nextDest =
+ newTarget ?? (root instanceof ShadowRoot ? root : document.body);
+ nextDest.appendChild(node);
},
destroy() {
node.remove();
diff --git a/src/interface/utils/syncPageTheme.ts b/src/interface/utils/syncPageTheme.ts
new file mode 100644
index 00000000..7e45a197
--- /dev/null
+++ b/src/interface/utils/syncPageTheme.ts
@@ -0,0 +1,74 @@
+import { settingsState } from "@/seqta/utils/listeners/SettingsState";
+
+const THEME_CSS_VARS = [
+ "--better-main",
+ "--better-pale",
+ "--better-light",
+ "--text-color",
+ "--background-primary",
+ "--background-secondary",
+ "--text-primary",
+ "--theme-offset-bg",
+ "--better-sub",
+] as const;
+
+const ACCENT_CSS_VARS = [
+ "--better-main",
+ "--accent-color-value",
+ "--accentColor",
+ "--colour-betterseqta-blue",
+] as const;
+
+function extractSolidColor(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed || trimmed === "initial") return null;
+ if (
+ trimmed.startsWith("#") ||
+ trimmed.startsWith("rgb") ||
+ trimmed.startsWith("hsl")
+ ) {
+ return trimmed;
+ }
+ if (trimmed.includes("gradient")) {
+ const match = trimmed.match(
+ /#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
+ );
+ return match?.[0] ?? null;
+ }
+ return null;
+}
+
+function resolvePageAccentColor(): string {
+ const computed = getComputedStyle(document.documentElement);
+ for (const name of ACCENT_CSS_VARS) {
+ const solid = extractSolidColor(computed.getPropertyValue(name));
+ if (solid) return solid;
+ }
+ const fromSettings = settingsState.selectedColor?.trim();
+ if (fromSettings) {
+ const solid = extractSolidColor(fromSettings);
+ if (solid) return solid;
+ }
+ return "#007bff";
+}
+
+/** Copy SEQTA page theme tokens onto a portaled UI root (matches analytics sync). */
+export function syncPageThemeToElement(target: HTMLElement): void {
+ const computed = getComputedStyle(document.documentElement);
+
+ for (const name of THEME_CSS_VARS) {
+ const value = computed.getPropertyValue(name).trim();
+ if (value) {
+ target.style.setProperty(name, value);
+ }
+ }
+
+ const accent = resolvePageAccentColor();
+ target.style.setProperty("--bsplus-analytics-accent", accent);
+ target.style.setProperty("--better-main", accent);
+
+ target.classList.toggle(
+ "dark",
+ document.documentElement.classList.contains("dark"),
+ );
+}
diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts
index 82f707e9..0783c539 100644
--- a/src/plugins/monofile.ts
+++ b/src/plugins/monofile.ts
@@ -17,6 +17,7 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners";
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
+import { applySelectedFont } from "@/seqta/ui/fonts/Manager";
import loading from "@/seqta/ui/Loading";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { getEngageRoutePage } from "@/seqta/utils/engageRoute";
@@ -697,6 +698,7 @@ export function init() {
new MessageHandler();
void updateAllColors();
+ applySelectedFont();
window.addEventListener("hashchange", () => {
if (settingsState.adaptiveThemeColour) void updateAllColors();
diff --git a/src/seqta/ui/fonts/Manager.ts b/src/seqta/ui/fonts/Manager.ts
new file mode 100644
index 00000000..808e4efd
--- /dev/null
+++ b/src/seqta/ui/fonts/Manager.ts
@@ -0,0 +1,136 @@
+import { settingsState } from "@/seqta/utils/listeners/SettingsState";
+import { FONT_PRESETS, getFontPreset, type FontPreset } from "./presets";
+
+const FONT_STYLE_ID = "betterseqta-font-override";
+const FONT_PICKER_BATCH_ID = "betterseqta-font-picker-preview";
+const loadedFontIds = new Set();
+let pickerFontsPromise: Promise | null = null;
+
+/** Elements that show per-font previews must stay outside the global override. */
+export const FONT_PICKER_ROOT_CLASS = "bsplus-font-picker-root";
+
+function googleFamilyParam(preset: FontPreset): string | null {
+ if (!preset.googleUrl) return null;
+ const name =
+ preset.stack.split(",")[0]?.trim().replace(/^"|"$/g, "") ?? "";
+ if (!name) return null;
+ return `family=${encodeURIComponent(name)}:wght@400;500;600;700`;
+}
+
+function injectStylesheet(id: string, href: string): Promise {
+ if (document.getElementById(id)) return Promise.resolve();
+
+ return new Promise((resolve) => {
+ const link = document.createElement("link");
+ link.id = id;
+ link.rel = "stylesheet";
+ link.href = href;
+ link.setAttribute("data-betterseqta-font-picker-batch", "true");
+ link.onload = () => resolve();
+ link.onerror = () => resolve();
+ document.head.appendChild(link);
+ });
+}
+
+/** Load all Google Fonts for picker previews (batched, awaited). */
+export function ensureFontPickerFontsLoaded(): Promise {
+ if (!pickerFontsPromise) {
+ pickerFontsPromise = (async () => {
+ const params = FONT_PRESETS.map(googleFamilyParam).filter(
+ (param): param is string => param !== null,
+ );
+
+ const chunkSize = 10;
+ for (let i = 0; i < params.length; i += chunkSize) {
+ const chunk = params.slice(i, i + chunkSize);
+ const url = `https://fonts.googleapis.com/css2?${chunk.join("&")}&display=swap`;
+ await injectStylesheet(`${FONT_PICKER_BATCH_ID}-${i / chunkSize}`, url);
+ }
+
+ try {
+ await document.fonts.ready;
+ } catch {
+ /* FontFaceSet unsupported or blocked */
+ }
+ })();
+ }
+
+ return pickerFontsPromise;
+}
+
+export function ensureFontLoaded(preset: FontPreset): void {
+ if (!preset.googleUrl || loadedFontIds.has(preset.id)) return;
+
+ if (document.querySelector(`link[data-betterseqta-font="${preset.id}"]`)) {
+ loadedFontIds.add(preset.id);
+ return;
+ }
+
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = preset.googleUrl;
+ link.setAttribute("data-betterseqta-font", preset.id);
+ document.head.appendChild(link);
+ loadedFontIds.add(preset.id);
+}
+
+export function buildFontPreviewCss(): string {
+ return FONT_PRESETS.map(
+ (preset) => `
+.bsplus-font-picker-option[data-font-id="${preset.id}"] .bsplus-font-picker-option-name {
+ font-family: ${preset.stack} !important;
+}`,
+ ).join("\n");
+}
+
+const SEQTA_FONT_SCOPE = `
+ .legacy-root,
+ .legacy-root input,
+ .legacy-root textarea,
+ .legacy-root button,
+ .legacy-root select,
+ .legacy-root option,
+ .legacy-root .input,
+ .legacy-root *,
+ #container,
+ #container *
+`;
+
+function buildFontOverrideCss(family: string): string {
+ const rule = `font-family: ${family} !important;`;
+
+ return `
+ ${SEQTA_FONT_SCOPE} {
+ ${rule}
+ }
+
+ .iconFamily,
+ .iconFamily *,
+ button.uiButton.timetable-zoom.iconFamily,
+ [class~="iconFamily"],
+ [class~="iconFamily"] * {
+ font-family: "IconFamily" !important;
+ }
+ `;
+}
+
+export function applySelectedFont(fontId?: string | null): void {
+ if (typeof document === "undefined") return;
+
+ const preset = getFontPreset(fontId ?? settingsState.selectedFont);
+ ensureFontLoaded(preset);
+
+ document.documentElement.style.setProperty(
+ "--betterseqta-font-family",
+ preset.stack.split(",")[0]?.trim().replace(/^"|"$/g, "") ?? "Rubik",
+ );
+
+ let style = document.getElementById(FONT_STYLE_ID) as HTMLStyleElement | null;
+ if (!style) {
+ style = document.createElement("style");
+ style.id = FONT_STYLE_ID;
+ document.head.appendChild(style);
+ }
+
+ style.textContent = buildFontOverrideCss(preset.stack);
+}
diff --git a/src/seqta/ui/fonts/presets.ts b/src/seqta/ui/fonts/presets.ts
new file mode 100644
index 00000000..e03b68d1
--- /dev/null
+++ b/src/seqta/ui/fonts/presets.ts
@@ -0,0 +1,265 @@
+export const DEFAULT_FONT_ID = "rubik";
+
+export interface FontPreset {
+ id: string;
+ name: string;
+ stack: string;
+ googleUrl?: string;
+ sample: string;
+}
+
+export const FONT_PRESETS: FontPreset[] = [
+ {
+ id: "rubik",
+ name: "Rubik",
+ stack: "Rubik, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "inter",
+ name: "Inter",
+ stack: "Inter, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "poppins",
+ name: "Poppins",
+ stack: "Poppins, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "nunito",
+ name: "Nunito",
+ stack: "Nunito, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "montserrat",
+ name: "Montserrat",
+ stack: "Montserrat, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "open-sans",
+ name: "Open Sans",
+ stack: '"Open Sans", sans-serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "lato",
+ name: "Lato",
+ stack: "Lato, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "source-sans-3",
+ name: "Source Sans 3",
+ stack: '"Source Sans 3", sans-serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "raleway",
+ name: "Raleway",
+ stack: "Raleway, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "dm-sans",
+ name: "DM Sans",
+ stack: '"DM Sans", sans-serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "plus-jakarta-sans",
+ name: "Plus Jakarta Sans",
+ stack: '"Plus Jakarta Sans", sans-serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "outfit",
+ name: "Outfit",
+ stack: "Outfit, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "roboto",
+ name: "Roboto",
+ stack: "Roboto, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "work-sans",
+ name: "Work Sans",
+ stack: '"Work Sans", sans-serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "manrope",
+ name: "Manrope",
+ stack: "Manrope, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "figtree",
+ name: "Figtree",
+ stack: "Figtree, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "lexend",
+ name: "Lexend",
+ stack: "Lexend, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Lexend:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "ubuntu",
+ name: "Ubuntu",
+ stack: "Ubuntu, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "karla",
+ name: "Karla",
+ stack: "Karla, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Karla:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "quicksand",
+ name: "Quicksand",
+ stack: "Quicksand, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "ibm-plex-sans",
+ name: "IBM Plex Sans",
+ stack: '"IBM Plex Sans", sans-serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "space-grotesk",
+ name: "Space Grotesk",
+ stack: '"Space Grotesk", sans-serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "mulish",
+ name: "Mulish",
+ stack: "Mulish, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Mulish:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "cabin",
+ name: "Cabin",
+ stack: "Cabin, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Cabin:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "oswald",
+ name: "Oswald",
+ stack: "Oswald, sans-serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "merriweather",
+ name: "Merriweather",
+ stack: "Merriweather, serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "playfair-display",
+ name: "Playfair Display",
+ stack: '"Playfair Display", serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "lora",
+ name: "Lora",
+ stack: "Lora, serif",
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "crimson-pro",
+ name: "Crimson Pro",
+ stack: '"Crimson Pro", serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;500;600;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "libre-baskerville",
+ name: "Libre Baskerville",
+ stack: '"Libre Baskerville", serif',
+ googleUrl:
+ "https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&display=swap",
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+ {
+ id: "system",
+ name: "System Default",
+ stack: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ sample: "Assessment due tomorrow at 3:30pm",
+ },
+];
+
+export function getFontPreset(id?: string | null): FontPreset {
+ return (
+ FONT_PRESETS.find((preset) => preset.id === id) ??
+ FONT_PRESETS.find((preset) => preset.id === DEFAULT_FONT_ID)!
+ );
+}
diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts
index 2d9ccf06..ed5316c8 100644
--- a/src/seqta/utils/listeners/StorageChanges.ts
+++ b/src/seqta/utils/listeners/StorageChanges.ts
@@ -1,5 +1,6 @@
import { settingsState } from "./SettingsState";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
+import { applySelectedFont } from "@/seqta/ui/fonts/Manager";
// Shortcuts rendering
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
@@ -40,6 +41,7 @@ export class StorageChangeHandler {
"iconOnlySidebar",
this.handleIconOnlySidebarChange.bind(this),
);
+ settingsState.register("selectedFont", () => applySelectedFont());
}
private handleIconOnlySidebarChange(newValue: boolean | undefined) {
diff --git a/src/types/storage.ts b/src/types/storage.ts
index 9ee34640..705f1d93 100644
--- a/src/types/storage.ts
+++ b/src/types/storage.ts
@@ -57,6 +57,8 @@ export interface SettingsState {
adaptiveThemeColour?: boolean;
adaptiveThemeGradient?: boolean;
adaptiveThemeColourTransition?: boolean;
+ /** Google Font preset id for SEQTA interface typography (`rubik` default). */
+ selectedFont?: string;
// depreciated keys
animatedbk: boolean;