# Cloud settings sync — complete options & format reference

Exhaustive reference for every value that **is** or **would be** included in a BetterSEQTA Cloud settings backup, the exact JSON shapes stored in `chrome.storage.local`, and how those shapes appear on the wire.

**Related docs**

- Server HTTP contract: [CLOUD_SETTINGS_SYNC_SERVER.md](./CLOUD_SETTINGS_SYNC_SERVER.md)
- Client upload/download: `src/seqta/utils/cloudSettingsSync.ts`
- Auto-sync (debounce, poll, triggers): `src/background/cloudSettingsAutoSync.ts`
- Core defaults: `src/seqta/utils/defaultSettings.ts`
- Full-schema initializer: `src/seqta/utils/ensureSyncableStorageDefaults.ts`
- In-memory settings + persistence: `src/seqta/utils/listeners/SettingsState.ts`
- Type definitions: `src/types/storage.ts`

---

## Table of contents

1. [Architecture](#architecture)
2. [Sync lifecycle](#sync-lifecycle)
3. [Wire format (HTTP body)](#wire-format-http-body)
4. [Local storage model](#local-storage-model)
5. [Exclusion rules](#exclusion-rules)
6. [Legacy key migration](#legacy-key-migration)
7. [Core extension settings](#core-extension-settings)
8. [Plugin settings objects](#plugin-settings-objects)
9. [Plugin runtime storage keys](#plugin-runtime-storage-keys)
10. [Excluded caches (local-only schemas)](#excluded-caches-local-only-schemas)
11. [Data outside `chrome.storage.local`](#data-outside-chromestoragelocal)
12. [Catch-all & forward compatibility](#catch-all--forward-compatibility)
13. [UI accessibility](#ui-accessibility)
14. [Default schema initialization](#default-schema-initialization)

---

## Architecture

Cloud settings sync is a **whole-snapshot backup** of extension local storage:

```
┌─────────────────────────────────────────────────────────────────┐
│  chrome.storage.local  (flat string-keyed JSON values)          │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────────────┐ │
│  │ Core keys   │  │ plugin.*     │  │ Excluded (see §5)      │ │
│  │ onoff,      │  │ .settings    │  │ OAuth, device caches,  │ │
│  │ DarkMode,   │  │ .storage.*   │  │ client watermarks      │ │
│  │ menuitems…  │  │              │  │                        │ │
│  └─────────────┘  └──────────────┘  └────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
                             │ buildUploadPatch()
                             │  • ensureSyncableStorageDefaults (local)
                             │  • normalize + diff vs last upload / defaults
                             ▼
              PUT /api/bsplus/settings/sync (sparse data patch)
              { schemaVersion, themeId, data: { … } }
                             │
                             ▼
              accounts.betterseqta.org (one row per user)
```

**Design principle:** inclusive by default. Any new key written to `chrome.storage.local` is uploaded unless explicitly added to an omit list in `cloudSettingsSync.ts`.

**Not included:** IndexedDB, `localStorage`, `sessionStorage`, or SEQTA page DOM state.

---

## Sync lifecycle

### When upload runs

| Trigger | Behaviour |
|---------|-----------|
| Any **included** `chrome.storage.local` key changes | Debounced upload after **2000 ms** (`UPLOAD_DEBOUNCE_MS`) |
| Manual upload (settings UI) | Immediate `PUT` |
| First poll with no cloud backup and no local watermark | Baseline upload |
| `requestCloudSettingsDebouncedUpload()` | Same debounced path (e.g. after theme install) |

Upload is skipped when:

- `autoCloudSettingsSync === false`
- No `bsplus_token` (not signed in)
- `suppressAutoUploadDuringRestore` is true (during download)

### When download runs

| Trigger | Behaviour |
|---------|-----------|
| Manual restore (settings UI) | `GET` → `applyDownloadedEnvelope` → theme prefetch → reload SEQTA tabs |
| Auto poll | If `bsplus.updated_at` from `GET /api/user/cloud-summary` is newer than local watermark |
| First poll with cloud backup but no watermark | Full download |

Download is skipped when server `schemaVersion` > client `CLOUD_SETTINGS_SYNC_SCHEMA_VERSION` (currently `1`).

### Poll throttle

- Key: `bsplus_lastCloudPoll` — **never uploaded**
- Value: `number` (Unix ms timestamp)
- Minimum interval between poll runs: **24 hours** (`POLL_THROTTLE_MS`)

### Restore semantics

`applyDownloadedEnvelope` calls `browser.storage.local.set(remoteSanitized)` with **only** keys from the server (after migration + strip). It does **not** wipe storage:

- **OAuth keys** remain because they are stripped from the remote blob and never overwritten.
- **Excluded device caches** remain for the same reason.
- **Client-only keys** (`bsplus_cloud_settings_known_remote_updated_at`, etc.) remain unless accidentally present in an old server payload (stripped defensively).
- Keys present locally but **absent** from an older cloud snapshot are **not deleted**.

After download, if `themeId` / `selectedTheme` is non-empty, the service worker sets `bsplus_pending_theme_ensure_after_cloud` so the page `ThemeManager` can download missing theme assets from the store.

### Local schema before diff (upload)

`ensureSyncableStorageDefaults()` (`src/seqta/utils/ensureSyncableStorageDefaults.ts`) ensures every **cloud-syncable** key exists in `chrome.storage.local` with its default value if it was previously absent. This runs **locally only** so diffs against the last-uploaded snapshot (or schema defaults) are consistent. The PUT body is a **sparse patch** of changed keys only — defaults are not bulk-uploaded.

| When it runs | Context |
|--------------|---------|
| `browser.runtime.onInstalled` | Service worker (install + update) |
| `browser.runtime.onStartup` | Service worker |
| Service worker load | `background.ts` (once at startup) |
| `initializeSettingsState()` | SEQTA content script + extension settings page (first init) |
| Before each cloud upload | `putSettingsOnce()` in `cloudSettingsAutoSync.ts` |

Rules:

- Builds defaults from `getDefaultSettingsState()` plus each plugin’s `plugin.{id}.settings` defaults from `getAllPluginSettings()`.
- **Does not backfill legacy keys** (`animatedbk`, `bksliderinput`, etc.) — missing `plugin.*.settings` are derived from legacy via `migrateLegacyToPluginSettings(existing)` when patched.
- **Does not backfill optional keys** where `undefined` is intentional (`timeFormat`, `selectedFont`, dev/privacy/announcement flags, etc.) so existing behaviour is unchanged.
- Skips keys in the cloud omit lists (`isKeyIncludedInCloudUploadPayload`).
- **Never overwrites** existing storage values — only patches keys absent from storage (`key in existing` is false).
- Settings writes during bootstrap do not fire UI listeners (`SettingsState.bootstrapping`); user edits persist **only the changed key**, not the whole in-memory object.

---

## Wire format (HTTP body)

### `PUT /api/bsplus/settings/sync` request

```http
PUT /api/bsplus/settings/sync HTTP/1.1
Host: accounts.betterseqta.org
Authorization: Bearer <access_token>
Content-Type: application/json
```

```json
{
  "schemaVersion": 1,
  "themeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "data": {
    "DarkMode": true,
    "selectedTheme": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

Only keys that changed since the last successful upload appear in `data`. The server merges this patch into stored JSON; omitted keys are not deleted. Dev JSON export (`getSnapshotForUpload`) still returns the full normalized map for debugging.

| Top-level field | JSON type | Rules |
|-----------------|-----------|-------|
| `schemaVersion` | `number` | Always `1` today (`CLOUD_SETTINGS_SYNC_SCHEMA_VERSION`) |
| `themeId` | `string` | `normalizeThemeIdForSync(selectedTheme)` — trimmed; `""` if unset/invalid |
| `data` | `object` | Flat map: storage key → value. Same types as `chrome.storage.local`. No nesting convention beyond whatever each key stores. |

### `GET /api/bsplus/settings/sync` response

Same shape as the PUT body, plus optional:

```json
{
  "schemaVersion": 1,
  "themeId": "…",
  "data": { },
  "updated_at": "2026-04-07T12:00:00.000Z"
}
```

- `updated_at`: ISO 8601 UTC string; written to `bsplus_cloud_settings_known_remote_updated_at` locally (never re-uploaded).

### JSON encoding notes

- All values must be JSON-serializable (`boolean`, `number`, `string`, `null`, arrays, plain objects).
- `undefined` is never stored by the WebExtension storage API.
- Dates are stored as **strings** (ISO or calendar formats), not `Date` objects.
- `chrome.storage.local` may historically stringify some booleans in edge cases; the client treats them as booleans after read.

---

## Local storage model

### Flat key namespace

Every persisted setting is a **top-level key** in `chrome.storage.local`:

| Pattern | Example | Value shape |
|---------|---------|---------------|
| Core setting | `DarkMode` | scalar or structured JSON |
| Plugin settings | `plugin.global-search.settings` | single JSON **object** with all plugin prefs |
| Plugin storage | `plugin.messageFolders.storage.folders` | one JSON value per property |
| Analytics cache | `bsplus.analytics.v2.https://school.seqta.com.au.12345` | structured cache (excluded) |

Plugin settings use `plugin.{pluginId}.settings` where `pluginId` matches the plugin registration id (e.g. `messageFolders`, not `message-folders`).

Plugin runtime storage uses `plugin.{pluginId}.storage.{propertyName}` — each property is a **separate** storage key, not nested under one object.

### Settings vs storage

| Mechanism | API | Persisted keys | Synced? |
|-----------|-----|----------------|---------|
| `settingsState` / `SettingsState` | Proxy over in-memory + `storage.local` | Top-level keys (`onoff`, `menuitems`, …) | Yes (unless excluded) |
| Plugin `api.settings` | Proxy; one object per plugin | `plugin.{id}.settings` | Yes |
| Plugin `api.storage` | Proxy; one key per property | `plugin.{id}.storage.{prop}` | Yes, except excluded prefixes |

Component/button plugin settings (`type: "component"` | `"button"`) are **not** written by the settings proxy loop; only scalar settings defined in `plugin.settings` are persisted automatically. The settings UI may still write `enabled` and other keys via `browser.storage.local.set` directly.

---

## Exclusion rules

Implemented in `shouldOmitKeyFromCloudPayload(key)`:

### Exact key exclusions

| Key | Format if present locally | On restore |
|-----|---------------------------|------------|
| `bsplus_token` | `string` JWT | Keep device value |
| `bsplus_refresh_token` | `string` | Keep device value |
| `bsplus_client_id` | `string` UUID | Keep device value |
| `bsplus_user` | `CloudUser` object (see below) | Keep device value |
| `cloudAccessToken` | `string` (legacy) | Keep device value |
| `cloudUsername` | `string` (legacy) | Keep device value |
| `plugin.assessments-average.storage.assessments` | object | Keep device value |
| `plugin.assessments-average.storage.weightings` | object | Keep device value |
| `bsplus_cloud_settings_known_remote_updated_at` | ISO `string` | Keep device value |
| `bsplus_cloud_settings_last_uploaded_snapshot` | `object` (normalized syncable map) | Keep device value |
| `bsplus_lastCloudPoll` | `number` (ms) | Keep device value |
| `bsplus_pending_theme_ensure_after_cloud` | `string` (theme id) | Keep device value |

#### `bsplus_user` shape (local only, never uploaded)

```json
{
  "id": "uuid",
  "email": "user@example.com",
  "username": "optional",
  "displayName": "optional",
  "pfpUrl": "https://…",
  "pfpHash": "abc123" ,
  "admin_level": 0
}
```

All fields except `id` are optional.

### Prefix exclusions

| Prefix | Matches |
|--------|---------|
| `plugin.global-search.storage.` | Any Global Search device cache key |
| `bsplus.analytics.` | Grade Analytics caches and chart mode prefs |

Prefix check: `key.startsWith(prefix)`.

---

## Legacy key migration

Runs in `migrateLegacyToPluginSettings()` on **both** upload and download. Legacy keys are **removed** from `data` after migration. Migration only fills plugin settings fields that are still `undefined`.

| Legacy key | Legacy format | Target key | Target field | Conversion |
|------------|---------------|------------|--------------|------------|
| `animatedbk` | `boolean` | `plugin.animated-background.settings` | `enabled` | `!!animatedbk` |
| `bksliderinput` | `string` `"0"`–`"100"` | `plugin.animated-background.settings` | `speed` | `speed = round((0.1 + (parseFloat(s) / 100) * 1.9) * 100) / 100` → range **0.1–2.0** |
| `assessmentsAverage` | `boolean` | `plugin.assessments-average.settings` | `enabled` | `!!assessmentsAverage` |
| `lettergrade` | `boolean` | `plugin.assessments-average.settings` | `lettergrade` | `!!lettergrade` |
| `notificationCollector` | `boolean` only | `plugin.notificationCollector.settings` | `enabled` | copy boolean |

**Example:** legacy `bksliderinput: "50"` → `speed: 1.05` because `0.1 + 0.5 * 1.9 = 1.05`.

Modern clients should only see plugin-format keys in uploaded payloads; legacy keys may still exist on very old profiles until first sync.

---

## Core extension settings

Top-level `chrome.storage.local` keys from `SettingsState` (`src/types/storage.ts`). Defaults from `getDefaultSettingsState()` in `src/seqta/utils/defaultSettings.ts` unless noted. Missing keys are backfilled by `ensureSyncableStorageDefaults()` (see [Default schema initialization](#default-schema-initialization)).

### Master & appearance

#### `onoff`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `true` |
| **UI** | Settings → “BetterSEQTA+” master switch |
| **Example** | `true` |

#### `DarkMode`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `true` |
| **UI** | SEQTA UI / theme system |
| **Example** | `true` |

#### `selectedTheme`

| | |
|-|-|
| **Type** | `string` |
| **Default** | `""` |
| **UI** | Theme selector / store install |
| **Format** | BetterSEQTA store theme UUID, **flavour (slave) variant** id, or empty string for no store theme |
| **Wire** | Duplicated as top-level `themeId` on upload (trimmed) |
| **Example** | `"f47ac10b-58cc-4372-a567-0e02b2c3d479"` or `""` |

Theme **asset blobs** (CSS, images) live in **localforage/IndexedDB**, not in this key. After restore, only the id is synced; assets are re-fetched if missing.

#### `selectedColor`

| | |
|-|-|
| **Type** | `string` |
| **Default** | `"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)"` |
| **UI** | Colour picker |
| **Format** | Any valid CSS `background` value: hex (`#c93d00`), `rgb()`, `rgba()`, `linear-gradient(...)`, etc. |
| **Example** | `"#c93d00"` or `"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)"` |

#### `originalSelectedColor`

| | |
|-|-|
| **Type** | `string` |
| **Default** | `""` |
| **Purpose** | Colour before theme preview; restored when clearing theme |
| **Example** | `"#c93d00"` |

#### `originalDarkMode`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | undefined until theme preview |
| **Purpose** | Dark mode before theme preview |
| **Example** | `true` |

#### `transparencyEffects`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `false` |
| **UI** | Settings → Transparency Effects |
| **Example** | `false` |

#### `animations`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `true` on normal devices; `false` if low-end (`hardwareConcurrency < 4` or `deviceMemory <= 2`) |
| **UI** | Settings → Animations |
| **Example** | `true` |

#### `iconOnlySidebar`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `false` |
| **UI** | Settings → Icon Only Sidebar |
| **Example** | `false` |

#### `selectedFont`

| | |
|-|-|
| **Type** | `string` |
| **Default** | undefined → runtime treats as `"rubik"` |
| **UI** | Settings → Interface Font |
| **Format** | Font preset id from `src/seqta/ui/fonts/presets.ts` |

Allowed ids:

`rubik`, `inter`, `poppins`, `nunito`, `montserrat`, `open-sans`, `lato`, `source-sans-3`, `raleway`, `dm-sans`, `plus-jakarta-sans`, `outfit`, `roboto`, `work-sans`, `manrope`, `figtree`, `lexend`, `ubuntu`, `karla`, `quicksand`, `ibm-plex-sans`, `space-grotesk`, `mulish`, `cabin`, `oswald`, `merriweather`, `playfair-display`, `lora`, `crimson-pro`, `libre-baskerville`, `system`

**Example:** `"inter"`

#### `adaptiveThemeColour`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `false` |
| **Example** | `false` |

#### `adaptiveThemeGradient`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `false` |
| **Example** | `false` |

#### `adaptiveThemeColourTransition`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `true` |
| **Example** | `true` |

### Navigation & layout

#### `defaultPage`

| | |
|-|-|
| **Type** | `string` |
| **Default** | `"home"` |
| **UI** | Settings → Default Page |
| **Allowed** | `"home"`, `"dashboard"`, `"timetable"`, `"welcome"`, `"messages"`, `"documents"`, `"reports"` |
| **Example** | `"home"` |

#### `timeFormat`

| | |
|-|-|
| **Type** | `string` |
| **Default** | undefined (24-hour behaviour) |
| **UI** | Settings → “12 Hour Time” switch |
| **Values** | `"12"` when enabled; `"24"` when disabled |
| **Example** | `"12"` |

#### `lessonalert`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `true` |
| **Purpose** | Lesson alert on home page |
| **Example** | `true` |

#### `menuitems`

| | |
|-|-|
| **Type** | `object` |
| **Default** | All sidebar items `{ toggle: true }` |
| **UI** | Menu editor overlay |
| **Shape** | `{ [menuKey]: { toggle: boolean } }` |

Keys (from `SettingsState`):

`assessments`, `courses`, `dashboard`, `documents`, `forums`, `goals`, `home`, `messages`, `myed`, `news`, `notices`, `portals`, `reports`, `settings`, `timetable`, `welcome`

**Example:**

```json
{
  "home": { "toggle": true },
  "timetable": { "toggle": false },
  "messages": { "toggle": true }
}
```

#### `menuorder`

| | |
|-|-|
| **Type** | `array` |
| **Default** | `[]` |
| **UI** | Menu editor (drag order) |
| **Item type** | `string` — SEQTA menu `data-key` values (e.g. `"home"`, `"timetable"`, `"analytics"` for plugin-injected items) |
| **Example** | `["home", "timetable", "assessments", "messages"]` |

#### `defaultmenuorder`

| | |
|-|-|
| **Type** | `array` |
| **Default** | `[]` (populated from DOM on first menu edit) |
| **Item type** | Same as `menuorder` — snapshot of school default order |
| **Example** | `["home", "dashboard", "timetable"]` |

### Shortcuts

#### `shortcuts`

| | |
|-|-|
| **Type** | `array` |
| **Default** | Outlook, Office, Google — each `enabled: true` |
| **UI** | Settings → Shortcuts |
| **Item shape** | `{ "name": string, "enabled": boolean }` |
| **Notes** | `name` matches keys in `src/seqta/content/links.json` (e.g. `"Outlook"`, `"YouTube"`) |

**Example:**

```json
[
  { "name": "Outlook", "enabled": true },
  { "name": "Office", "enabled": false },
  { "name": "Google", "enabled": true }
]
```

#### `customshortcuts`

| | |
|-|-|
| **Type** | `array` |
| **Default** | `[]` |
| **UI** | Settings → Shortcuts → add custom |
| **Item shape** | `{ "name": string, "url": string, "icon": string }` |
| **`url`** | Full URL with protocol, e.g. `"https://example.com"` |
| **`icon`** | Inline **SVG markup string** OR single fallback character (first letter of title) |

**Example:**

```json
[
  {
    "name": "School Portal",
    "url": "https://portal.school.edu.au",
    "icon": "<svg viewBox=\"0 0 24 24\">…</svg>"
  }
]
```

### Subjects & news

#### `subjectfilters`

| | |
|-|-|
| **Type** | `object` |
| **Default** | `{}` |
| **UI** | Home / assessments subject filters |
| **Shape** | `{ [subjectCode: string]: boolean }` — `false` hides subject; missing key = visible |
| **Example** | `{ "10MAT": false, "10ENG": true }` |

#### `newsSource`

| | |
|-|-|
| **Type** | `string` |
| **Default** | `"australia"` |
| **UI** | Settings → News Feed Source |
| **Allowed** | `australia`, `usa`, `uk`, `taiwan`, `hong_kong`, `panama`, `canada`, `singapore`, `japan`, `netherlands` |
| **Example** | `"australia"` |

### Cloud sync preference

#### `autoCloudSettingsSync`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `true` |
| **UI** | Settings → Cloud Settings Sync |
| **Semantics** | Sync runs when **not** strictly `false` (default-on) |
| **Example** | `true` |

### Theme of the Month

#### `themeOfTheMonthDisabled`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | `false` |
| **UI** | Global Search plugin section → Theme of the Month switch (inverted in UI) |
| **Example** | `false` |

#### `themeOfTheMonthDismissedMonth`

| | |
|-|-|
| **Type** | `string` |
| **Format** | `"YYYY-MM"` calendar month |
| **Example** | `"2026-06"` |

#### `themeOfTheMonthLastSeenId`

| | |
|-|-|
| **Type** | `string` |
| **Status** | Deprecated; may still exist in old profiles |
| **Synced** | Yes if present |

### One-time announcements & privacy

#### `privacyStatementShown`

| | |
|-|-|
| **Type** | `boolean` |
| **Example** | `true` |

#### `privacyStatementLastUpdated`

| | |
|-|-|
| **Type** | `string` |
| **Format** | ISO date, e.g. `"2025-12-20"` |

#### `engageParentsAnnouncementShown`

| | |
|-|-|
| **Type** | `boolean` |
| **Purpose** | SEQTA Engage parents announcement dismissed |

#### `bsCloudAutoSyncAnnouncementShown`

| | |
|-|-|
| **Type** | `boolean` |
| **Purpose** | Cloud auto-sync announcement dismissed |

### Update & developer flags

#### `justupdated`

| | |
|-|-|
| **Type** | `boolean` |
| **Purpose** | Set `true` after extension update; drives startup popup queue |
| **Example** | `true` |

#### `devMode`

| | |
|-|-|
| **Type** | `boolean` |
| **Default** | undefined / false |
| **UI** | Hidden unlock in settings |
| **Example** | `true` |

#### `hideSensitiveContent`

| | |
|-|-|
| **Type** | `boolean` |
| **UI** | Dev mode → Sensitive Hider |
| **Example** | `false` |

#### `mockNotices`

| | |
|-|-|
| **Type** | `boolean` |
| **UI** | Dev mode → Mock Notices |
| **Example** | `false` |

#### `devGhReleaseVersionOverride`

| | |
|-|-|
| **Type** | `string` |
| **UI** | Dev mode → version override field |
| **Format** | Semver string, e.g. `"3.7.0"` |
| **Example** | `"99.0.0"` |

#### `lastSeenNightlyPublishedAt`

| | |
|-|-|
| **Type** | `string` |
| **Format** | ISO 8601 timestamp from GitHub release `published_at` |
| **Example** | `"2026-06-01T04:30:00Z"` |

### Profile picture helper

#### `profile_picture_revision`

| | |
|-|-|
| **Type** | `number` |
| **Purpose** | Incremented when cloud/local profile picture changes; triggers UI refresh |
| **Not** | The image itself (stored in localforage `profile-picture-store`) |
| **Example** | `3` |

---

## Plugin settings objects

Each plugin stores one object at `plugin.{pluginId}.settings`. Plugins with `disableToggle: true` also store `enabled: boolean` (written from settings UI, not the settings proxy).

Settings of type `component` or `button` are **not** auto-persisted by the plugin settings proxy.

### `plugin.animated-background.settings`

| Field | Type | Default | Range / notes |
|-------|------|---------|---------------|
| `enabled` | `boolean` | `true` | From `disableToggle` UI |
| `speed` | `number` | `1` | **0.1–2.0**, step 0.05 in UI |

```json
{ "enabled": true, "speed": 1.05 }
```

### `plugin.assessments-average.settings`

| Field | Type | Default |
|-------|------|---------|
| `enabled` | `boolean` | `false` |
| `lettergrade` | `boolean` | `false` |

```json
{ "enabled": true, "lettergrade": false }
```

### `plugin.notificationCollector.settings`

| Field | Type | Default |
|-------|------|---------|
| `enabled` | `boolean` | `true` |

```json
{ "enabled": true }
```

### `plugin.timetable.settings`

| Field | Type | Default |
|-------|------|---------|
| `enabled` | `boolean` | `true` |

```json
{ "enabled": true }
```

### `plugin.timetableEdit.settings`

| Field | Type | Default |
|-------|------|---------|
| `enabled` | `boolean` | `true` |

```json
{ "enabled": true }
```

### `plugin.global-search.settings`

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `enabled` | `boolean` | `false` | Plugin default off |
| `searchHotkey` | `string` | `"ctrl+k"` or `"cmd+k"` | See hotkey format below |
| `showRecentFirst` | `boolean` | `true` | |
| `transparencyEffects` | `boolean` | `true` | Search bar blur |
| `runIndexingOnLoad` | `boolean` | `true` | |
| `passiveIndexing` | `boolean` | `true` | Index visited pages |

```json
{
  "enabled": true,
  "searchHotkey": "ctrl+shift+f",
  "showRecentFirst": true,
  "transparencyEffects": true,
  "runIndexingOnLoad": true,
  "passiveIndexing": true
}
```

#### Hotkey string format (`searchHotkey`)

- Lowercase, `+`-separated: `modifier[+modifier]+key`
- Modifiers: `ctrl`/`control`, `cmd`/`meta`/`command`, `alt`/`option`, `shift`
- Key: single character or name (`k`, `f`, etc.) — matched against `event.key` case-insensitively
- Valid iff at least one non-modifier key is present (`isValidHotkey`)
- **Examples:** `"ctrl+k"`, `"cmd+shift+p"`, `"alt+f"`

### `plugin.profile-picture.settings`

| Field | Type | Default |
|-------|------|---------|
| `enabled` | `boolean` | `false` |
| `useCloudPfp` | `boolean` | `false` |

```json
{ "enabled": true, "useCloudPfp": true }
```

When `useCloudPfp` is true, avatar URL comes from `bsplus_user.pfpUrl` (local auth, not synced) — other devices need their own login.

### `plugin.messageFolders.settings`

| Field | Type | Default |
|-------|------|---------|
| `enabled` | `boolean` | `true` |
| `showTagsInAllMessages` | `boolean` | `true` |
| `hideFolderedMessagesInAll` | `boolean` | `true` |

```json
{
  "enabled": true,
  "showTagsInAllMessages": true,
  "hideFolderedMessagesInAll": true
}
```

### `plugin.enhanced-navigation.settings`

| Field | Type | Default |
|-------|------|---------|
| `enabled` | `boolean` | `true` |
| `autoScrollOnClick` | `boolean` | `false` |

```json
{ "enabled": true, "autoScrollOnClick": false }
```

### `plugin.background-music.settings`

| Field | Type | Default | Range |
|-------|------|---------|-------|
| `enabled` | `boolean` | `false` | |
| `volume` | `number` | `0.5` | 0–1 |
| `pauseOnHidden` | `boolean` | `true` | |

```json
{ "enabled": true, "volume": 0.75, "pauseOnHidden": true }
```

Audio blob: localforage `background-music-store` / `music` / key `audio-blob` — **not synced**.

### `plugin.grade-analytics.settings`

| Field | Type | Default | Range |
|-------|------|---------|-------|
| `cacheTtlHours` | `number` | `24` | 1–168 |

```json
{ "cacheTtlHours": 48 }
```

No `enabled` field — plugin uses `disableToggle: false` and always runs when registered (except Engage).

### Unused plugin settings keys

- `plugin.themes.settings` — not written (empty plugin settings)
- `plugin.assessments-overview.settings` — not written

---

## Plugin runtime storage keys

Separate top-level keys per property: `plugin.{pluginId}.storage.{propertyName}`.

### Message Folders (`plugin.messageFolders`)

#### `plugin.messageFolders.storage.folders`

```json
[
  {
    "id": "m1abc2def",
    "name": "Important",
    "color": "#3b82f6",
    "emoji": "<svg …></svg>"
  }
]
```

| Field | Type | Notes |
|-------|------|-------|
| `id` | `string` | `Date.now().toString(36) + random` |
| `name` | `string` | Display name |
| `color` | `string` | Hex colour from preset palette |
| `emoji` | `string` | Inline SVG icon markup (not unicode emoji) |

#### `plugin.messageFolders.storage.messageAssignments`

```json
{
  "msg-uuid-1": ["folderId1"],
  "msg-uuid-2": ["folderId1", "folderId2"]
}
```

Keys: SEQTA message identifiers. Values: arrays of folder `id` strings.

### Timetable Edit (`plugin.timetableEdit`)

#### `plugin.timetableEdit.storage.timetableOverrides`

Keyed by class instance id (`ci` as string):

```json
{
  "42": { "room": "B204", "staff": "Mr Smith" },
  "17": { "staff": "Ms Jones" }
}
```

#### `plugin.timetableEdit.storage.timetableOverridesBySubject`

Keyed by subject description string:

```json
{
  "10 Mathematics": { "room": "MA1" }
}
```

Entry shape: `{ "room"?: string, "staff"?: string }` — either field optional.

### Assessment Averages (`plugin.assessments-average`)

#### `plugin.assessments-average.storage.weightingOverrides` — **synced**

User manual weight overrides by assessment id:

```json
{
  "123456": "25",
  "123457": "N/A"
}
```

Values are weight strings as shown in UI (percentage text or `"N/A"`).

#### `plugin.assessments-average.storage.assessments` — **NOT synced**

Title → assessment id map (school data):

```json
{
  "Semester 1 Exam": "123456"
}
```

#### `plugin.assessments-average.storage.weightings` — **NOT synced**

```json
{
  "123456": {
    "weight": "25",
    "fingerprint": "[\"GRADED\",true,\"…\"]",
    "pluginVersion": 1,
    "refreshing": false
  }
}
```

| Field | Type | Notes |
|-------|------|-------|
| `weight` | `string` | e.g. `"25"`, `"processing"`, `"N/A"` |
| `fingerprint` | `string` | JSON-stringified assessment state fingerprint |
| `pluginVersion` | `number` | `WEIGHTING_SCHEMA_VERSION` (= 1) |
| `refreshing` | `boolean` | optional; background refetch in progress |

### Notification Collector (`plugin.notificationCollector`)

| Key | Type | Example |
|-----|------|---------|
| `plugin.notificationCollector.storage.lastNotificationCount` | `number` | `3` |
| `plugin.notificationCollector.storage.consecutiveErrors` | `number` | `0` |
| `plugin.notificationCollector.storage.lastCheckedTime` | `string` | `"2026-06-09T14:30:00.000Z"` |

All **synced** (not under an excluded prefix).

---

## Excluded caches (local-only schemas)

### Grade Analytics — prefix `bsplus.analytics.`

#### `bsplus.analytics.v2.{origin}.{studentId}`

- `origin`: full school origin, e.g. `https://school.seqta.com.au` (dots in hostname appear in key)
- `studentId`: numeric SEQTA student id

```json
{
  "updatedAt": 1717939200000,
  "assessments": [
    {
      "id": 1,
      "title": "Assignment 1",
      "subject": "10 Mathematics",
      "status": "MARKS_RELEASED",
      "due": "2026-03-15",
      "code": "10MAT",
      "metaclassID": 100,
      "programmeID": 10,
      "graded": true,
      "overdue": false,
      "hasFeedback": true,
      "expectationsEnabled": false,
      "expectationsCompleted": false,
      "reflectionsEnabled": false,
      "reflectionsCompleted": false,
      "availability": "FULL",
      "finalGrade": 85,
      "letterGrade": "A"
    }
  ]
}
```

`status` enum: `"OVERDUE"` | `"MARKS_RELEASED"` | `"PENDING"`.

#### `bsplus.analytics.distMode.v1.{origin}.{studentId}`

Scalar string: `"auto"` | `"letter"` | `"percent"`.

### Global Search — prefix `plugin.global-search.storage.`

Reserved for future device-local caches. No keys in use today; any key matching the prefix is excluded.

---

## Data outside `chrome.storage.local`

These affect UX but **never** appear in the cloud `data` blob:

| Store | Location | Contents |
|-------|----------|----------|
| Global Search structured index | IndexedDB `betterseqta-index` | Searchable page text |
| Global Search vectors | IndexedDB `embeddiaDB` | Embedding index |
| Global Search schema version | `localStorage` key `betterseqta-index-version` | Index migration |
| Installed themes | localforage (default instance) | `CustomTheme` objects keyed by theme id; index `customThemes` |
| Profile picture upload | localforage `profile-picture-store` / `profilePicture` | Image blob |
| Cloud PFP cache | localforage `cloud-pfp-store` / `cloudPfp` | Cached avatar blobs |
| Background music | localforage `background-music-store` / `music` / `audio-blob` | Audio blob |
| Dev API override | `sessionStorage` `bsplus_dev_api_base` | Staging server URL |
| Engage student context | `localStorage` `bsplus.engageTimetable.student.{origin}` | Current student id |

After cloud restore, devices may need to **rebuild** indexes, **re-upload** media, or **re-download** themes even when synced settings reference them.

---

## Catch-all & forward compatibility

### Inclusion test

```ts
isKeyIncludedInCloudUploadPayload(key) === !shouldOmitKeyFromCloudPayload(key)
```

Any new `chrome.storage.local` key syncs automatically unless added to:

- `KEYS_OMITTED_FROM_CLOUD_UPLOAD`
- `SENSITIVE_DEVICE_STORAGE_KEYS_EXACT`
- `CLIENT_ONLY_CLOUD_KEYS_EXACT`
- `SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES`

### Upload trigger flow

1. `browser.storage.onChanged` (area `local`)
2. At least one changed key passes `isKeyIncludedInCloudUploadPayload`
3. `autoCloudSettingsSync !== false`
4. Valid access token
5. Not during restore → schedule 2 s debounce → `PUT` sparse patch (skip if empty)

### Server storage suggestion

Store the full PUT body (or at minimum `{ schemaVersion, themeId, data }`) as JSONB per user. `data` alone is sufficient for restore if `themeId` is duplicated inside `data.selectedTheme`.

### Versioning

- Client `schemaVersion`: **1**
- If server returns higher `schemaVersion` in cloud-summary, auto-download is skipped until client is updated.

---

## UI accessibility

Which synced keys can be changed directly by the user in the extension **settings popup** (SEQTA → BetterSEQTA+ settings) or other in-product UI — vs keys that are written only by the app, popups, or plugins at runtime.

**Settings popup tabs:** Settings · Shortcuts · Themes (`src/interface/pages/settings.svelte`).

### Extension popup — Settings tab (`general.svelte`)

| Storage key / path | UI label | Control |
|--------------------|----------|---------|
| `iconOnlySidebar` | Icon Only Sidebar | Switch |
| `animations` | Animations | Switch |
| `timeFormat` | 12 Hour Time | Switch (`"12"` / `"24"`) |
| `transparencyEffects` | Transparency Effects | Switch |
| `defaultPage` | Default Page | Select |
| `newsSource` | News Feed Source | Select |
| `selectedColor` | Custom Theme Colour | Button → colour picker modal |
| `selectedFont` | Interface Font | Button → font picker modal |
| `adaptiveThemeColour` | Adaptive Theme Colour | Switch |
| `adaptiveThemeGradient` | Soft Gradient | Switch (when adaptive colour on) |
| `adaptiveThemeColourTransition` | Smooth colour transition | Switch (when adaptive colour on) |
| `menuorder`, `menuitems`, `defaultmenuorder` | Edit Sidebar Layout | Button → opens editor **on the SEQTA tab** (not inside the popup) |
| `themeOfTheMonthDisabled` | Theme of the Month | Switch (under Global Search plugin block) |
| `autoCloudSettingsSync` | Automatic sync | Switch (BetterSEQTA Cloud card, signed in only) |
| `onoff` | BetterSEQTA+ | Switch (bottom of tab) |

#### Plugin blocks on Settings tab

Each row is `plugin.{id}.settings.{field}` unless noted. Component settings (upload UIs) change synced **preference** keys but not blob data in IndexedDB.

| Plugin | UI | Synced fields from UI |
|--------|-----|------------------------|
| **Animated Background** | Enable + Animation Speed slider | `enabled`, `speed` |
| **Assessment Averages** | Enable (+ disclaimer) + Letter Grades | `enabled`, `lettergrade` |
| **Notification Collector** | Enable only | `enabled` |
| **Timetable Enhancer** | Enable only | `enabled` |
| **Edit Rooms & Teachers** | Enable only | `enabled` |
| **Global Search** | Enable + hotkey + 4 toggles + Reset Index button | `enabled`, `searchHotkey`, `showRecentFirst`, `transparencyEffects`, `runIndexingOnLoad`, `passiveIndexing` — Reset Index does **not** sync index data |
| **Custom Profile Picture** | Enable + Use cloud PFP (if signed in) + upload/remove | `enabled`, `useCloudPfp`; upload updates `profile_picture_revision` (image blob **not** synced) |
| **Message Folders** | Enable + 2 toggles | `enabled`, `showTagsInAllMessages`, `hideFolderedMessagesInAll` |
| **Enhanced Navigation** | Enable + Auto-scroll | `enabled`, `autoScrollOnClick` |
| **Background Music** | Enable + volume + pause on hidden + upload | `enabled`, `volume`, `pauseOnHidden`; audio blob **not** synced |
| **Grade Analytics** | Cache duration (hours) slider only | `cacheTtlHours` |

**Not shown on Settings tab** (empty `settings` + no enable toggle → card hidden):

- **Themes** plugin — use **Themes** tab instead
- **Assessments Overview** plugin — no settings UI

#### Dev mode only (Settings tab, after typing “dev” on logo)

| Storage key | UI label |
|-------------|----------|
| `devMode` | Developer Mode |
| `hideSensitiveContent` | Sensitive Hider |
| `mockNotices` | Mock Notices |
| `privacyStatementShown`, `privacyStatementLastUpdated` | Show Privacy Notification (resets to show popup) |
| `devGhReleaseVersionOverride` | GitHub latest version override (text field) |

Dev **Export cloud settings JSON** downloads the upload payload; it does not change storage.

### Extension popup — Shortcuts tab (`shortcuts.svelte`)

| Storage key | UI |
|-------------|-----|
| `shortcuts` | Toggle built-in shortcuts (Outlook, Office, Google, … from `links.json`) |
| `customshortcuts` | Add / delete custom shortcuts (name, URL, optional SVG icon) |

Changes use `settingsState.setKey()` so the SEQTA home page updates immediately via `StorageChangeHandler` → `renderShortcuts()` (embedded settings run in the content script, where `storage.onChanged` does not fire for local writes). Empty arrays are persisted explicitly (`customshortcuts: []`) so cloud restore and other devices clear removed shortcuts.

### Extension popup — Themes tab (`theme.svelte`)

| Storage key | UI |
|-------------|-----|
| `selectedTheme` | Install / select / clear store themes (`ThemeSelector`) |
| `selectedColor` | May change when applying a theme with a default colour |
| `originalSelectedColor`, `originalDarkMode` | Set internally during theme **preview** in theme manager (not dedicated controls) |

Animated background selection uses `BackgroundSelector` (local/custom backgrounds); store theme id still goes to `selectedTheme`.

### SEQTA page UI (outside settings popup)

| Storage key / path | Where | Control |
|--------------------|-------|---------|
| `DarkMode` | SEQTA top bar | Sun/moon **Light/Dark** button |
| `menuorder`, `menuitems` | SEQTA sidebar | **Edit Sidebar Layout** overlay (drag order + per-item toggles) |
| `subjectfilters` | Home → upcoming assessments | Per-subject checkboxes (`#upcoming-filters`) |
| `subjectfilters` | Assessments → Overview | Subject filter UI in overview grid |
| `plugin.messageFolders.storage.folders` | Messages page | Create/edit/delete folders |
| `plugin.messageFolders.storage.messageAssignments` | Messages page | Assign messages to folders |
| `plugin.timetableEdit.storage.timetableOverrides` | Timetable | Edit room/teacher on a class |
| `plugin.timetableEdit.storage.timetableOverridesBySubject` | Timetable | Subject-level overrides |
| `plugin.assessments-average.storage.weightingOverrides` | Assessments page | Per-assessment “Override %” inputs |
| Global Search | SEQTA | Hotkey opens search bar (hotkey set in Settings) |

### Automatic / popup-only (synced, no dedicated settings control)

| Storage key / path | How it changes |
|--------------------|----------------|
| `lessonalert` | Default only — **no UI** (read by home loader) |
| `privacyStatementShown`, `privacyStatementLastUpdated` | Privacy popup on first run (dev can reset) |
| `engageParentsAnnouncementShown` | Dismiss Engage parents announcement |
| `bsCloudAutoSyncAnnouncementShown` | Dismiss cloud sync announcement |
| `themeOfTheMonthDismissedMonth` | Dismiss Theme of the Month popup |
| `justupdated` | Set `true` after extension update |
| `lastSeenNightlyPublishedAt` | Dismiss / acknowledge GitHub nightly update badge |
| `profile_picture_revision` | Incremented when profile picture changes |
| `plugin.notificationCollector.storage.*` | Written by notification poll loop |
| Legacy `animatedbk`, `bksliderinput`, etc. | Migrated to plugin settings; no longer shown in UI |

### Synced plugin prefs with UI vs storage-only side effects

| User action in UI | Synced | Not synced |
|-------------------|--------|------------|
| Upload profile picture | `useCloudPfp`, `profile_picture_revision` | Image blob (localforage) |
| Upload background music | `enabled`, `volume`, `pauseOnHidden` | Audio blob (localforage) |
| Install store theme | `selectedTheme`, often `selectedColor` / `DarkMode` | Theme assets (localforage) |
| Global Search indexing | Plugin settings toggles | IndexedDB `betterseqta-index`, `embeddiaDB` |
| Assessment averages | `lettergrade`, overrides | `storage.assessments`, `storage.weightings` caches |
| Grade Analytics TTL slider | `cacheTtlHours` | `bsplus.analytics.*` caches |

### Never in settings UI (excluded from sync entirely)

OAuth keys, analytics caches, assessment weighting caches, global-search storage prefix, client watermarks — see [Exclusion rules](#exclusion-rules).

---

## Default schema initialization

Implementation: `ensureSyncableStorageDefaults()` + `getSyncableStorageDefaults()`.

### What gets written when a key is missing

1. **All core `SettingsState` fields** from `getDefaultSettingsState()` — including `shortcuts`, `customshortcuts: []`, `menuitems`, `selectedFont: "rubik"`, `timeFormat: "24"`, announcement flags defaulting to `false`, etc.
2. **Every registered plugin’s** `plugin.{pluginId}.settings` object (defaults from plugin definitions; `enabled` included for `disableToggle` plugins).
3. **Legacy migration** on that flat map so upload-shaped storage uses `plugin.animated-background.settings` rather than `animatedbk` / `bksliderinput`.

### What is not auto-initialized

| Category | Reason |
|----------|--------|
| OAuth / session keys | Excluded from cloud; device-local |
| `plugin.*.storage.*` runtime data | Created when plugins run (folders, timetable overrides, etc.) |
| `bsplus.analytics.*` | Excluded device/school caches |
| `plugin.assessments-average.storage.assessments` / `.weightings` | Excluded school caches |
| `plugin.global-search.storage.*` | Excluded prefix |
| Client-only watermarks | Never uploaded |

### Settings persistence and live UI

`SettingsState` (`src/seqta/utils/listeners/SettingsState.ts`):

- Assignments (`settingsState.foo = …`) and `setKey()` persist to `chrome.storage.local` and **notify registered listeners** in the same context (required for embedded SEQTA settings).
- `saveToStorage()` omits `undefined` values so optional keys are not accidentally stripped.
