From fba5d09c75e0cf0eba201b711c99622549ba8b68 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Wed, 29 Apr 2026 11:13:32 +0930 Subject: [PATCH] feat: theme flavours for theme varients --- docs/CLOUD_SETTINGS_SYNC_SERVER.md | 13 +- .../components/store/CoverSwiper.svelte | 71 +- .../components/store/ThemeCard.svelte | 82 +- .../components/store/ThemeGrid.svelte | 13 +- .../components/store/ThemeModal.svelte | 1071 ++++++++++++++--- src/interface/pages/store.svelte | 60 +- src/interface/types/Theme.ts | 32 + src/interface/utils/themeStoreFlavours.ts | 165 +++ src/plugins/built-in/themes/theme-manager.ts | 2 + src/seqta/utils/Openers/OpenWhatsNewPopup.ts | 4 +- src/seqta/utils/cloudSettingsSync.ts | 5 +- 11 files changed, 1311 insertions(+), 207 deletions(-) create mode 100644 src/interface/utils/themeStoreFlavours.ts diff --git a/docs/CLOUD_SETTINGS_SYNC_SERVER.md b/docs/CLOUD_SETTINGS_SYNC_SERVER.md index c9c085af..d9b502e8 100644 --- a/docs/CLOUD_SETTINGS_SYNC_SERVER.md +++ b/docs/CLOUD_SETTINGS_SYNC_SERVER.md @@ -35,13 +35,16 @@ Upserts the caller’s settings backup. ```json { "schemaVersion": 1, + "themeId": "uuid-string-or-empty", "data": { - "...": "flat key-value map mirroring extension storage (see Payload shape)" + "...": "flat key-value map mirroring extension storage (see Payload shape)", + "selectedTheme": "uuid-or-empty-string" } } ``` - **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations. +- **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**. - **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`). **Success response:** HTTP `200` (or `201` if you prefer create semantics). Example: @@ -67,15 +70,19 @@ Returns the caller’s latest settings backup. ```json { "schemaVersion": 1, + "themeId": "uuid-string-or-empty", "data": { }, "updated_at": "2026-04-07T12:00:00.000Z" } ``` - **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys). +- **`themeId`**: optional; if present must match **`data.selectedTheme`** (see `PUT`). - **`schemaVersion`**: optional but recommended; should match what was stored. - **`updated_at`**: optional; included for UX if the client shows “last backup” time. +The extension resolves **`themeId`** (if non-empty), else **`data.selectedTheme`,** to [`resolveThemeIdForPostSyncDownload`](../src/seqta/utils/cloudSettingsSync.ts) after downloading the envelope — used only to reinstall theme assets from **`betterseqta.org`** when IndexedDB lacks that id (see **BetterSEQTA Cloud** flavour note in **[THEME_STORE_FLAVOURS_API](./THEME_STORE_FLAVOURS_API.md)** section “Cloud settings sync compatibility”). + **No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user. **Error responses:** `401` if the token is invalid, etc. @@ -128,6 +135,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, ## Client reference (extension) -- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**). -- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`. +- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**). +- Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above. - Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`. diff --git a/src/interface/components/store/CoverSwiper.svelte b/src/interface/components/store/CoverSwiper.svelte index 0b1a2c08..cd6eb126 100644 --- a/src/interface/components/store/CoverSwiper.svelte +++ b/src/interface/components/store/CoverSwiper.svelte @@ -1,10 +1,13 @@ -{#if coverThemes.length > 0} +{#if slides.length > 0}
-
- {#each coverThemes as theme} + {#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
{ if (e.key === 'Enter') setDisplayTheme(theme) }} - onclick={() => setDisplayTheme(theme)} + onkeydown={(e) => { + if (e.key === 'Enter') setDisplayTheme(slide.openTheme); + }} + onclick={() => setDisplayTheme(slide.openTheme)} > - Theme Preview - {#if theme.featured === true} + + {#if slide.badgeFeatured === true}
- + Featured
{/if} -
-

{theme.name}

- {#if theme.author} -

By {theme.author}

+
+

{slide.title}

+ {#if slide.subtitle} +

{slide.subtitle}

+ {/if} + {#if slide.openTheme.author && !slide.subtitle} +

By {slide.openTheme.author}

+ {/if} + {#if slide.openTheme.description && !slide.subtitle} +

{slide.openTheme.description}

{/if} -

{theme.description}

-
+
{/each}
- -
- - -
-
-

- {theme.name} -

- {#if theme.featured === true} - - - - - Featured - - {/if} -
- {#if theme.author} -

- By {theme.author} -

- {/if} -
- - - - - {(theme.download_count ?? 0).toLocaleString()} downloads - - - - - - {(theme.favorite_count ?? 0).toLocaleString()} favorites - -
- Theme Cover -

- {theme.description} -

-
- {#if toggleFavorite && theme} + +
+ +
+ - {/if} - {#if currentThemes.includes(theme.id)} - - {:else} - - {/if} -
- {#if relatedThemes.length > 0} -
+ {'\ued8a'} + + -

- Related themes -

-
- {#each relatedThemes as relatedTheme (relatedTheme.id)} - - {/each}
- {/if} -
- {:else} -
- + +
+ +

+ + {theme.name} + +

+ + {#if theme.featured === true} + + + + + + + + + + Featured + + + + {/if} + +
+ + {#if theme.author} + +

+ + By {theme.author} + +

+ + {/if} + +
+ + + + + + + + + + {modalDisplayDownloadCount.toLocaleString()} downloads + + + + + + + + + + + + {(theme.favorite_count ?? 0).toLocaleString()} favorites + + + +
+ + + + {#if heroSlides.length > 0} + + {#key theme?.id} + +
+ +
+ +
+ + {#each heroSlides as slide, slideIdx (slideIdx)} + +
+ + {slide.caption} + +
+ + {/each} + +
+ +
+ + {#if heroSlides.length > 1} + +
+ + + + + +
+ + {/if} + +
+ + {/key} + + {/if} + + + + {#if hasFlavours} + + {@const masterThumb = masterCarouselImageUrl(theme)} + +

Variants

+ +
+ + {#if currentThemes.includes(theme.id)} + + + + {:else} + + + + {/if} + + {#each theme.flavours ?? [] as f, flavourIdx (f.id)} + + {@const thumb = flavourCarouselImageUrl(f)} + + {#if currentThemes.includes(f.id)} + + + + {:else} + + + + {/if} + + {/each} + +
+ + {/if} + + + +

+ + {theme.description} + +

+ + + +
+ + {#if toggleFavorite && theme} + + + + {/if} + + + + {#if !hasFlavours} + + {#if currentThemes.includes(theme.id)} + + + + {:else} + + + + {/if} + + {/if} + +
+ + + + {#if relatedThemes.length > 0} + +
+ + + +

+ + Related themes + +

+ +
+ + {#each relatedThemes as relatedTheme (relatedTheme.id)} + + + + {/each} + +
+ + {/if} +
+ + {:else} + +
+ + + +
+ {/if} +
+
+ + + diff --git a/src/interface/pages/store.svelte b/src/interface/pages/store.svelte index 7b05711d..92bcef14 100644 --- a/src/interface/pages/store.svelte +++ b/src/interface/pages/store.svelte @@ -7,6 +7,7 @@ import SkeletonLoader from '../components/SkeletonLoader.svelte'; import { settingsState } from '@/seqta/utils/listeners/SettingsState' import type { Theme } from '../types/Theme' + import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours' import browser from 'webextension-polyfill' import ThemeModal from '../components/store/ThemeModal.svelte' import Header from '../components/store/Header.svelte' @@ -26,7 +27,12 @@ // State variables let searchTerm = $state(''); let themes = $state([]); - let coverThemes = $state([]); + + /** Grid/search/cover: hides flat-listed slaves when API sends them */ + let listThemes = $derived(visibleStoreThemes(themes)); + + /** Cover marquee slides (master + flavour imagery for top masters) */ + let coverSlides = $derived(buildCoverSlidesForThemes(listThemes.slice(0, 3))); let loading = $state(true); let darkMode = $state(false); let displayTheme = $state(null); @@ -108,7 +114,6 @@ throw new Error(data?.error || 'Failed to fetch themes'); } themes = [...data.data.themes].sort(compareStoreThemes); - coverThemes = themes.slice(0, 3); loading = false; } catch (err) { @@ -128,13 +133,36 @@ // Filter themes (list is already featured-first, then newest; filter preserves order) let filteredThemes = $derived( - themes.filter( + listThemes.filter( (theme) => theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase()), ), ); + async function installThemeFromStore(themeId: string, meta: Theme) { + const fullRow = themes.find((x) => x.id === themeId); + if (fullRow) { + await themeManager.downloadTheme(fullRow); + } else { + const flavour = meta.flavours?.find((f) => f.id === themeId); + await themeManager.downloadTheme({ + id: themeId, + name: flavour?.name ?? meta.name, + } as Theme); + } + await themeManager.setTheme(themeId); + themeUpdates.triggerUpdate(); + await fetchCurrentThemes(); + void browser.runtime.sendMessage({ type: 'cloudSettingsRequestDebouncedUpload' }).catch(() => {}); + } + + async function removeThemeFromStore(themeId: string) { + await themeManager.deleteTheme(themeId); + themeUpdates.triggerUpdate(); + await fetchCurrentThemes(); + } + $effect(() => { loadBackground(); selectedBackground @@ -172,12 +200,13 @@ {#if activeTab === 'themes'} {#if searchTerm === ''} - + {/if} (showSignInOverlay = true)} - onInstall={async () => { - if (displayTheme) { - await themeManager.downloadTheme(displayTheme); - await themeManager.setTheme(displayTheme.id); - themeUpdates.triggerUpdate(); - await fetchCurrentThemes(); - void browser.runtime.sendMessage({ type: 'cloudSettingsRequestDebouncedUpload' }).catch(() => {}); - } + onInstall={async (themeId: string) => { + if (displayTheme) await installThemeFromStore(themeId, displayTheme); }} - onRemove={async () => { - if (displayTheme?.id) { - console.debug('deleting theme', displayTheme.id); - await themeManager.deleteTheme(displayTheme.id); - themeUpdates.triggerUpdate(); - await fetchCurrentThemes(); - } + onRemove={async (themeId: string) => { + console.debug('deleting theme', themeId); + await removeThemeFromStore(themeId); }} /> {/if} diff --git a/src/interface/types/Theme.ts b/src/interface/types/Theme.ts index 407744ef..2c3c7268 100644 --- a/src/interface/types/Theme.ts +++ b/src/interface/types/Theme.ts @@ -1,3 +1,17 @@ +export type ThemeRole = "standard" | "master" | "slave"; + +/** List/detail metadata for variants of a master theme (full theme.json fetched at install by id). */ +export type ThemeFlavour = { + id: string; + name: string; + /** Mirrors theme.json accent (e.g. defaultColour); used for install picker buttons */ + accent_color: string; + cover_image: string; + marquee_image?: string; + /** Per-variant installs when slaves are not returned as flat `theme_role` rows */ + download_count?: number; +}; + export type Theme = { id: string; name: string; @@ -15,4 +29,22 @@ export type Theme = { created_at?: number; /** Unix seconds — last server update (GET /api/themes). */ updated_at?: number; + /** Omitted / `standard` — show in grid. `slave` hides from grid. `master` can list `flavours`. */ + theme_role?: ThemeRole; + /** Present when `theme_role === "slave"` and API returns a flat list during migration */ + master_id?: string; + /** Variants nested on master rows; installs use flavour `id` */ + flavours?: ThemeFlavour[]; +}; + +/** One marquee slide (cover hero or modal carousel). */ +export type ThemeCoverSlide = { + imageUrl: string; + /** Main line — usually master name */ + title: string; + /** Subline — flavour name when applicable */ + subtitle?: string; + /** Opening the modal uses this theme (always the grid row / master object) */ + openTheme: Theme; + badgeFeatured?: boolean; }; diff --git a/src/interface/utils/themeStoreFlavours.ts b/src/interface/utils/themeStoreFlavours.ts new file mode 100644 index 00000000..e6c55cd4 --- /dev/null +++ b/src/interface/utils/themeStoreFlavours.ts @@ -0,0 +1,165 @@ +import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme"; + +export function isHiddenStoreTheme(theme: Theme): boolean { + return theme.theme_role === "slave"; +} + +/** Grid and search: omit slave rows (when API sends a flattened list). */ +export function visibleStoreThemes(themes: Theme[]): Theme[] { + return themes.filter((t) => !isHiddenStoreTheme(t)); +} + +function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string { + return t.marqueeImage || t.coverImage; +} + +/** + * Builds slides for CoverSwiper: for each top-N master, first master image then each flavour image. + */ +export function buildCoverSlidesForThemes(topThemes: Theme[]): ThemeCoverSlide[] { + const slides: ThemeCoverSlide[] = []; + for (const theme of topThemes) { + const flavours = theme.flavours ?? []; + if (flavours.length === 0) { + slides.push({ + imageUrl: marqueeOrCoverUrl(theme), + title: theme.name, + subtitle: theme.author ? `By ${theme.author}` : undefined, + openTheme: theme, + badgeFeatured: theme.featured === true, + }); + continue; + } + slides.push({ + imageUrl: marqueeOrCoverUrl(theme), + title: theme.name, + subtitle: theme.author ? `By ${theme.author}` : undefined, + openTheme: theme, + badgeFeatured: theme.featured === true, + }); + for (const f of flavours) { + slides.push({ + imageUrl: f.marquee_image || f.cover_image, + title: theme.name, + subtitle: f.name, + openTheme: theme, + badgeFeatured: theme.featured === true, + }); + } + } + return slides; +} + +export type ModalHeroSlide = { imageUrl: string; caption: string }; + +/** Preview image for carousel + flavour picker (matches hero slide order after master slide). */ +export function flavourCarouselImageUrl(f: ThemeFlavour): string { + const u = (f.marquee_image || f.cover_image || "").trim(); + return u; +} + +/** Preview image for master variant tile (modal hero slide 0). */ +export function masterCarouselImageUrl(t: Theme): string { + return (marqueeOrCoverUrl(t) || "").trim(); +} + +/** + * Ordered preview URLs for the store grid card rotator: master first, then each variant. + * Uses nested `flavours` when present; otherwise flat `slave` rows (same order as modal when possible). + */ +export function gridCardPreviewImageUrls(theme: Theme, allStoreRows?: Theme[]): string[] { + const urls: string[] = []; + const push = (raw: string) => { + const u = raw.trim(); + if (u && !urls.includes(u)) urls.push(u); + }; + + push(marqueeOrCoverUrl(theme) || ""); + + const flavours = theme.flavours ?? []; + if (flavours.length > 0) { + for (const f of flavours) { + push(flavourCarouselImageUrl(f)); + } + return urls.length > 0 ? urls : [(theme.coverImage || "").trim()].filter(Boolean); + } + + if (allStoreRows) { + const slaves = allStoreRows + .filter((t) => t.theme_role === "slave" && t.master_id === theme.id) + .sort((a, b) => a.id.localeCompare(b.id)); + for (const s of slaves) { + push((marqueeOrCoverUrl(s) || "").trim()); + } + } + + if (urls.length > 0) return urls; + const fallback = (theme.coverImage || "").trim(); + return fallback ? [fallback] : []; +} + +/** + * Downloads shown on the grid card for a master row: master's count plus each slave + * (flat `theme_role === "slave"` with `master_id`) and any flavour-only `download_count` + * when there is no matching flat slave id (nested-only API shape). + */ +export function masterGridDisplayDownloadCount(master: Theme, allStoreRows: Theme[]): number { + let total = master.download_count ?? 0; + const slaveRows = allStoreRows.filter( + (t) => t.theme_role === "slave" && t.master_id === master.id, + ); + const countedIds = new Set(slaveRows.map((r) => r.id)); + for (const r of slaveRows) { + total += r.download_count ?? 0; + } + for (const f of master.flavours ?? []) { + if (countedIds.has(f.id)) continue; + total += f.download_count ?? 0; + } + return total; +} + +/** Modal hero: master first, then each flavour (plan order). */ +export function buildModalHeroSlides(theme: Theme): ModalHeroSlide[] { + const slides: ModalHeroSlide[] = []; + slides.push({ + imageUrl: marqueeOrCoverUrl(theme), + caption: theme.name, + }); + const flavours = theme.flavours ?? []; + for (const f of flavours) { + slides.push({ + imageUrl: f.marquee_image || f.cover_image, + caption: `${theme.name} — ${f.name}`, + }); + } + return slides; +} + +/** + * Relative luminance 0–1 for rgba/rgb/hex-ish strings; fallback 0.5 → white text + */ +export function pickContrastingTextColor(backgroundCss: string): "#ffffff" | "#0a0a0a" { + const s = backgroundCss.trim(); + const rgba = s.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, + ); + if (rgba) { + const r = Number(rgba[1]) / 255; + const g = Number(rgba[2]) / 255; + const b = Number(rgba[3]) / 255; + const a = rgba[4] !== undefined ? Number(rgba[4]) : 1; + const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const effective = lum * a + 0.05 * (1 - a); + return effective > 0.45 ? "#0a0a0a" : "#ffffff"; + } + const hex = s.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i); + if (hex) { + const r = parseInt(hex[1], 16) / 255; + const g = parseInt(hex[2], 16) / 255; + const b = parseInt(hex[3], 16) / 255; + const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return lum > 0.45 ? "#0a0a0a" : "#ffffff"; + } + return "#ffffff"; +} diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 0de365a0..0faf1449 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -170,6 +170,8 @@ export class ThemeManager { /** * After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW). * Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme(). + * The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a + * flavour (slave) variant id — **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer. */ public async prepareThemeAfterCloudSync(): Promise { try { diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index 3fbd0aef..aef94bc9 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -35,9 +35,9 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
-

3.6.4 - Fix for alpine theme & Assement dashlet improvement

+

3.6.4 - Theme syncing, falvours, fixes & Upcoming Assement dashlet improvement

  • Added advanced colour adjustments variables for theme customisation.
  • -
  • Improved logic for assement dashlet to improve compatibility.
  • +
  • Improved logic for upcoming assements dashlet to improve compatibility.
  • BS Cloud can now automatically download themes from other devices.
  • 3.6.3 - Assessment overview fix

    diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index cb9c7d59..d474fc76 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -143,7 +143,10 @@ export async function getSnapshotForUpload(): Promise<{ return buildUploadPayload(all as Record); } -/** Theme to ensure is installed locally after a downloaded envelope (explicit field overrides `data.selectedTheme`). */ +/** + * Theme to ensure is installed locally after a downloaded envelope (explicit `themeId` overrides `data.selectedTheme`). + * Works for any store-backed id, including **flavour (slave) variants** nested under masters in the catalogue. + */ export function resolveThemeIdForPostSyncDownload(envelope: unknown): string | undefined { if (envelope && typeof envelope === "object" && "themeId" in envelope) { const top = normalizeThemeIdForSync(