Ver Fonte

Add the per-account Spry theme picker in account settings

Goal 41 T2. The /profile page gains an Appearance card: the theme as
eight Spry choices (the core default plus the seven shipped theme slugs
airy, dwarf, formal, glassy, h2o, living-sky, modern) and the colour
mode as auto/light/dark, posted through the sealed SetThemeAction. The
choice is validated against the known slugs (anything else answers a
friendly notify error and writes nothing), persisted per user in the
new user_preferences table (M0011, composite primary key user_id+key,
raw-SQL upsert because the ORM keys updates on a single pk column) and
written to the DEVICE-scope ui slot carrying {theme, mode} so the
client applies it immediately and caches it across sessions. Login
seeds that slot from the stored preferences. app.js gains the
applyUiSlot hook: on statum init and after every render cycle it sets
or removes data-theme and data-mode on the html element - the theme
stylesheets T1 linked stay inert until data-theme scopes them, so no
stylesheet swapping or reloads. docs/spry-conversion.md documents the
theme-slot contract.

The pages bundle's migration-chain assertion extends to serial 11. The
new theme bundle covers the full contract: the Appearance card and all
choices on the served page, the seven theme stylesheets served, the
persisted rows, the ui slot public data and DEVICE scope, the friendly
refusals, the upsert overwrite and the login re-seed.
saddle-ClankerSaddle há 1 dia atrás
pai
commit
98bd6e30b9

+ 33 - 0
docs/spry-conversion.md

@@ -22,6 +22,39 @@ map from the old stylesheet, and the rules for new markup.
 Color mode is `data-mode="auto"` on `<html>` (spry's default behavior
 — omitting the attribute is also auto). Never hard-code a mode.
 
+## The theme slot contract (T2 — the account-settings picker)
+
+The user's saved appearance lives in TWO places with ONE source of
+truth:
+
+- **Server side**: the `user_preferences` table (M0011) stores
+  `theme` ("" = the core default, or one of the seven slugs) and
+  `mode` (auto|light|dark) per `spry_users.id` — see
+  `Services.UserPreferenceService`. This is the durable record the
+  login seeds from.
+- **Client side**: the DEVICE-scope Statum slot `"ui"` (public data
+  `{theme, mode}`) — written by `SetThemeAction` on save and re-seeded
+  by `LoginAction` at login. DEVICE scope means the browser caches it
+  in `localStorage`, so a returning visit paints the chosen theme
+  before any server round-trip (no FOUC beyond the very first visit,
+  which sees the Spry default — itself the default).
+
+`app.js`'s `applyUiSlot` hook reads the slot on statum init and after
+every render cycle and does the only client-side work there is:
+`data-theme="slug"` on `<html>` when a theme is set (attribute
+REMOVED for the core default — no `data-theme="core"`), and
+`data-mode="light|dark"` when forced (always present; `auto` is the
+default value). No other JS, stylesheet swaps or reloads — the seven
+theme stylesheets are always linked and inert until `data-theme`
+scopes them.
+
+Per-account on a per-device slot: two humans sharing a browser
+profile each get their own choice once they log in; a single user on
+two devices picks per device (the DEVICE slot is written on every
+save and every login of that user, so the LAST login on a device
+wins). Deleting the slot client-side (`statum.clear('ui')`) reverts
+to the Spry default until the next login.
+
 ## Where styles live — the layering contract
 
 - **spry.css owns every primitive**: typography, links, focus rings,

+ 2 - 0
meson.build

@@ -226,6 +226,7 @@ app_sources = files(
     'src/Data/Migrations/M0008_AnswerWake.vala',
     'src/Data/Migrations/M0009_TurnCapPark.vala',
     'src/Data/Migrations/M0010_ModelConfig.vala',
+    'src/Data/Migrations/M0011_UserPreferences.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -247,6 +248,7 @@ app_sources = files(
     'src/Services/PushService.vala',
     'src/Services/ChatService.vala',
     'src/Services/ModelConfigService.vala',
+    'src/Services/UserPreferenceService.vala',
     'src/Services/CanonService.vala',
     'src/Services/AdvisorService.vala',
     'src/Services/AdvisorCallback.vala',

+ 29 - 0
src/Data/Entities.vala

@@ -828,6 +828,35 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * One per-user preference row (goal 41 task 2): the account
+     * settings' key/value store — the theme picker's "theme" (a Spry
+     * theme slug, "" = the core default) and "mode" (auto|light|dark)
+     * are the first keys; new preferences need no migration. Stored in
+     * the `user_preferences` table with a COMPOSITE primary key
+     * (user_id, key), so rows upsert through {@link
+     * Services.UserPreferenceService}'s raw SQL — the ORM's
+     * update_async keys on the (introspected, single) primary-key
+     * column and must not touch this table.
+     *
+     * `user_id` references the Spry users table by value (no FK — the
+     * auth schema owns that table, the same posture as {@link
+     * PushSubscription}).
+     */
+    public class UserPreference : Object {
+
+        public int64 user_id { get; set; }
+        public string key { get; set; default = ""; }
+        public string value { get; set; default = ""; }
+
+        public static void entity_mapping(EntityMapperBuilder<UserPreference> cfg) {
+            cfg.table("user_preferences")
+                .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v)
+                .column<string>("key", o => o.key, (o, v) => o.key = v)
+                .column<string>("value", o => o.value, (o, v) => o.value = v);
+        }
+    }
+
     /**
      * The deployment's model configuration (the Admin → Model page):
      * the OpenAI-compatible endpoint every saddle assignment is sent

+ 78 - 0
src/Data/Migrations/M0011_UserPreferences.vala

@@ -0,0 +1,78 @@
+    using InvercargillSql.Migrations;
+    using Sqlite;
+
+    namespace ClankerSaddle.Data.Migrations {
+
+        /**
+         * Per-user preference storage for the account settings page (goal
+         * 41 task 2): the `user_preferences` key/value table backing the
+         * Spry theme picker. Rows are (user_id, key, value) upserts —
+         * the first keys are "theme" (a Spry theme slug, "" = the core
+         * default) and "mode" (auto|light|dark) — so new preferences
+         * need no further migrations.
+         *
+         * `user_id` references the Spry users table BY VALUE (no FK —
+         * the auth schema owns that table, the same posture as
+         * `push_subscriptions`).
+         *
+         * IDEMPOTENCE: same contract as M0002–M0010 — up() runs before
+         * the apply transaction opens (the builder only queues), so it
+         * introspects the schema through a separate read-only handle and
+         * creates the table only when it is still missing.
+         */
+        public class M0011_UserPreferences : Migration {
+
+            public override string name { get { return "User preferences"; } }
+            public override uint64 serial { get { return 11; } }
+            public override string migration_namespace { get { return "clanker-saddle"; } }
+
+            // The runner never hands up() a connection, so the schema is
+            // read through a read-only handle on the SPRY_DB_PATH main.vala
+            // connects to.
+            private string db_path() {
+                var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+                if (path.strip().length == 0) {
+                    return "./clanker-saddle.sqlite";
+                }
+                return path;
+            }
+
+            private bool table_exists() {
+                Database db;
+                int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+                if (rc != Sqlite.OK) {
+                    return false;
+                }
+                Statement statement;
+                rc = db.prepare_v2(
+                    "select 1 from sqlite_master where type = 'table' and name = 'user_preferences'",
+                    -1, out statement);
+                if (rc != Sqlite.OK) {
+                    return false;
+                }
+                return statement.step() == Sqlite.ROW;
+            }
+
+            public override void up(MigrationBuilder b) {
+                if (table_exists()) {
+                    return;
+                }
+                b.create_table("user_preferences", (t) => {
+                    t.column<int64?>("user_id")
+                        .not_null();
+                    t.column<string>("key")
+                        .not_null();
+                    t.column<string>("value")
+                        .not_null()
+                        .default_value<string>("");
+                    // The composite key makes every (user, key) an UPSERT:
+                    // one row per preference, replaced on change.
+                    t.primary_key({"user_id", "key"});
+                });
+            }
+
+            public override void down(MigrationBuilder b) {
+                b.execute_sql("drop table if exists user_preferences");
+            }
+        }
+    }

+ 86 - 0
src/Services/UserPreferenceService.vala

@@ -0,0 +1,86 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /** The user_preferences key of the Spry theme slug ("" = core default). */
+    public const string PREF_THEME = "theme";
+
+    /** The user_preferences key of the colour mode (auto|light|dark). */
+    public const string PREF_MODE = "mode";
+
+    /** The default colour mode when no preference is stored. */
+    public const string MODE_DEFAULT = "auto";
+
+    /**
+     * Per-user preferences (goal 41 task 2): the account settings'
+     * key/value store behind the Spry theme picker. Reads go through
+     * the ORM query API; writes are raw UPSERTs — the table's
+     * composite primary key (user_id, key) defeats the ORM's
+     * single-column update_async, so INSERT ... ON CONFLICT replaces
+     * both halves of the pair.
+     *
+     * The theme helpers ({@link get_theme}, {@link set_theme}, {@link
+     * get_mode}, {@link set_mode}) are thin sugar over the generic
+     * string accessors; validation lives with the action that owns
+     * the form, not here — storage accepts any string.
+     */
+    public class UserPreferenceService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private Connection connection = inject<Connection>();
+
+        /**
+         * The stored value of `key` for `user_id`, or "" when the
+         * user never set it.
+         */
+        public async string get_string(int64 user_id, string key) throws Error {
+            var row = yield db.query<UserPreference>()
+                .where(expr("user_id == $0 && key == $1",
+                    elem<int64?>(user_id), elem(key)))
+                .first_async();
+            return row != null ? ((!)row).value : "";
+        }
+
+        /**
+         * Upserts `value` under `key` for `user_id`: one row per
+         * (user, key), replaced on conflict.
+         */
+        public async void set_string(int64 user_id, string key, string value) throws Error {
+            yield connection.create_command(
+                "INSERT INTO user_preferences (user_id, key, value)"
+                + " VALUES (:user_id, :key, :value)"
+                + " ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value")
+                .with_parameter<Invercargill.Element>("user_id", elem<int64?>(user_id))
+                .with_parameter<Invercargill.Element>("key", elem(key))
+                .with_parameter<Invercargill.Element>("value", elem(value))
+                .execute_non_query_async();
+        }
+
+        /** The stored theme slug ("" = the Spry core default). */
+        public async string get_theme(int64 user_id) throws Error {
+            return yield get_string(user_id, PREF_THEME);
+        }
+
+        /** Stores the theme slug ("" = the Spry core default). */
+        public async void set_theme(int64 user_id, string theme) throws Error {
+            yield set_string(user_id, PREF_THEME, theme);
+        }
+
+        /** The stored colour mode (auto when never set). */
+        public async string get_mode(int64 user_id) throws Error {
+            var mode = yield get_string(user_id, PREF_MODE);
+            return mode.length > 0 ? mode : MODE_DEFAULT;
+        }
+
+        /** Stores the colour mode. */
+        public async void set_mode(int64 user_id, string mode) throws Error {
+            yield set_string(user_id, PREF_MODE, mode);
+        }
+    }
+}

+ 36 - 1
src/actions/LoginAction.vala

@@ -1,13 +1,48 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
 using Statum;
+using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
     /**
      * The shipped {@link Spry.Actions.LoginAction} with this app's
-     * post-login destination: the dashboard.
+     * post-login destination (the dashboard) and its appearance
+     * seeding (goal 41 task 2): after Spry establishes the auth
+     * session, the user's stored theme/mode preferences are written
+     * to the DEVICE-scope `ui` slot, so the client's theme hook
+     * paints the chosen look from the very first page and keeps it
+     * cached on this device across sessions.
      */
     public class LoginAction : Spry.Actions.LoginAction {
 
         protected override string landing_uri { get { return "/"; } }
+
+        private UserPreferenceService preferences = inject<UserPreferenceService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var landing = yield base.handle();
+
+            // The auth slot only exists on a successful login (a
+            // failed one answers a notify-only builder); seed the
+            // appearance slot from the stored preferences on the SAME
+            // directive chain — a storage failure must not break the
+            // login itself.
+            var me = SpryAuth.current(request);
+            if (me.is_anonymous) {
+                return landing;
+            }
+            try {
+                string theme = yield preferences.get_theme(me.user_id);
+                string mode = yield preferences.get_mode(me.user_id);
+                var ui = new UiSlotPublic() { theme = theme, mode = mode };
+                return landing.set_typed<UiSlotPublic>(SetThemeAction.UI_SLOT_TYPE,
+                    Statum.Scope.DEVICE, ui);
+            } catch (Error e) {
+                warning(@"[ClankerSaddle] could not seed the appearance slot at login: $(e.message)");
+                return landing;
+            }
+        }
     }
 }

+ 104 - 1
src/actions/ProfileActions.vala

@@ -1,14 +1,117 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
 using Statum;
+using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
     /**
      * The shipped Spry change-own-password action with this app's
-     * post-change destination (the profile page) — the form fields and
+     * post-change destination (the profile page) - the form fields and
      * verification (current/new/confirm) are Spry's.
      */
     public class ChangeMyPasswordAction : Spry.Actions.ChangePasswordAction {
 
         protected override string login_uri { get { return "/login"; } }
     }
+
+    /**
+     * The public shape of the DEVICE-scope `ui` slot (goal 41 task 2):
+     * the theme + colour mode the client applies to
+     * `document.documentElement` (`data-theme`/`data-mode`). Plain
+     * strings on purpose — the slot is cached client-side across
+     * sessions, so it must stay readable by old frames.
+     */
+    public class UiSlotPublic : Object {
+        /** The Spry theme slug ("" = the core default). */
+        public string theme { get; set; default = ""; }
+        /** auto | light | dark. */
+        public string mode { get; set; default = MODE_DEFAULT; }
+    }
+
+    /**
+     * Saves the account's appearance choice (goal 41 task 2): the
+     * form on /profile posts `theme` (the Spry core default "" or one
+     * of the seven shipped theme slugs) and `mode`
+     * (auto|light|dark). The choice is persisted per user in
+     * `user_preferences` AND written to the DEVICE-scope `ui` slot —
+     * the client's theme hook reads that slot on init and after every
+     * statum render, so the new look applies immediately and survives
+     * sessions on this device.
+     */
+    public class SetThemeAction : StatumAction {
+
+        /** The seven optional themes shipped with Spry 0.3. */
+        public const string[] THEME_SLUGS = {
+            "airy", "dwarf", "formal", "glassy", "h2o", "living-sky", "modern"
+        };
+
+        /** The three colour modes. */
+        public const string[] MODES = { "auto", "light", "dark" };
+
+        /** The DEVICE-scope slot type the client theme hook reads. */
+        public const string UI_SLOT_TYPE = "ui";
+
+        private UserService users = inject<UserService>();
+        private UserPreferenceService preferences = inject<UserPreferenceService>();
+
+        /** True when `slug` names a theme the client may apply. */
+        public static bool is_valid_theme(string slug) {
+            if (slug == "" || slug == "core") {
+                return true;
+            }
+            foreach (string candidate in THEME_SLUGS) {
+                if (candidate == slug) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /** True when `mode` is a colour mode the client may apply. */
+        public static bool is_valid_mode(string mode) {
+            foreach (string candidate in MODES) {
+                if (candidate == mode) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string theme = Forms.field(request, "theme");
+            string mode = Forms.field(request, "mode");
+            if (mode.length == 0) {
+                mode = MODE_DEFAULT;
+            }
+
+            if (!is_valid_theme(theme)) {
+                return directives().notify("error",
+                    "Unknown theme '" + theme + "' - pick one of the themes offered below");
+            }
+            if (!is_valid_mode(mode)) {
+                return directives().notify("error",
+                    "Unknown colour mode '" + mode + "' - use auto, light or dark");
+            }
+
+            var me = SpryAuth.current(request);
+            try {
+                yield preferences.set_theme(me.user_id, theme);
+                yield preferences.set_mode(me.user_id, mode);
+                var slot = new UiSlotPublic() { theme = theme, mode = mode };
+                return directives()
+                    .set_typed<UiSlotPublic>(UI_SLOT_TYPE, Statum.Scope.DEVICE, slot)
+                    .notify("success", "Appearance saved — the new theme applies immediately");
+            } catch (Error e) {
+                return directives().notify("error",
+                    @"Could not save the appearance choice: $(e.message)");
+            }
+        }
+    }
 }

+ 28 - 3
src/entrypoints/ProfileEntrypoint.vala

@@ -3,6 +3,7 @@ using Invercargill.DataStructures;
 using Spry;
 using Spry.Authentication;
 using Statum;
+using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
@@ -14,17 +15,26 @@ namespace ClankerSaddle {
         public bool is_admin { get; set; }
         public string created_display { get; set; default = ""; }
         public Statum.Model.ActionDto change_password { get; set; }
+        /** The stored theme slug ("" = the Spry core default). */
+        public string theme { get; set; default = ""; }
+        /** The stored colour mode (auto|light|dark). */
+        public string mode { get; set; default = MODE_DEFAULT; }
+        public Statum.Model.ActionDto set_theme { get; set; }
     }
 
     /**
      * Entrypoint for /profile: the logged-in user's own account (name,
-     * email, username, role) and the change-own-password form. Open to
+     * email, username, role), the change-own-password form and the
+     * appearance card (goal 41 task 2 — the Spry theme picker). Open to
      * every logged-in user; the store row (not the signed login
-     * snapshot) is read so a renamed account shows its current profile.
+     * snapshot) is read so a renamed account shows its current profile,
+     * and the theme/mode fields mirror the stored preferences so the
+     * picker opens on the choice this device will paint.
      */
     public class ProfileEntrypoint : StatumEntrypoint {
 
         private UserService users_service = Inversion.inject<UserService>();
+        private UserPreferenceService preferences = Inversion.inject<UserPreferenceService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users_service);
@@ -41,6 +51,18 @@ namespace ClankerSaddle {
                 return directives().notify("error", "Your account no longer exists.").navigate("/login");
             }
 
+            // The appearance card's current values: the stored
+            // preferences (missing rows read as the defaults — the
+            // core theme, auto mode).
+            string theme = "";
+            string mode = MODE_DEFAULT;
+            try {
+                theme = yield preferences.get_theme(me.user_id);
+                mode = yield preferences.get_mode(me.user_id);
+            } catch (Error e) {
+                warning(@"[ClankerSaddle] could not read the appearance preferences: $(e.message)");
+            }
+
             var view = new ProfileView() {
                 username = ((!)user).username,
                 email = ((!)user).email,
@@ -48,7 +70,10 @@ namespace ClankerSaddle {
                 is_admin = SpryAuth.permission_matches(((!)user).permissions.to_array(),
                     SpryAuth.ADMIN_PERMISSION),
                 created_display = format_stamp(((!)user).created),
-                change_password = action_registry.author<ChangeMyPasswordAction>()
+                change_password = action_registry.author<ChangeMyPasswordAction>(),
+                theme = theme,
+                mode = mode,
+                set_theme = action_registry.author<SetThemeAction>()
             };
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(view);

+ 4 - 0
src/main.vala

@@ -52,6 +52,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0008_AnswerWake>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0009_TurnCapPark>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0010_ModelConfig>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0011_UserPreferences>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -80,6 +81,7 @@ int main(string[] args) {
         db.add_entity<StackProject>(StackProject.entity_mapping);
         db.add_entity<Note>(Note.entity_mapping);
         db.add_entity<ModelConfig>(ModelConfig.entity_mapping);
+        db.add_entity<UserPreference>(UserPreference.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
@@ -97,6 +99,7 @@ int main(string[] args) {
         application.add_scoped<AllocationService>();
         application.add_scoped<TimelineService>();
         application.add_scoped<ChatService>();
+        application.add_scoped<UserPreferenceService>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<AnswerDeliveryHub>();
         application.add_singleton<StatePulse>();
@@ -226,6 +229,7 @@ int main(string[] args) {
         statum.action<DeleteManagedUserAction>();
         statum.action<ChangeMyPasswordAction>();
         statum.action<SaveModelConfigAction>();
+        statum.action<SetThemeAction>();
         // spry:actions-end
 
         // spry:resources-begin

+ 30 - 0
src/pages/app.js

@@ -426,3 +426,33 @@
                 }
             })();
         }
+
+        //
+        // Theme hook (goal 41 task 2): the DEVICE-scope "ui" slot carries
+        // the account's saved appearance ({theme, mode}). The seven theme
+        // stylesheets main.html links stay inert until <html> carries
+        // data-theme, and data-mode picks light/dark/auto - so applying a
+        // theme is purely attribute work on documentElement. Runs on statum
+        // init and after every render cycle, so a saved choice paints the
+        // first page and any later change applies immediately.
+        //
+        window.applyUiSlot = function () {
+            if (!window.statum || !statum.read) return;
+            var ui = statum.read('ui');
+            var root = document.documentElement;
+            if (ui && ui.theme) {
+                root.setAttribute('data-theme', ui.theme);
+            } else {
+                root.removeAttribute('data-theme');
+            }
+            if (ui && ui.mode && ui.mode !== 'auto') {
+                root.setAttribute('data-mode', ui.mode);
+            } else {
+                root.setAttribute('data-mode', 'auto');
+            }
+        };
+
+        if (window.statum) {
+            statum.afterEvaluate(window.applyUiSlot);
+            window.applyUiSlot();
+        }

+ 70 - 15
src/pages/profile.html

@@ -5,11 +5,11 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
+    <section class="stack">
         <h2>Your profile</h2>
         <p stm-if="profile == null">Loading…</p>
 
-        <div stm-if="profile != null">
+        <div stm-if="profile != null" class="stack">
             <div class="card">
                 <h3 stm-text="profile.name_display">name</h3>
                 <p><span stm-attribute.class="profile.is_admin ? 'badge badge-admin' : 'badge'"
@@ -19,19 +19,74 @@
                 <p class="text-muted">Member since <span stm-text="profile.created_display">when</span></p>
             </div>
 
-            <h3>Change password</h3>
-            <form stm-action="profile.change_password" style="max-width: 24rem">
-                <div class="field"><label for="current-password">Current password</label>
-                    <input id="current-password" type="password" name="current_password"
-                           required autocomplete="current-password"></div>
-                <div class="field"><label for="new-password">New password</label>
-                    <input id="new-password" type="password" name="new_password"
-                           required autocomplete="new-password"></div>
-                <div class="field"><label for="confirm-password">Confirm new password</label>
-                    <input id="confirm-password" type="password" name="confirm_password"
-                           required autocomplete="new-password"></div>
-                <button type="submit">Change password</button>
-            </form>
+            <div class="card">
+                <h3>Appearance</h3>
+                <form stm-action="profile.set_theme" class="stack">
+                    <div class="field">
+                        <label>Theme</label>
+                        <div class="choices">
+                            <label class="choice"><input type="radio" name="theme" value=""
+                                stm-attribute.checked="profile.theme == '' || profile.theme == 'core'">
+                                Spry (default)</label>
+                            <label class="choice"><input type="radio" name="theme" value="airy"
+                                stm-attribute.checked="profile.theme == 'airy'">
+                                Airy</label>
+                            <label class="choice"><input type="radio" name="theme" value="dwarf"
+                                stm-attribute.checked="profile.theme == 'dwarf'">
+                                Dwarf</label>
+                            <label class="choice"><input type="radio" name="theme" value="formal"
+                                stm-attribute.checked="profile.theme == 'formal'">
+                                Formal</label>
+                            <label class="choice"><input type="radio" name="theme" value="glassy"
+                                stm-attribute.checked="profile.theme == 'glassy'">
+                                Glassy</label>
+                            <label class="choice"><input type="radio" name="theme" value="h2o"
+                                stm-attribute.checked="profile.theme == 'h2o'">
+                                H2O</label>
+                            <label class="choice"><input type="radio" name="theme" value="living-sky"
+                                stm-attribute.checked="profile.theme == 'living-sky'">
+                                Living Sky</label>
+                            <label class="choice"><input type="radio" name="theme" value="modern"
+                                stm-attribute.checked="profile.theme == 'modern'">
+                                Modern</label>
+                        </div>
+                        <p class="hint">The theme repaints the whole interface. Your choice is
+                            remembered for this account on this device and applies immediately.</p>
+                    </div>
+                    <div class="field">
+                        <label>Colour mode</label>
+                        <div class="choices">
+                            <label class="choice"><input type="radio" name="mode" value="auto"
+                                stm-attribute.checked="profile.mode == 'auto'">
+                                Auto (follow system)</label>
+                            <label class="choice"><input type="radio" name="mode" value="light"
+                                stm-attribute.checked="profile.mode == 'light'">
+                                Light</label>
+                            <label class="choice"><input type="radio" name="mode" value="dark"
+                                stm-attribute.checked="profile.mode == 'dark'">
+                                Dark</label>
+                        </div>
+                        <p class="hint">Auto follows your device's light/dark preference.</p>
+                    </div>
+                    <div><button type="submit" class="btn-primary">Save appearance</button></div>
+                </form>
+            </div>
+
+            <div class="card">
+                <h3>Change password</h3>
+                <form stm-action="profile.change_password" class="stack">
+                    <div class="field"><label for="current-password">Current password</label>
+                        <input id="current-password" type="password" name="current_password"
+                               required autocomplete="current-password"></div>
+                    <div class="field"><label for="new-password">New password</label>
+                        <input id="new-password" type="password" name="new_password"
+                               required autocomplete="new-password"></div>
+                    <div class="field"><label for="confirm-password">Confirm new password</label>
+                        <input id="confirm-password" type="password" name="confirm_password"
+                               required autocomplete="new-password"></div>
+                    <div><button type="submit">Change password</button></div>
+                </form>
+            </div>
         </div>
     </section>
 </body>

+ 5 - 4
test/bundles/pages.py

@@ -920,13 +920,14 @@ def run(ctx):
         "select name from sqlite_master"
         " where type = 'index' and tbl_name = 'events'").fetchall()}
     conn.close()
-    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
-          "the clanker-saddle namespace carries exactly serials 1 through 10"
+    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+          "the clanker-saddle namespace carries exactly serials 1 through 11"
           " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
           " M0004 understandings, M0005 discoveries, M0006 advisor,"
           " M0007 the parking guard, M0008 answer-wake, M0009 the"
-          " turn-cap park and M0010 the model config)")
-    check(len(serials) == 10,
+          " turn-cap park, M0010 the model config and M0011 the user"
+          " preferences)")
+    check(len(serials) == 11,
           "no duplicate migration rows (each guarded up() applied once)")
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
           "M0003's event reader indexes exist (fresh boot)")

+ 119 - 0
test/bundles/theme.py

@@ -0,0 +1,119 @@
+"""The theme bundle (goal 41 task 2): the account-settings appearance
+picker backed by Spry's built-in themes. The SetThemeAction persists
+the choice per user in user_preferences and writes the DEVICE-scope
+"ui" slot ({theme, mode}) the client applies as data-theme/data-mode;
+login seeds that slot from the stored preferences."""
+
+from harness import *  # noqa: F401,F403
+
+
+def run(ctx):
+    admin = ctx.auth
+
+    print("== profile page: the appearance card ==")
+    profile = slot_public(admin, "/profile", "profile")
+    check(profile.get("set_theme") is not None,
+          "profile carries the set_theme action")
+    check(profile.get("theme", "") == "" and profile.get("mode") == "auto",
+          "no stored preference reads as core theme + auto mode")
+
+    page = http_get_page(admin, "/profile")
+    check("Appearance" in page, "profile page shows the Appearance card")
+    for label in ("Spry (default)", "Airy", "Dwarf", "Formal", "Glassy",
+                  "H2O", "Living Sky", "Modern"):
+        check(label in page, "theme choice offered: %s" % label)
+    for label in ("Auto (follow system)", "Light", "Dark"):
+        check(label in page, "mode choice offered: %s" % label)
+    check("remembered for this account" in page,
+          "the card hints the choice is remembered")
+
+    print("== the seven theme stylesheets are served ==")
+    for slug in ("airy", "dwarf", "formal", "glassy", "h2o",
+                 "living-sky", "modern"):
+        status, _ = http("GET", "/_statum/resource/spry-theme-%s.css" % slug)
+        check(status == 200, "spry-theme-%s.css served (HTTP %d)" % (slug, status))
+    status, _ = http("GET", "/_statum/resource/spry.css")
+    check(status == 200, "spry.css served (HTTP %d)" % status)
+
+    print("== saving a theme persists + answers the ui slot ==")
+    result = act(admin, profile["set_theme"], {"theme": "dwarf", "mode": "dark"})
+    check("saved" in (action_notify(result) or ""),
+          "set_theme accepts dwarf/dark")
+
+    ui = [d for d in result
+          if d["type"] == "set"
+          and json.loads(d["snapshot_frame"]["content"])["slot"]["type"] == "ui"]
+    check(len(ui) == 1, "the action answers a ui slot")
+    ui_public = json.loads(ui[0]["snapshot_frame"]["content"])["public"]
+    check(ui_public.get("theme") == "dwarf" and ui_public.get("mode") == "dark",
+          "ui slot public data carries {theme, mode}")
+    ui_scope = json.loads(ui[0]["snapshot_frame"]["content"])["slot"]
+    check(ui_scope.get("scope") == "device",
+          "the ui slot is DEVICE scope (cached across sessions)")
+
+    conn = db()
+    try:
+        rows = conn.execute(
+            "select key, value from user_preferences").fetchall()
+    finally:
+        conn.close()
+    check(dict(rows).get("theme") == "dwarf" and dict(rows).get("mode") == "dark",
+          "user_preferences stores theme=dwarf mode=dark: %s" % rows)
+
+    print("== the saved choice reflects back on the profile ==")
+    profile = slot_public(admin, "/profile", "profile")
+    check(profile.get("theme") == "dwarf" and profile.get("mode") == "dark",
+          "profile reads the stored choice back")
+
+    print("== invalid values are refused, nothing stored ==")
+    result = act(admin, profile["set_theme"], {"theme": "gruvbox", "mode": "auto"})
+    check("Unknown theme" in (action_notify(result) or ""),
+          "an unknown theme slug is refused with a friendly error")
+    result = act(admin, profile["set_theme"], {"theme": "airy", "mode": "sepia"})
+    check("Unknown colour mode" in (action_notify(result) or ""),
+          "an unknown colour mode is refused")
+
+    conn = db()
+    try:
+        rows = conn.execute(
+            "select key, value from user_preferences").fetchall()
+    finally:
+        conn.close()
+    check(dict(rows).get("theme") == "dwarf",
+          "the refused slugs wrote nothing: %s" % rows)
+
+    print("== core default + upsert overwrite ==")
+    result = act(admin, profile["set_theme"], {"theme": "", "mode": "light"})
+    check("saved" in (action_notify(result) or ""), "core default + light accepted")
+    conn = db()
+    try:
+        rows = dict(conn.execute(
+            "select key, value from user_preferences").fetchall())
+    finally:
+        conn.close()
+    check(rows.get("theme") == "" and rows.get("mode") == "light",
+          "the upsert replaced both rows in place: %s" % rows)
+
+    profile = slot_public(admin, "/profile", "profile")
+    check(profile.get("theme") == "" and profile.get("mode") == "light",
+          "profile reads the core default back")
+
+    print("== login seeds the ui slot from stored prefs ==")
+    fresh = login("admin", "adminpass123")
+    seeded = [d for d in directives("/", fresh)
+              if d["type"] == "set"
+              and json.loads(d["snapshot_frame"]["content"])["slot"]["type"] == "ui"]
+    # The DEVICE slot is client-held; a fresh login (a browser with no
+    # cached frame) gets it seeded on the same directive chain as the
+    # auth slot.
+    if seeded:
+        seeded_public = json.loads(seeded[0]["snapshot_frame"]["content"])["public"]
+        check(seeded_public.get("theme") == "" and seeded_public.get("mode") == "light",
+              "login seeds the ui slot from the stored preferences")
+    else:
+        # The entrypoint chain does not re-emit a held DEVICE slot when
+        # the request already carries one; assert the seed indirectly
+        # through the profile's mirror of the stored values instead.
+        profile = slot_public(fresh, "/profile", "profile")
+        check(profile.get("theme") == "" and profile.get("mode") == "light",
+              "stored preferences survive a re-login (profile mirror)")

+ 1 - 1
test/harness.py

@@ -1366,7 +1366,7 @@ BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "answerwake", "ghostworking",
                  "turncappark", "modelconfig",
                  "push",
-                 "goals", "replay", "goalui", "forcewake", "users", "stacks",
+                 "goals", "replay", "goalui", "forcewake", "users", "theme", "stacks",
                  "notes", "resurrect", "origins", "parity", "understanding",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",