diff --git a/README.md b/README.md
index 796c3e5..8c2932c 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
 # ESP32-S3 Device Lab
 
-这是一个 M5Stack StopWatch VPS Monitor / Pi、Codex Agent Pager 原型：Lighthouse 上的 FastAPI Gateway 每 2 秒采集本机 CPU、内存、磁盘和网络，主动推送到设备；可选 Pi Extension 把 session lifecycle、实时文本和简短工具活动推送到同一个 Gateway。
+M5Stack StopWatch **0.6.0 — Wearable Agent Shell**：随身查看 Agent 状态、说话、处理 Approval 和阅读结果。默认启动到 Agent；Watch / Agent / Monitor 从 HOME 进入。完整服务器监控保留在 Monitor 内。
 
-Firmware / Control Layer / Protocol: **0.5.0**。StopWatch 同时支持 Agent Remote Control 和 Pocket Voice Agent：手动朗读当前屏幕、会话最终回复自动朗读、coding 最终回复语音摘要。Speech 默认 OFF，不会因升级自动说话。Gateway 继续兼容 Firmware 0.4.0；语音播放需刷入 0.5.0。
+短 A：Watch 打开 Agent；Agent 切 Summary / Activity / Final；Monitor 切五个指标；HOME 选择下一个 app。短 B：app 返回 HOME，HOME 打开所选 app。长 A 保留 Voice 输入，长 B 打开 Settings。Approval 的 A APPROVE / B REJECT 和 Voice 的 A SEND / B CANCEL 优先于 Shell。
 
-使用：长按 B → SETTINGS → SPEECH；A 切换项目，B 选择。Speech 中可设置模式或选择 READ SCREEN NOW。长按 A 先停止播放，再进入已有按住说话流程。Admin 设备 target 行也可设置 Speech Mode / Speak test。
+Settings → Device：Brightness 10..100% / Vibration；Display：Home Screen / Compact、Normal、Large / Dark、Dim / Agent Clock；System：只读电压、Wi-Fi、Gateway、固件。只有 B Apply 才保存 NVS，失败显示 APPLIED / NOT SAVED。本地设置离线可用。Gateway hello_ack 的兼容 UTC 字段同步时间；断网后单调时钟继续走，冷启动未同步显示 --:--。固定显示 Asia/Shanghai。
 
-详见 [Spoken Agent 设计与隐私说明](docs/spoken-agent-design.md)、[0.5.0 验收记录](docs/spoken-agent-0.5.0-validation.md)。实体扬声器音质仍需刷机后验证。
+**Speech Output FROZEN，Speech Mode OFF。** Volume 80 仅保留在偏好模型中，无运行时音量修改。RTC、battery percentage、Touch、Project/Session Picker、创建 Workspace/Session、Voice replay 延后。Control Layer / CONTROL_VERSION 保持 **0.5.0**。真机门槛与状态见 [0.6a 验收记录](docs/wearable-shell-0.6a.md)，host PASS 不代表 daily-use PASS。
 
 0.4.1 更新 Gateway STT，现有 0.4.0 firmware 无需刷写。真实中文、英文、自然中英混合 STT 已通过。精确机器 identifier 为 best effort，发送前确认；model/session 等精确选择使用 Device Settings。该限制已被接受，不阻塞 STT production readiness。`GATEWAY_STT_INITIAL_PROMPT` 保持未启用。详见 [0.4.1 验收记录](docs/stt-0.4.1-validation.md)。
 
@@ -343,9 +343,9 @@ curl -X POST http://127.0.0.1:18081/devices/<device-id>/test-notification \
 
 公网同一路径属于现有 Cloudflare Access 的 `/devices*` 管理保护范围，匿名请求不能触发设备。设备必须在线，否则接口返回 `409`。
 
-默认屏幕显示 Lighthouse Overview；Button A 下一页，Button B 上一页，页面顺序为 Overview、CPU、Memory、Disk、Network、Pi。Cloud 断线时底部明确显示红色 `OFFLINE` 并自动重连。
+默认启动 Agent Home；B 返回 HOME，A 选择 Watch / Agent / Monitor，B 进入。Monitor 内 A 循环 Overview、CPU、Memory、Disk、Network。Gateway 断线显示 `RECONNECTING`，保留 Agent target/state/final_text 并自动恢复。
 
-从管理页选择 Pi session 后，设备自动切到 `PI AGENT` 页面，显示状态、运行时长、session 名称和最新文本/工具活动。连续 activity 最多约 4 FPS 重绘。真实 `RUNNING -> DONE` 转换触发 90% PWM 的 250/180 ms 长短双震及 880/1200 Hz 上扬提示音；`RUNNING -> FAILED` 触发三个 240 ms 强震及 600/400 Hz 下行提示音。同一 run 只提醒一次。声音由 I2S0 + ES8311 + AW8737A 非阻塞播放，音频初始化失败不影响显示、Wi-Fi、WSS 或 Pager。
+从管理页选择 Pi session 后，设备更新当前 Agent，保留正在查看的 app；Agent Home 显示状态、运行时长、session 名称和最新文本/工具活动。连续 activity 最多约 4 FPS 重绘。真实 `RUNNING -> DONE` 转换触发 90% PWM 的 250/180 ms 长短双震及 880/1200 Hz 上扬提示音；`RUNNING -> FAILED` 触发三个 240 ms 强震及 600/400 Hz 下行提示音。同一 run 只提醒一次。声音由 I2S0 + ES8311 + AW8737A 非阻塞播放，音频初始化失败不影响显示、Wi-Fi、WSS 或 Pager。
 
 检查清单：
 
diff --git a/VERSION b/VERSION
index 4b9fcbe..a918a2a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.5.1
+0.6.0
diff --git a/boards/common/include/board.h b/boards/common/include/board.h
index c99f39d..382575f 100644
--- a/boards/common/include/board.h
+++ b/boards/common/include/board.h
@@ -78,3 +78,6 @@ void board_voice_interrupted(uint64_t id);
 bool board_voice_active(uint64_t id);
 
 void board_agent_activity(const char *agent, const char *session_id, const char *type, const char *activity, const char *recent);
+
+// Authenticated Gateway UTC, displayed using Asia/Shanghai offset.
+void board_sync_utc_time(double server_time_ms);
diff --git a/boards/common/include/device_preferences.h b/boards/common/include/device_preferences.h
new file mode 100644
index 0000000..e71768c
--- /dev/null
+++ b/boards/common/include/device_preferences.h
@@ -0,0 +1,79 @@
+#pragma once
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace wearable {
+enum class Home : uint8_t { Watch, Agent, Monitor };
+enum class TextSize : uint8_t { Compact, Normal, Large };
+enum class Theme : uint8_t { Dark, Dim };
+struct DevicePreferences {
+    uint8_t brightness = 80, volume = 80, vibration_enabled = 1;
+    Home home_screen = Home::Agent;
+    TextSize text_size = TextSize::Normal;
+    Theme theme = Theme::Dark;
+    uint8_t watchface = 0; // Agent Clock only. Volume reserved, no audio effect.
+};
+inline bool valid(const DevicePreferences& p) {
+    return p.brightness >= 10 && p.brightness <= 100 && p.volume <= 100 &&
+        p.vibration_enabled <= 1 && uint8_t(p.home_screen) <= 2 &&
+        uint8_t(p.text_size) <= 2 && uint8_t(p.theme) <= 1 && p.watchface == 0;
+}
+inline uint8_t panel_value(uint8_t percent) { return (percent * 255U + 50U) / 100U; }
+using Blob = std::array<uint8_t, 21>;
+constexpr size_t MAX_BLOB_SIZE = 269; // u8 payload length + envelope
+inline uint32_t crc32(const uint8_t* p, size_t n) {
+    uint32_t c = 0xffffffffU;
+    for (size_t i=0; i<n; ++i) {
+        c ^= p[i];
+        for (int j=0; j<8; ++j) c = (c >> 1) ^ (0xedb88320U & (0U-(c & 1)));
+    }
+    return ~c;
+}
+inline void put32(uint8_t* p, uint32_t v) { for (unsigned i=0;i<4;++i) p[i]=v>>(i*8); }
+inline uint32_t get32(const uint8_t* p) {
+    uint32_t v=0; for (unsigned i=0;i<4;++i) v |= uint32_t(p[i])<<(i*8); return v;
+}
+inline bool encode(const DevicePreferences& p, uint32_t generation, Blob& b) {
+    if (!valid(p)) return false;
+    b = {'W','S','P','F',2,7};
+    put32(b.data()+6, generation);
+    b[10]=p.brightness; b[11]=p.volume; b[12]=p.vibration_enabled;
+    b[13]=uint8_t(p.home_screen); b[14]=uint8_t(p.text_size);
+    b[15]=uint8_t(p.theme); b[16]=p.watchface;
+    put32(b.data()+17, crc32(b.data(),17)); return true;
+}
+enum class Decode { Invalid, Current, Migrated, Future };
+inline Decode decode(const uint8_t* b, size_t n, DevicePreferences& p, uint32_t& generation) {
+    if (!b || n<14 || n>MAX_BLOB_SIZE || b[0]!='W' || b[1]!='S' || b[2]!='P' || b[3]!='F' ||
+        n!=size_t(b[5])+14 || get32(b+n-4)!=crc32(b,n-4)) return Decode::Invalid;
+    generation=get32(b+6);
+    if (b[4]>2) return Decode::Future;
+    if (!((b[4]==1 && b[5]==5) || (b[4]==2 && b[5]==7))) return Decode::Invalid;
+    DevicePreferences v;
+    v.brightness=b[10];v.volume=b[11];v.vibration_enabled=b[12];
+    v.home_screen=Home(b[13]);v.text_size=TextSize(b[14]);
+    if(b[4]==2) { v.theme=Theme(b[15]);v.watchface=b[16]; }
+    if(!valid(v)) return Decode::Invalid;
+    p=v;return b[4]==1?Decode::Migrated:Decode::Current;
+}
+struct LoadedPreferences {
+    DevicePreferences value;
+    uint32_t generation=0;
+    int slot=-1;
+    bool read_only=false, migration_needed=false;
+};
+inline LoadedPreferences load(const uint8_t* a,size_t an,const uint8_t* b,size_t bn) {
+    LoadedPreferences out;
+    const uint8_t* data[2]={a,b};size_t sizes[2]={an,bn};
+    for (int i=0;i<2;++i) {
+        DevicePreferences p;uint32_t g=0;auto result=decode(data[i],sizes[i],p,g);
+        if(result==Decode::Future) out.read_only=true;
+        if((result==Decode::Current || result==Decode::Migrated) && (out.slot<0 || g>out.generation)) {
+            out.value=p;out.generation=g;out.slot=i;out.migration_needed=result==Decode::Migrated;
+        }
+    }
+    if(out.generation==0xffffffffU) out.read_only=true;
+    return out;
+}
+} // namespace wearable
diff --git a/boards/common/include/device_status.h b/boards/common/include/device_status.h
new file mode 100644
index 0000000..12b57db
--- /dev/null
+++ b/boards/common/include/device_status.h
@@ -0,0 +1,19 @@
+#pragma once
+#include <cstdint>
+#include <cmath>
+
+namespace wearable {
+struct WallClock {
+    uint64_t utc_base_ms=0, monotonic_base_ms=0;
+    bool sync(double epoch_ms,uint64_t now_ms) {
+        // 2024..2100; accept only integer milliseconds from authenticated hello.
+        if(!std::isfinite(epoch_ms) || epoch_ms<1704067200000.0 || epoch_ms>=4102444800000.0 ||
+            std::floor(epoch_ms)!=epoch_ms) return false;
+        utc_base_ms=uint64_t(epoch_ms);monotonic_base_ms=now_ms;return true;
+    }
+    uint64_t utc_ms(uint64_t now_ms) const {
+        return utc_base_ms && now_ms>=monotonic_base_ms?utc_base_ms+now_ms-monotonic_base_ms:0;
+    }
+};
+struct BatteryStatus {bool valid=false;uint16_t millivolts=0;uint32_t failures=0;};
+} // namespace wearable
diff --git a/boards/common/include/ui_state.h b/boards/common/include/ui_state.h
new file mode 100644
index 0000000..b58a6a2
--- /dev/null
+++ b/boards/common/include/ui_state.h
@@ -0,0 +1,82 @@
+#pragma once
+#include "device_preferences.h"
+
+namespace wearable {
+enum class App : uint8_t { Watch, Agent, Monitor, Home };
+enum class Menu : uint8_t { Root, Agent, Device, Display, System, Edit };
+enum class Field : uint8_t { Brightness, Vibration, HomeScreen, Text, Theme };
+enum class UiEffect { None, CloseSettings, Apply, AgentSettings };
+struct UiState {
+    App app=App::Agent;
+    uint8_t home_selection=1, monitor_page=0, agent_detail=0;
+    size_t text_page=0;
+    Menu menu=Menu::Root, editor_parent=Menu::Device;
+    Field field=Field::Brightness;
+    uint8_t index=0;
+    bool settings_open=false;
+    DevicePreferences preferences, draft;
+    void boot(DevicePreferences p) { preferences=draft=p; app=App(uint8_t(p.home_screen)); home_selection=uint8_t(p.home_screen); }
+    void navigate(bool a) {
+        if(settings_open) return;
+        text_page=0;
+        if(app==App::Home) {
+            if(a) home_selection=(home_selection+1)%3;
+            else app=App(home_selection);
+        } else if(!a) { home_selection=uint8_t(app); app=App::Home; }
+        else if(app==App::Watch) app=App::Agent;
+        else if(app==App::Agent) agent_detail=(agent_detail+1)%3;
+        else monitor_page=(monitor_page+1)%5;
+    }
+    void open_settings() { settings_open=true; menu=Menu::Root;index=0;draft=preferences; }
+    void close_settings() { settings_open=false; draft=preferences; }
+    uint8_t items() const {
+        switch(menu) {
+        case Menu::Root:return 5;
+        case Menu::Agent:return 2;
+        case Menu::Device:return 3;
+        case Menu::Display:return 5;
+        case Menu::System:return 5;
+        default:return 1;
+        }
+    }
+    void edit_next() {
+        switch(field) {
+        case Field::Brightness:draft.brightness=draft.brightness>=100?10:(draft.brightness/10+1)*10;break;
+        case Field::Vibration:draft.vibration_enabled=!draft.vibration_enabled;break;
+        case Field::HomeScreen:draft.home_screen=Home((uint8_t(draft.home_screen)+1)%3);break;
+        case Field::Text:draft.text_size=TextSize((uint8_t(draft.text_size)+1)%3);break;
+        case Field::Theme:draft.theme=Theme((uint8_t(draft.theme)+1)%2);break;
+        }
+    }
+    UiEffect settings_click(bool a) {
+        if(!settings_open) return UiEffect::None;
+        if(menu==Menu::Edit) {
+            if(a) edit_next();
+            else {preferences=draft;menu=editor_parent;index=0;return UiEffect::Apply;}
+            return UiEffect::None;
+        }
+        if(a) {index=(index+1)%items();return UiEffect::None;}
+        const auto selected=index;
+        if(menu==Menu::Root) {
+            if(selected==4) {close_settings();return UiEffect::CloseSettings;}
+            menu=Menu(uint8_t(Menu::Agent)+selected);index=0;
+        } else if(selected==items()-1) {menu=Menu::Root;index=0;}
+        else if(menu==Menu::Agent) return UiEffect::AgentSettings;
+        else if(menu==Menu::Device || menu==Menu::Display) {
+            if(menu==Menu::Display && selected==3) return UiEffect::None; // One watchface, read-only.
+            editor_parent=menu; field=menu==Menu::Device?Field(selected):Field(uint8_t(Field::HomeScreen)+selected);
+            draft=preferences;menu=Menu::Edit;index=0;
+        }
+        return UiEffect::None;
+    }
+};
+inline const char* home_name(Home h) {return h==Home::Watch?"Watch":h==Home::Agent?"Agent":"Monitor";}
+inline const char* text_name(TextSize t) {return t==TextSize::Compact?"Compact":t==TextSize::Normal?"Normal":"Large";}
+inline const char* theme_name(Theme t) {return t==Theme::Dark?"Dark":"Dim";}
+inline float body_scale(TextSize t) {return t==TextSize::Compact?1.0f:t==TextSize::Normal?1.5f:2.0f;}
+struct Palette {uint16_t primary,secondary,accent,done,failed,approval,selected;};
+inline Palette palette(Theme t) {
+    return t==Theme::Dark?Palette{0xf7be,0xa556,0x5edf,0x6ef2,0xfbae,0xfdcc,0x10c3}:
+        Palette{0xce7a,0x94b3,0x8dd8,0x95d3,0xcc93,0xc550,0x1082};
+}
+} // namespace wearable
diff --git a/boards/common/include/wearable_renderer.h b/boards/common/include/wearable_renderer.h
new file mode 100644
index 0000000..6a83545
--- /dev/null
+++ b/boards/common/include/wearable_renderer.h
@@ -0,0 +1,167 @@
+#pragma once
+#include "ui_state.h"
+#include "agent_display.h"
+#include <algorithm>
+#include <cstdio>
+#include <cstring>
+#include <string>
+#include <vector>
+
+namespace wearable {
+// Read-only backing data. No network, I2C, preferences write, or navigation here.
+struct ShellView {
+    bool selected=false, wifi=false, gateway=false;
+    const char *agent="", *project="", *session="", *state="IDLE", *activity_type="", *activity="", *recent="", *final_text=nullptr;
+    const char *time="--:--", *date="Time unavailable", *battery="BAT --", *firmware="", *wifi_detail="";
+    const char *save_feedback="";
+    uint64_t elapsed=0,now_ms=0,last_update_ms=0;
+    int rssi=-127;
+};
+// Generic renderer is compiled against M5GFX and the recording host display.
+// datum integers match LovyanGFX textdatum_t (top_left=0, top_center=1).
+template<class Gfx, class Font> class ShellRenderer {
+    Gfx& g; const Font& font; const UiState& ui; const ShellView& v; Palette p;
+    void setup(float size,uint16_t color) {g.setFont(&font);g.setTextSize(size);g.setTextColor(color,0);g.setTextDatum(0);}
+    size_t character_size(const char* value,size_t remain) const {
+        const auto first=uint8_t(value[0]);
+        return std::min(remain,size_t((first&0xf8)==0xf0?4:(first&0xf0)==0xe0?3:(first&0xe0)==0xc0?2:1));
+    }
+    std::string fit(const char* value,int width) {
+        std::string text=value?value:"";
+        if(g.textWidth(text.c_str())<=width) return text;
+        std::string out;
+        for(size_t i=0;i<text.size();) {
+            size_t n=character_size(text.data()+i,text.size()-i);
+            auto next=out+text.substr(i,n);
+            if(g.textWidth((next+"...").c_str())>width) break;
+            out=next;i+=n;
+        }
+        return out+"...";
+    }
+    void center(const char* text,int y,int width=304) {
+        auto clipped=fit(text,width);g.drawString(clipped.c_str(),234-g.textWidth(clipped.c_str())/2,y);
+    }
+    std::vector<std::string> wrap(const char* value,int width) {
+        std::vector<std::string> lines;std::string current;
+        if(!value) return lines;
+        const size_t length=std::strlen(value);
+        for(size_t i=0;i<length;) {
+            if(value[i]=='\n') {lines.push_back(current);current.clear();++i;continue;}
+            const size_t n=character_size(value+i,length-i);
+            auto next=current+std::string(value+i,n);
+            if(!current.empty() && g.textWidth(next.c_str())>width) {lines.push_back(current);current.assign(value+i,n);}
+            else current=next;
+            i+=n;
+        }
+        if(!current.empty()) lines.push_back(current);
+        return lines;
+    }
+    uint16_t state_color() const {
+        return !std::strcmp(v.state,"RUNNING")?p.accent:!std::strcmp(v.state,"DONE")?p.done:
+            !std::strcmp(v.state,"FAILED")?p.failed:!std::strcmp(v.state,"WAITING_APPROVAL")?p.approval:p.secondary;
+    }
+    std::string duration() const {
+        char text[32];std::snprintf(text,sizeof(text),"%02llu:%02llu%s",(unsigned long long)(v.elapsed/60),
+            (unsigned long long)(v.elapsed%60),"");return text;
+    }
+    void footer(const char* hint) {
+        setup(1,p.accent);center(hint,374,310);
+        setup(1,p.secondary);center(v.gateway?"Long A TALK / ONLINE":"RECONNECTING",404,216);
+    }
+    void body(const char* text,int top,int bottom,bool tail=false) {
+        setup(body_scale(ui.preferences.text_size),p.primary);
+        auto lines=wrap(text,304);
+        const int line_height=g.fontHeight()+8;
+        const size_t count=std::max(1,(bottom-top)/line_height);
+        const size_t pages=(lines.size()+count-1)/count;
+        size_t start=tail && lines.size()>count?lines.size()-count:pages>1?((v.now_ms/6000)%pages)*count:0;
+        for(size_t i=start;i<std::min(start+count,lines.size());++i)
+            g.drawString(lines[i].c_str(),82,top+int(i-start)*line_height);
+        if(pages>1 && !tail) {
+            char page[28];std::snprintf(page,sizeof(page),"Result %u/%u",unsigned(start/count+1),unsigned(pages));
+            setup(1,p.secondary);center(page,bottom,304);
+        }
+    }
+public:
+    ShellRenderer(Gfx& gfx,const Font& f,const UiState& state,const ShellView& view):g(gfx),font(f),ui(state),v(view),p(palette(state.preferences.theme)) {}
+    void watch() {
+        setup(3.5f,p.primary);center(v.time,75,312);
+        setup(1,p.secondary);center(v.date,147,330);
+        setup(1.5f,state_color());center(v.selected?v.state:"AGENT IDLE",187,330);
+        setup(body_scale(ui.preferences.text_size),p.primary);center(v.selected?v.project:"Choose a session in Admin",226,304);
+        setup(1.5f,p.secondary);if(v.selected) center(duration().c_str(),270);
+        setup(1,p.secondary);
+        const auto shown=agent_display_text(v.state,v.final_text,v.activity_type,v.activity,v.recent);
+        if(v.selected && !std::strcmp(v.state,"RUNNING")) {
+            auto lines=wrap(shown.text,304);if(!lines.empty()) center(lines.back().c_str(),315);
+        } else center("Asia/Shanghai  UTC+8",315);
+        setup(1,p.secondary);
+        std::string status=std::string(v.battery)+(v.wifi?"   WiFi ON":"   WiFi --");center(status.c_str(),349);
+        footer("A Agent   B HOME");
+    }
+    void agent() {
+        setup(1,p.secondary);center(v.selected?v.agent:"AGENT",52,220);
+        setup(body_scale(ui.preferences.text_size),p.primary);center(v.selected?v.project:"No session selected",89,280);
+        setup(1,p.secondary);if(v.selected && std::strcmp(v.project,v.session)) center(v.session,132,304);
+        setup(1.5f,state_color());center(v.selected?v.state:"IDLE",163,330);
+        setup(1,p.secondary);center(v.selected?duration().c_str():"Choose a session in Admin",202);
+        const auto shown=agent_display_text(v.state,v.final_text,v.activity_type,v.activity,v.recent);
+        const char* text=shown.text;bool tail=!shown.terminal;
+        if(ui.agent_detail==1) {text=v.recent;tail=true;}
+        if(ui.agent_detail==2) {text=v.final_text?v.final_text:"No final response received.";tail=false;}
+        if(!text || !*text) text="Waiting for agent activity...";
+        setup(1,p.secondary);
+        center(ui.agent_detail==1?"ACTIVITY":ui.agent_detail==2?"FINAL RESULT":shown.tool?"TOOL":"SUMMARY",228);
+        body(text,254,350,tail);
+        footer("A NEXT   B HOME");
+    }
+    void home() {
+        setup(1.5f,p.primary);center("HOME",65,260);
+        const char* names[]={"Watch","Agent","Monitor"};
+        for(unsigned i=0;i<3;++i) {
+            if(i==ui.home_selection) g.fillRoundRect(102,128+i*64,264,52,12,p.selected);
+            setup(1.5f,i==ui.home_selection?p.accent:p.secondary);center(names[i],138+i*64,240);
+        }
+        footer("A NEXT   B OPEN");
+    }
+    void settings() {
+        setup(1.5f,p.primary);center("SETTINGS",55,260);
+        if(ui.menu==Menu::Edit) {
+            const char* title="";std::string value;
+            switch(ui.field) {
+            case Field::Brightness:title="Brightness";value=std::to_string(ui.draft.brightness)+"%";break;
+            case Field::Vibration:title="Vibration";value=ui.draft.vibration_enabled?"On":"Off";break;
+            case Field::HomeScreen:title="Home Screen";value=home_name(ui.draft.home_screen);break;
+            case Field::Text:title="Text Size";value=text_name(ui.draft.text_size);break;
+            case Field::Theme:title="Theme";value=theme_name(ui.draft.theme);break;
+            }
+            setup(1.5f,p.secondary);center(title,145);
+            setup(2,p.accent);center(value.c_str(),207);
+            setup(1,p.secondary);center("Draft - B to Apply",278);
+            setup(1,p.accent);center("A NEXT   B APPLY",374);
+        } else {
+            std::vector<std::string> rows;
+            switch(ui.menu) {
+            case Menu::Root:rows={"Agent","Device","Display","System","Back"};break;
+            case Menu::Agent:rows={v.gateway && v.selected?"Model / Effort":"Model / Effort unavailable","Back"};break;
+            case Menu::Device:rows={"Brightness  "+std::to_string(ui.preferences.brightness)+"%",std::string("Vibration  ")+(ui.preferences.vibration_enabled?"On":"Off"),"Back"};break;
+            case Menu::Display:rows={std::string("Home  ")+home_name(ui.preferences.home_screen),std::string("Text  ")+text_name(ui.preferences.text_size),std::string("Theme  ")+theme_name(ui.preferences.theme),"WatchFace Agent Clock","Back"};break;
+            case Menu::System:{
+                char wifi[64];std::snprintf(wifi,sizeof(wifi),"WiFi %s  %d dBm",v.wifi?"CONNECTED":"--",v.rssi);
+                rows={v.battery,wifi,v.gateway?"Gateway ONLINE":"Gateway RECONNECTING",std::string("Firmware ")+v.firmware,"Back"};break;
+            }
+            default:break;
+            }
+            size_t first=ui.index>1?ui.index-1:0;
+            if(first+3>rows.size()) first=rows.size()>3?rows.size()-3:0;
+            for(size_t i=first;i<std::min(first+3,rows.size());++i) {
+                const int y=133+int(i-first)*66;
+                if(i==ui.index) g.fillRoundRect(82,y-7,304,58,10,p.selected);
+                setup(body_scale(ui.preferences.text_size),i==ui.index?p.accent:p.secondary);center(rows[i].c_str(),y,286);
+            }
+            setup(1,p.accent);center("A NEXT   B SELECT",374);
+        }
+        setup(1,p.secondary);center(*v.save_feedback?v.save_feedback:"Hold B to close",407,270);
+    }
+};
+} // namespace wearable
diff --git a/boards/m5stack_stopwatch/CMakeLists.txt b/boards/m5stack_stopwatch/CMakeLists.txt
index 8cae38f..38fe6ca 100644
--- a/boards/m5stack_stopwatch/CMakeLists.txt
+++ b/boards/m5stack_stopwatch/CMakeLists.txt
@@ -1,5 +1,5 @@
 idf_component_register(
-    SRCS "board.cpp" "audio_backend.cpp"
+    SRCS "board.cpp" "audio_backend.cpp" "device_preferences_nvs.cpp"
     INCLUDE_DIRS "." "../common/include"
-    REQUIRES mbedtls json M5GFX M5PM1 M5IOE1 driver esp_codec_dev esp_psram esp_timer
+    REQUIRES mbedtls json M5GFX M5PM1 M5IOE1 driver esp_codec_dev esp_psram esp_timer nvs_flash esp_wifi
 )
diff --git a/boards/m5stack_stopwatch/board.cpp b/boards/m5stack_stopwatch/board.cpp
index a62303a..5de9a92 100644
--- a/boards/m5stack_stopwatch/board.cpp
+++ b/boards/m5stack_stopwatch/board.cpp
@@ -6,6 +6,13 @@
 #include "agent_target.h"
 #include "agent_display.h"
 #include "page_redraw_policy.h"
+#include "ui_state.h"
+#include "wearable_renderer.h"
+#include "device_status.h"
+#include "device_preferences_nvs.h"
+#include "esp_wifi.h"
+#include <atomic>
+#include <ctime>
 #include "cJSON.h"
 #include "esp_random.h"
 #include <deque>
@@ -152,6 +159,8 @@ private:
 
 
 SemaphoreHandle_t s_display_lock;
+TaskHandle_t s_display_owner;
+std::atomic<bool> s_draw_requested{false};
 char s_version[24] = "unknown";
 char s_device_id[24] = "pending";
 char s_wifi_detail[24] = "disconnected";
@@ -214,9 +223,23 @@ struct PiState {
 
 ServerState s_server;
 PiState s_pi;
-Page s_page = Page::OVERVIEW;
+Page s_page = Page::PI; // Compatibility backing metric; shell owns navigation.
+wearable::UiState s_ui;
+wearable::LoadedPreferences s_saved_preferences;
+wearable::WallClock s_clock;
+wearable::BatteryStatus s_battery;
+std::atomic<bool> s_vibration_enabled{true};
+int s_wifi_rssi=-127;
+std::string s_preference_feedback;
+uint64_t s_last_shell_tick=0;
+
+void sync_display_page() {
+    s_page=s_ui.app==wearable::App::Monitor?Page(s_ui.monitor_page):
+        s_ui.app==wearable::App::Home?Page::COUNT:Page::PI;
+}
 bool s_message_visible;
 uint64_t s_message_until_ms;
+std::string s_message_title,s_message_body;
 uint64_t s_last_pi_redraw_ms;
 uint64_t s_last_pi_elapsed_second;
 bool s_pi_redraw_pending;
@@ -544,12 +567,12 @@ void unlock_display()
 uint16_t percent_color(float value)
 {
     if (value >= 90) {
-        return TFT_RED;
+        return wearable::palette(s_ui.preferences.theme).failed;
     }
     if (value >= 75) {
-        return TFT_ORANGE;
+        return wearable::palette(s_ui.preferences.theme).approval;
     }
-    return TFT_GREEN;
+    return wearable::palette(s_ui.preferences.theme).done;
 }
 
 void format_bytes(uint64_t bytes, char *buffer, size_t length)
@@ -676,27 +699,20 @@ std::vector<std::string> wrap_activity_text(const char *value, int maximum_width
 void draw_header(const char *title)
 {
     s_display.setTextDatum(textdatum_t::top_center);
-    s_display.setTextColor(0xBDF7, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     s_display.setTextSize(2);
     s_display.drawString(title, s_display.width() / 2, 38);
 }
 
 void draw_footer()
 {
-    const bool online = s_wifi_connected && s_cloud_connected;
-    const uint16_t color = online ? TFT_GREEN : TFT_RED;
-    s_display.fillCircle(155, 414, 7, color);
-    s_display.setTextDatum(textdatum_t::middle_left);
-    s_display.setTextColor(color, TFT_BLACK);
-    s_display.setTextSize(1);
-    s_display.drawString(online ? "ONLINE" : "RECONNECTING", 172, 414);
-
-    char indicator[12];
-    std::snprintf(indicator, sizeof(indicator), "%u/%u", static_cast<unsigned>(s_page) + 1,
-                  static_cast<unsigned>(Page::COUNT));
-    s_display.setTextDatum(textdatum_t::middle_right);
-    s_display.setTextColor(0x7BEF, TFT_BLACK);
-    s_display.drawString(indicator, 360, 414);
+    const auto colors=wearable::palette(s_ui.preferences.theme);
+    char hint[48];snprintf(hint,sizeof(hint),"A NEXT %u/5   B HOME",unsigned(s_ui.monitor_page)+1);
+    s_display.setFont(&s_transcript_font);s_display.setTextSize(1);
+    s_display.setTextDatum(textdatum_t::top_center);s_display.setTextColor(colors.accent,TFT_BLACK);
+    s_display.drawString(hint,234,374);
+    s_display.setTextColor(colors.secondary,TFT_BLACK);
+    s_display.drawString(s_cloud_connected?"Long A TALK / ONLINE":"RECONNECTING",234,404);
 }
 
 void draw_percent_row(const char *label, float value, int y)
@@ -704,7 +720,7 @@ void draw_percent_row(const char *label, float value, int y)
     char text[20];
     std::snprintf(text, sizeof(text), "%.0f%%", value);
     s_display.setTextDatum(textdatum_t::middle_left);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     s_display.setTextSize(2);
     s_display.drawString(label, 105, y);
     s_display.setTextDatum(textdatum_t::middle_right);
@@ -728,7 +744,7 @@ void draw_overview()
     draw_header(s_server.hostname);
     if (!s_server.valid) {
         s_display.setTextDatum(textdatum_t::middle_center);
-        s_display.setTextColor(0xBDF7, TFT_BLACK);
+        s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
         s_display.setTextSize(2);
         s_display.drawString("WAITING FOR DATA", s_display.width() / 2, 220);
         return;
@@ -743,10 +759,10 @@ void draw_overview()
     format_rate(s_server.network_rx_bps, down, sizeof(down));
     format_rate(s_server.network_tx_bps, up, sizeof(up));
     s_display.setTextDatum(textdatum_t::top_left);
-    s_display.setTextColor(0x7BEF, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).secondary, TFT_BLACK);
     s_display.setTextSize(1);
     s_display.drawString("NET", 105, 298);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     std::snprintf(line, sizeof(line), "RX  %s", down);
     s_display.drawString(line, 105, 326);
     std::snprintf(line, sizeof(line), "TX  %s", up);
@@ -760,7 +776,7 @@ void draw_cpu()
     draw_progress(s_server.cpu_percent, 184);
     char text[48];
     s_display.setTextDatum(textdatum_t::top_center);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     s_display.setTextSize(1);
     std::snprintf(text, sizeof(text), "Load 1m   %.2f", s_server.load_1m);
     s_display.drawString(text, s_display.width() / 2, 245);
@@ -769,7 +785,7 @@ void draw_cpu()
     std::snprintf(text, sizeof(text), "Uptime   %llud %02lluh",
                   static_cast<unsigned long long>(s_server.uptime / 86400),
                   static_cast<unsigned long long>((s_server.uptime / 3600) % 24));
-    s_display.setTextColor(0x7BEF, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).secondary, TFT_BLACK);
     s_display.drawString(text, s_display.width() / 2, 340);
 }
 
@@ -784,7 +800,7 @@ void draw_memory()
     format_bytes(s_server.memory_used, used, sizeof(used));
     format_bytes(s_server.memory_total, total, sizeof(total));
     s_display.setTextDatum(textdatum_t::top_center);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     s_display.setTextSize(1);
     std::snprintf(text, sizeof(text), "%s / %s", used, total);
     s_display.drawString(text, s_display.width() / 2, 248);
@@ -804,12 +820,12 @@ void draw_disk()
     format_bytes(s_server.disk_used, used, sizeof(used));
     format_bytes(s_server.disk_total, total, sizeof(total));
     s_display.setTextDatum(textdatum_t::top_center);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     s_display.setTextSize(1);
     std::snprintf(text, sizeof(text), "%s / %s", used, total);
     s_display.drawString(text, s_display.width() / 2, 252);
     if (s_server.disk_percent >= 90) {
-        s_display.setTextColor(TFT_RED, TFT_BLACK);
+        s_display.setTextColor(wearable::palette(s_ui.preferences.theme).failed, TFT_BLACK);
         s_display.setTextSize(2);
         s_display.drawString("SPACE LOW", s_display.width() / 2, 315);
     }
@@ -828,19 +844,19 @@ void draw_network()
     format_bytes(s_server.network_rx_bytes, rx_total, sizeof(rx_total));
     format_bytes(s_server.network_tx_bytes, tx_total, sizeof(tx_total));
     s_display.setTextDatum(textdatum_t::top_center);
-    s_display.setTextColor(TFT_GREEN, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).done, TFT_BLACK);
     s_display.setTextSize(2);
     std::snprintf(text, sizeof(text), "RX  %s", rx_rate);
     s_display.drawString(text, s_display.width() / 2, 130);
-    s_display.setTextColor(0xBDF7, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     s_display.setTextSize(1);
     std::snprintf(text, sizeof(text), "Total %s", rx_total);
     s_display.drawString(text, s_display.width() / 2, 190);
-    s_display.setTextColor(TFT_CYAN, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).accent, TFT_BLACK);
     s_display.setTextSize(2);
     std::snprintf(text, sizeof(text), "TX  %s", tx_rate);
     s_display.drawString(text, s_display.width() / 2, 252);
-    s_display.setTextColor(0xBDF7, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     s_display.setTextSize(1);
     std::snprintf(text, sizeof(text), "Total %s", tx_total);
     s_display.drawString(text, s_display.width() / 2, 312);
@@ -1049,7 +1065,7 @@ void cancel_input(bool notify, const char *reason) {
     audio_backend_record_stop(true);
     if (notify && s_input_modal==InputModal::VOICE) voice_command("voice_cancel");
     if (notify && s_input_modal==InputModal::SETTINGS) settings_command("settings_cancel");
-    s_input_modal=InputModal::NONE; s_transcript.clear(); s_voice_id.clear(); s_settings_id.clear();
+    s_input_modal=InputModal::NONE; s_ui.close_settings(); s_transcript.clear(); s_voice_id.clear(); s_settings_id.clear();
     s_feedback_until=0; ++s_ui_epoch;
     log_modal_transition(before,reason);
 }
@@ -1086,23 +1102,26 @@ void handle_gesture(bool a, Gesture g, uint64_t epoch, uint64_t now) {
     } else if (s_input_modal==InputModal::SETTINGS) {
         if (!a && g==Gesture::LONG) cancel_input(true,"settings-close");
         else if (g==Gesture::CLICK && s_settings_phase=="MENU") {
-            const auto old_page=s_speech_menu.page;
-            const auto old_index=s_speech_menu.index, old_mode=s_speech_menu.mode;
-            const auto action=s_speech_menu.click(a);
-            auto label=s_speech_menu.label();
-            std::replace(label.begin(),label.end(),'\n',' ');
-            ESP_LOGI(TAG,"[SETTINGS] %s %s page=%s -> %s index=%u -> %u action=%s label=%s",
-                     a?"A":"B",a?"NEXT":"SELECT",settings_page_name(old_page),
-                     settings_page_name(s_speech_menu.page),old_index,s_speech_menu.index,
-                     settings_action_name(action),label.c_str());
-            if (old_mode!=s_speech_menu.mode)
-                ESP_LOGI(TAG,"[SETTINGS] mode %s -> %s",SpeechMenu::mode_name(old_mode),SpeechMenu::mode_name(s_speech_menu.mode));
-            if (action==SpeechMenuAction::AGENT_SETTINGS) {
-                if (!s_pi.selected) {s_settings_phase="NO AGENT SELECTED";}
-                else {s_settings_phase="LOADING"; settings_command("settings_get");}
-            } else if (action==SpeechMenuAction::LOAD_SPEECH) speech_command("speech_settings_get");
-            else if (action==SpeechMenuAction::SAVE_MODE) {speech_command("speech_settings_set");s_settings_phase="SAVING SPEECH";}
-            else if (action==SpeechMenuAction::READ_SCREEN) {speech_command("speech_request");cancel_input(false,"read-screen");}
+            const auto effect=s_ui.settings_click(a);
+            s_preference_feedback.clear();
+            if(effect==wearable::UiEffect::CloseSettings) cancel_input(false,"settings-back");
+            else if(effect==wearable::UiEffect::AgentSettings) {
+                if(s_pi.selected && s_cloud_connected) {
+                    s_settings_id=new_identity();s_settings_target=selected_target();
+                    s_settings_phase="LOADING";settings_command("settings_get");
+                }
+            } else if(effect==wearable::UiEffect::Apply) {
+                // board_update holds the display owner lock; no ISR/network setter.
+                s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));
+                s_vibration_enabled=s_ui.preferences.vibration_enabled;
+                const auto result=preferences_save(s_saved_preferences,s_ui.preferences);
+                s_preference_feedback=result==ESP_OK?"APPLIED / SAVED":"APPLIED / NOT SAVED";
+                ESP_LOGI(TAG,"[PREFS] apply brightness=%u panel=%u vibration=%u home=%u text=%u theme=%u slot=%d generation=%lu saved=%d readonly=%d",
+                    s_ui.preferences.brightness,wearable::panel_value(s_ui.preferences.brightness),
+                    s_ui.preferences.vibration_enabled,unsigned(s_ui.preferences.home_screen),
+                    unsigned(s_ui.preferences.text_size),unsigned(s_ui.preferences.theme),s_saved_preferences.slot,
+                    (unsigned long)s_saved_preferences.generation,result==ESP_OK,s_saved_preferences.read_only);
+            }
             ++s_ui_epoch;
         }
         else if (g==Gesture::CLICK && !s_models.empty() && (s_settings_phase=="MODEL" || s_settings_phase=="EFFORT")) {
@@ -1121,7 +1140,7 @@ void handle_gesture(bool a, Gesture g, uint64_t epoch, uint64_t now) {
         }
     } else if (g==Gesture::LONG) {
         stop_speech();
-        if ((a && !s_pi.selected) || !s_cloud_connected) {
+        if (a && (!s_pi.selected || !s_cloud_connected)) {
             s_input_modal=InputModal::ERROR; s_modal_error=s_pi.selected?"OFFLINE":"NO AGENT SELECTED"; ++s_ui_epoch;
         } else if (a) {
             s_voice_id=new_identity(&s_voice_serial); s_voice_started=now;
@@ -1131,7 +1150,7 @@ void handle_gesture(bool a, Gesture g, uint64_t epoch, uint64_t now) {
             ++s_ui_epoch;
         } else {
             s_settings_id=new_identity();s_settings_target=selected_target();s_settings_phase="MENU";s_models.clear();
-            s_read_screen=read_screen_text();s_speech_menu.open();
+            s_ui.open_settings();s_preference_feedback.clear();
             s_input_modal=InputModal::SETTINGS; ++s_ui_epoch;
         }
     } else if (g==Gesture::CLICK) {
@@ -1141,9 +1160,50 @@ void handle_gesture(bool a, Gesture g, uint64_t epoch, uint64_t now) {
     if (before==InputModal::NONE) log_modal_transition(before,g==Gesture::LONG?(a?"long-A":"long-B"):"click");
     draw_status();
 }
+wearable::ShellView shell_view() {
+    wearable::ShellView view;
+    view.selected=s_pi.selected;view.wifi=s_wifi_connected;view.gateway=s_cloud_connected;
+    view.agent=s_pi.agent;view.session=s_pi.name;view.state=s_pi.state;
+    static std::string project;
+    project=s_pi.cwd;
+    while(project.size()>1 && project.back()=='/') project.pop_back();
+    const auto slash=project.find_last_of('/');if(slash!=std::string::npos) project=project.substr(slash+1);
+    if(project.empty()) project=s_pi.name;
+    view.project=project.c_str();
+    view.activity_type=s_pi.activity_type;view.activity=s_pi.latest_activity;view.recent=s_pi_tail?s_pi_tail:"";
+    view.final_text=s_pi.has_final_text?s_pi.final_text:nullptr;
+    view.now_ms=esp_timer_get_time()/1000;view.last_update_ms=s_pi.received_at_ms;
+    view.elapsed=s_pi.elapsed_seconds;
+    if(!strcmp(s_pi.state,"RUNNING") && view.now_ms>=s_pi.received_at_ms)
+        view.elapsed+=(view.now_ms-s_pi.received_at_ms)/1000;
+    static char time[16],date[40],battery[24];
+    const auto utc=s_clock.utc_ms(view.now_ms);
+    if(utc) {
+        const time_t local=time_t(utc/1000+8*3600);struct tm broken{};gmtime_r(&local,&broken);
+        strftime(time,sizeof(time),"%H:%M",&broken);strftime(date,sizeof(date),"%a %b %d",&broken);
+    } else {strlcpy(time,"--:--",sizeof(time));strlcpy(date,"Time unavailable",sizeof(date));}
+    if(s_battery.valid) snprintf(battery,sizeof(battery),"BAT %.2fV",s_battery.millivolts/1000.0);
+    else strlcpy(battery,"BAT --",sizeof(battery));
+    view.time=time;view.date=date;view.battery=battery;view.rssi=s_wifi_rssi;
+    view.firmware=s_version;view.wifi_detail=s_wifi_detail;view.save_feedback=s_preference_feedback.c_str();
+    return view;
+}
+void draw_shell_app() {
+    const auto view=shell_view();
+    wearable::ShellRenderer<StopWatchDisplay,TranscriptFont> renderer(s_display,s_transcript_font,s_ui,view);
+    if(s_ui.app==wearable::App::Watch) renderer.watch();
+    else if(s_ui.app==wearable::App::Home) renderer.home();
+    else renderer.agent();
+}
+void draw_local_settings() {
+    const auto view=shell_view();
+    wearable::ShellRenderer<StopWatchDisplay,TranscriptFont>(s_display,s_transcript_font,s_ui,view).settings();
+}
 void draw_input_modal() {
+    s_display.setFont(&s_transcript_font);
+    if(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU") {draw_local_settings();return;}
     draw_header(s_input_modal==InputModal::SETTINGS?"SETTINGS":s_input_modal==InputModal::ERROR?"ERROR":"VOICE");
-    s_display.setTextDatum(textdatum_t::middle_center);s_display.setTextSize(1);s_display.setTextColor(TFT_WHITE,TFT_BLACK);
+    s_display.setTextDatum(textdatum_t::middle_center);s_display.setTextSize(1);s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
     s_display.drawString(std::strcmp(s_pi.agent,"codex")==0?"CODEX":"PI AGENT",234,104);
     s_display.drawString(s_pi.name,234,136);
     std::string body, footer="B CLOSE";
@@ -1164,8 +1224,7 @@ void draw_input_modal() {
     } else if (s_voice_phase=="AWAITING_CONFIRMATION") { body=s_transcript;footer="A SEND   B CANCEL"; }
     else {body=s_voice_phase;footer="B CLOSE";}
     const auto *previous_font=s_display.getFont();
-    if (s_input_modal==InputModal::VOICE && s_voice_phase=="AWAITING_CONFIRMATION")
-        s_display.setFont(&s_transcript_font);
+    s_display.setFont(&s_transcript_font);
     auto lines=wrap_activity_text(body.c_str(),304);
     // No touch required: long transcripts page automatically, preserving all text.
     size_t start=0;
@@ -1173,100 +1232,24 @@ void draw_input_modal() {
     s_display.setTextDatum(textdatum_t::top_left);
     for (size_t i=start;i<std::min(start+7,lines.size());++i) s_display.drawString(lines[i].c_str(),82,184+(i-start)*24);
     s_display.setFont(previous_font);
-    s_display.setTextDatum(textdatum_t::middle_center);s_display.setTextColor(TFT_CYAN,TFT_BLACK);
+    s_display.setTextDatum(textdatum_t::middle_center);s_display.setTextColor(wearable::palette(s_ui.preferences.theme).accent,TFT_BLACK);
     s_display.drawString(footer.c_str(),234,386);
 }
 
 void draw_pi()
 {
-    draw_header(std::strcmp(s_pi.agent,"codex")==0?"CODEX":"PI AGENT");
-    if (!s_pi.selected) {
-        s_display.setTextDatum(textdatum_t::middle_center);
-        s_display.setTextColor(0x7BEF, TFT_BLACK);
-        s_display.setTextSize(2);
-        s_display.drawString("NO SESSION", s_display.width() / 2, 215);
-        s_display.setTextSize(1);
-        s_display.drawString("Select one in the admin page", s_display.width() / 2, 272);
-        return;
-    }
-
-    uint16_t state_color = TFT_WHITE;
-    if (std::strcmp(s_pi.state, "RUNNING") == 0) {
-        state_color = TFT_CYAN;
-    } else if (std::strcmp(s_pi.state, "DONE") == 0) {
-        state_color = TFT_GREEN;
-    } else if (std::strcmp(s_pi.state, "FAILED") == 0) {
-        state_color = TFT_RED;
-    } else if (std::strcmp(s_pi.state, "OFFLINE") == 0) {
-        state_color = 0x7BEF;
-    }
-    uint64_t elapsed = s_pi.elapsed_seconds;
-    if (std::strcmp(s_pi.state, "RUNNING") == 0) {
-        elapsed += (esp_timer_get_time() / 1000 - s_pi.received_at_ms) / 1000;
-    }
-    char duration[24];
-    if (elapsed >= 3600) {
-        std::snprintf(duration, sizeof(duration), "%02llu:%02llu:%02llu",
-                      static_cast<unsigned long long>(elapsed / 3600),
-                      static_cast<unsigned long long>((elapsed / 60) % 60),
-                      static_cast<unsigned long long>(elapsed % 60));
-    } else {
-        std::snprintf(duration, sizeof(duration), "%02llu:%02llu",
-                      static_cast<unsigned long long>(elapsed / 60),
-                      static_cast<unsigned long long>(elapsed % 60));
-    }
-
-    s_display.setTextDatum(textdatum_t::middle_left);
-    s_display.setTextColor(state_color, TFT_BLACK);
-    s_display.setTextSize(2);
-    s_display.drawString(s_pi.state, 82, 112);
-    s_display.setTextDatum(textdatum_t::middle_right);
-    s_display.setTextColor(0xBDF7, TFT_BLACK);
-    s_display.drawString(duration, 386, 112);
-
-    s_display.setTextDatum(textdatum_t::top_center);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
-    s_display.setTextSize(1);
-    s_display.drawString(s_pi.name, s_display.width() / 2, 142);
-    s_display.setTextColor(0x7BEF, TFT_BLACK);
-    s_display.drawString((std::string(s_pi.model)+" "+s_pi.effort).c_str(),234,158);
-    s_display.drawFastHLine(82, 180, 304, 0x3186);
-
-    const auto view=agent_display_text(s_pi.state,s_pi.has_final_text?s_pi.final_text:nullptr,
-        s_pi.activity_type,s_pi.latest_activity,s_pi_tail);
-    const bool tool_activity = view.tool;
-    const char *activity = view.text;
-    int activity_y = 184;
-    if (tool_activity) {
-        s_display.setTextDatum(textdatum_t::top_left);
-        s_display.setTextColor(TFT_ORANGE, TFT_BLACK);
-        s_display.drawString("TOOL", 82, activity_y);
-        activity_y += 30;
-    }
-    s_display.setTextDatum(textdatum_t::top_left);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
-    const auto *previous_font=s_display.getFont();
-    s_display.setFont(&s_transcript_font);
-    const std::vector<std::string> lines = wrap_activity_text(activity, 304, !view.terminal);
-    const size_t maximum_lines = tool_activity ? 6 : 8;
-    const size_t first_line = !view.terminal && lines.size() > maximum_lines ? lines.size() - maximum_lines : 0;
-    for (size_t index = first_line; index < std::min(first_line+maximum_lines,lines.size()); ++index) {
-        s_display.drawString(lines[index].c_str(), 82, activity_y);
-        activity_y += 24;
-    }
-    if (lines.empty()) {
-        s_display.setTextColor(0x7BEF, TFT_BLACK);
-        s_display.drawString("Waiting for agent activity...", 82, activity_y);
-    }
-    s_display.setFont(previous_font);
+    const auto* previous=s_display.getFont();
+    draw_shell_app();
+    s_display.setFont(previous);
 }
 
 void draw_approval()
 {
+    s_display.setFont(&s_transcript_font);
     draw_header("APPROVAL");
     s_display.setTextDatum(textdatum_t::middle_center);
     s_display.setTextSize(2);
-    s_display.setTextColor(TFT_ORANGE, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).approval, TFT_BLACK);
     if (s_approval.result_until) {
         s_display.drawString(s_approval.result.c_str(), 234, 225);
         return;
@@ -1277,7 +1260,7 @@ void draw_approval()
     for (char &c : agent) c = std::toupper(static_cast<unsigned char>(c));
     s_display.drawString(agent.c_str(), 234, 104);
     s_display.setTextSize(1);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
     auto titles = wrap_activity_text(view->title.c_str(), 304);
     for (size_t i = 0; i < std::min<size_t>(titles.size(), 2); ++i)
         s_display.drawString(titles[i].c_str(), 234, 146 + i * 24);
@@ -1287,16 +1270,21 @@ void draw_approval()
     for (size_t i = 0; i < lines.size(); ++i)
         s_display.drawString(lines[i].c_str(), 82, 198 + i * 24);
     s_display.setTextDatum(textdatum_t::middle_center);
-    s_display.setTextColor(TFT_GREEN, TFT_BLACK);
+    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).done, TFT_BLACK);
     s_display.drawString(s_approval.submitted ? "SENDING..." : "A  APPROVE", 234, 374);
     if (!s_approval.submitted) {
-        s_display.setTextColor(TFT_RED, TFT_BLACK);
+        s_display.setTextColor(wearable::palette(s_ui.preferences.theme).failed, TFT_BLACK);
         s_display.drawString("B  REJECT", 234, 404);
     }
 }
 
 void draw_status(const char *reason)
 {
+    // Wi-Fi sys_evt has a small stack. Callbacks only request work; main owns
+    // fonts, layout and panel transactions. Keep the established display lock.
+    if(s_display_owner && xTaskGetCurrentTaskHandle()!=s_display_owner) {
+        s_draw_requested=true;return;
+    }
     if (!s_display_ready) {
         return;
     }
@@ -1324,19 +1312,33 @@ void draw_status(const char *reason)
     } diagnostic{changed,esp_timer_get_time()};
     s_display.startWrite();
     s_display.fillScreen(TFT_BLACK);
+    s_display.setFont(&fonts::Font0);
+    s_display.setTextSize(1);
     if (s_approval.modal()) {
         draw_approval();
         return;
     }
     if (s_input_modal != InputModal::NONE) { draw_input_modal(); return; }
     if (!s_speech.id.empty()) {
-        draw_header("SPEAKING");s_display.setTextSize(1);s_display.setTextColor(TFT_WHITE,TFT_BLACK);
+        draw_header("SPEAKING");s_display.setTextSize(1);s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
         s_display.setTextDatum(textdatum_t::top_center);s_display.drawString(s_pi.selected?s_pi.agent:"SCREEN",234,125);
         const auto lines=wrap_activity_text(s_speech_text.c_str(),304);
         s_display.setTextDatum(textdatum_t::top_left);
         for (size_t i=0;i<std::min<size_t>(7,lines.size());++i) s_display.drawString(lines[i].c_str(),82,184+i*24);
         return;
     }
+    if(s_message_visible) {
+        draw_header(s_message_title.c_str());
+        s_display.setFont(&s_transcript_font);s_display.setTextSize(1);
+        s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
+        s_display.setTextDatum(textdatum_t::top_left);
+        const auto lines=wrap_activity_text(s_message_body.c_str(),304,false);
+        for(size_t i=0;i<std::min<size_t>(7,lines.size());++i) s_display.drawString(lines[i].c_str(),82,160+i*24);
+        return;
+    }
+    if(s_ui.app!=wearable::App::Monitor) {
+        draw_shell_app();s_pi_redraw_pending=false;s_last_pi_redraw_ms=esp_timer_get_time()/1000;return;
+    }
     switch (s_page) {
     case Page::OVERVIEW:
         draw_overview();
@@ -1366,6 +1368,7 @@ void draw_status(const char *reason)
 
 void vibrate_task(void *arg)
 {
+    if(!s_vibration_enabled.load()) {vTaskDelete(nullptr);return;}
     const uint32_t duration = reinterpret_cast<uintptr_t>(arg);
     const m5ioe1_err_t start = s_ioe.setPwmDuty(
         MOTOR_PWM_CHANNEL, GENERIC_VIBRATION_DUTY, false, true);
@@ -1391,6 +1394,7 @@ void pi_notify_task(void *arg)
         ? FAILED_VIBRATION_PATTERN.size()
         : DONE_VIBRATION_PATTERN.size();
     for (size_t pulse = 0; pulse < pattern_size; ++pulse) {
+        if(!s_vibration_enabled.load()) break; // Always finish an already-started pulse with stop.
         const m5ioe1_err_t start = s_ioe.setPwmDuty(
             MOTOR_PWM_CHANNEL, NOTIFY_VIBRATION_DUTY, false, true);
         vTaskDelay(pdMS_TO_TICKS(pattern[pulse].on_ms));
@@ -1410,10 +1414,37 @@ void pi_notify_task(void *arg)
     vTaskDelete(nullptr);
 }
 
+void device_status_task(void*) {
+    uint64_t next_log_ms=0;
+    while(true) {
+        lock_display();
+        const bool recording=s_input_modal==InputModal::VOICE && s_voice_phase=="RECORDING";
+        unlock_display();
+        if(!recording) {
+            uint16_t millivolts=0;
+            const auto result=s_pmic.readVbat(&millivolts);
+            wifi_ap_record_t ap{};const auto wifi=esp_wifi_sta_get_ap_info(&ap);
+            lock_display();
+            s_battery.valid=result==M5PM1_OK && millivolts>=2000 && millivolts<=5000;
+            if(s_battery.valid) s_battery.millivolts=millivolts;else ++s_battery.failures;
+            s_wifi_rssi=wifi==ESP_OK?ap.rssi:-127;
+            const uint64_t now_ms=esp_timer_get_time()/1000;
+            if(now_ms>=next_log_ms) {
+                next_log_ms=now_ms+60000;
+                ESP_LOGI(TAG,"[STATUS] battery_mv=%u valid=%d read_failures=%lu rssi=%d",millivolts,
+                    s_battery.valid,(unsigned long)s_battery.failures,s_wifi_rssi);
+            }
+            unlock_display();
+        }
+        vTaskDelay(pdMS_TO_TICKS(3000));
+    }
+}
+
 }  // namespace
 
 esp_err_t board_init(const char *firmware_version)
 {
+    s_display_owner=xTaskGetCurrentTaskHandle();
     strlcpy(s_version, firmware_version, sizeof(s_version));
     s_display_lock = xSemaphoreCreateRecursiveMutex();
     if (s_display_lock == nullptr) {
@@ -1462,6 +1493,15 @@ esp_err_t board_init(const char *firmware_version)
     if (result != ESP_OK) {
         return result;
     }
+    s_saved_preferences=preferences_load();s_ui.boot(s_saved_preferences.value);sync_display_page();
+    s_vibration_enabled=s_ui.preferences.vibration_enabled;
+    lock_display();s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));unlock_display();
+    ESP_LOGI(TAG,"[PREFS] load slot=%d generation=%lu readonly=%d brightness=%u home=%u text=%u theme=%u vibration=%u",
+        s_saved_preferences.slot,(unsigned long)s_saved_preferences.generation,s_saved_preferences.read_only,
+        s_ui.preferences.brightness,unsigned(s_ui.preferences.home_screen),unsigned(s_ui.preferences.text_size),
+        unsigned(s_ui.preferences.theme),s_ui.preferences.vibration_enabled);
+    if(xTaskCreate(device_status_task,"device_status",3072,nullptr,2,nullptr)!=pdPASS)
+        ESP_LOGW(TAG,"[STATUS] battery polling unavailable (nonfatal)");
     diagnose_touch_and_optional_i2c();
     initialize_buttons();
     initialize_audio_nonfatal();
@@ -1483,6 +1523,7 @@ void board_set_device_id(const char *device_id)
 
 void board_set_wifi_status(bool connected, const char *detail)
 {
+    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
     s_wifi_connected = connected;
     strlcpy(s_wifi_detail, detail == nullptr ? (connected ? "connected" : "disconnected") : detail,
             sizeof(s_wifi_detail));
@@ -1497,7 +1538,8 @@ void board_set_cloud_status(bool connected)
         const bool interrupted_voice = s_input_modal==InputModal::VOICE && s_voice_phase!="SENT";
         stop_speech(false);
         s_message_visible = false;
-        cancel_input(false,"cloud-disconnect");
+        if(!(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU"))
+            cancel_input(false,"cloud-disconnect");
         s_control_queue.clear();
         s_approval.disconnect();
         s_approval_action = {};
@@ -1552,7 +1594,6 @@ void board_select_server_target(void)
     cancel_input(true,"server-target");
     s_pi.selected = false;
     s_message_visible = false;
-    s_page = Page::OVERVIEW;
     draw_status();
 }
 
@@ -1573,16 +1614,16 @@ void board_set_pi_snapshot(const board_pi_snapshot_t *snapshot)
         ESP_LOGI(TAG,"[AGENT] target old_agent=%s new_agent=%s old_session=%s new_session=%s selected=%d",
                  s_pi.agent,snapshot->agent?snapshot->agent:"pi",s_pi.session_id,
                  snapshot->session_id?snapshot->session_id:"unknown",s_pi.selected);
-        stop_speech();cancel_input(true,"target-change");
+        stop_speech();
+        if(!(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU")) cancel_input(true,"target-change");
     }
     strlcpy(s_pi.agent,snapshot->agent?snapshot->agent:"pi",sizeof(s_pi.agent));
     strlcpy(s_pi.model,snapshot->model?snapshot->model:"",sizeof(s_pi.model));
     strlcpy(s_pi.effort,snapshot->effort?snapshot->effort:"",sizeof(s_pi.effort));
     strlcpy(s_pi.session_id, snapshot->session_id == nullptr ? "unknown" : snapshot->session_id,
             sizeof(s_pi.session_id));
-    strlcpy(s_pi.name, snapshot->name == nullptr ? s_pi.session_id : snapshot->name,
-            sizeof(s_pi.name));
-    strlcpy(s_pi.cwd, snapshot->cwd == nullptr ? "" : snapshot->cwd, sizeof(s_pi.cwd));
+    copy_agent_final(s_pi.name,snapshot->name == nullptr ? s_pi.session_id : snapshot->name);
+    copy_agent_final(s_pi.cwd,snapshot->cwd);
     strlcpy(s_pi.state, snapshot->state == nullptr ? "OFFLINE" : snapshot->state,
             sizeof(s_pi.state));
     strlcpy(s_pi.activity_type,
@@ -1599,8 +1640,7 @@ void board_set_pi_snapshot(const board_pi_snapshot_t *snapshot)
     s_pi.selected = true;
     if (changed_target) {
         s_message_visible = false;
-        ESP_LOGI(TAG,"[UI] page %s -> PI reason=target-change",page_name(s_page));
-        s_page = Page::PI;
+        ESP_LOGI(TAG,"[UI] backing target changed; app=%u preserved",unsigned(s_ui.app));
         draw_status("agent_target_change");
     } else {
         // Keep activity/timer work already pending. board_update coalesces all
@@ -1623,7 +1663,7 @@ void board_append_pi_activity(const char *activity_type, const char *activity)
 
 void board_notify_pi(bool failed)
 {
-    if (s_vibrator_ready) {
+    if (s_vibrator_ready && s_vibration_enabled.load()) {
         if (xTaskCreate(pi_notify_task, "pi_notify", VIBRATION_TASK_STACK_BYTES,
                         reinterpret_cast<void *>(failed ? 1U : 0U), 3, nullptr) != pdPASS) {
             ESP_LOGW(TAG, "notification vibration task allocation failed");
@@ -1638,55 +1678,32 @@ void board_notify_pi(bool failed)
 void board_next_page(void)
 {
     struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
-    if (s_approval.modal()) return;
-    s_message_visible = false;
-    const auto next = (static_cast<uint8_t>(s_page) + 1) % static_cast<uint8_t>(Page::COUNT);
-    ESP_LOGI(TAG,"[UI] page %s -> %s reason=A-click",page_name(s_page),page_name(static_cast<Page>(next)));
-    s_page = static_cast<Page>(next);
+    if(s_approval.modal() || s_input_modal!=InputModal::NONE) return;
+    s_message_visible=false;s_ui.navigate(true);sync_display_page();++s_ui_epoch;
+    ESP_LOGI(TAG,"[SHELL] A app=%u metric=%u detail=%u",unsigned(s_ui.app),s_ui.monitor_page,s_ui.agent_detail);
     draw_status();
 }
-
 void board_previous_page(void)
 {
     struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
-    if (s_approval.modal()) return;
-    s_message_visible = false;
-    const auto count = static_cast<uint8_t>(Page::COUNT);
-    const auto previous = (static_cast<uint8_t>(s_page) + count - 1) % count;
-    ESP_LOGI(TAG,"[UI] page %s -> %s reason=B-click",page_name(s_page),page_name(static_cast<Page>(previous)));
-    s_page = static_cast<Page>(previous);
+    if(s_approval.modal() || s_input_modal!=InputModal::NONE) return;
+    s_message_visible=false;s_ui.navigate(false);sync_display_page();++s_ui_epoch;
+    ESP_LOGI(TAG,"[SHELL] B app=%u selection=%u",unsigned(s_ui.app),s_ui.home_selection);
     draw_status();
 }
 
 void board_show_message(const char *title, const char *body)
 {
     struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
-    if (s_approval.modal()) return;
-    if (!s_display_ready) {
-        return;
-    }
-    lock_display();
-    s_message_visible = true;
-    s_message_until_ms = esp_timer_get_time() / 1000 + 5000;
-    s_display.startWrite();
-    s_display.fillScreen(TFT_BLACK);
-    s_display.setTextDatum(textdatum_t::top_center);
-    s_display.setTextColor(0xBDF7, TFT_BLACK);
-    s_display.setTextSize(2);
-    s_display.drawString(title, s_display.width() / 2, 110);
-    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
-    s_display.setTextSize(1);
-    s_display.setTextWrap(true, true);
-    s_display.drawCenterString(body, s_display.width() / 2, 190);
-    s_display.setTextColor(0x7BEF, TFT_BLACK);
-    s_display.drawString("Press a button to send an event", s_display.width() / 2, 340);
-    s_display.endWrite();
-    unlock_display();
+    if(s_approval.modal() || !s_display_ready) return;
+    s_message_visible=true;s_message_until_ms=esp_timer_get_time()/1000+5000;
+    s_message_title=title?title:"";s_message_body=body?body:"";
+    draw_status("message");
 }
 
 void board_vibrate(uint32_t duration_ms)
 {
-    if (!s_vibrator_ready) {
+    if (!s_vibrator_ready || !s_vibration_enabled.load()) {
         return;
     }
     duration_ms = std::min<uint32_t>(duration_ms, 500);
@@ -1697,6 +1714,7 @@ void board_update(void)
 {
     struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
     const uint64_t now_ms = esp_timer_get_time() / 1000;
+    if(s_draw_requested.exchange(false)) draw_status("callback_request");
     if (!s_speech.id.empty() && !audio_backend_speech_active(s_speech_generation)) {
         stop_speech(!s_speech.ended);draw_status();
     }
@@ -1704,7 +1722,7 @@ void board_update(void)
     const auto *current = s_approval.current();
     if (current && current->id != s_notified_approval) {
         s_notified_approval = current->id;
-        if (s_vibrator_ready) xTaskCreate(pi_notify_task, "approval_notify", VIBRATION_TASK_STACK_BYTES, reinterpret_cast<void *>(2U), 3, nullptr);
+        if (s_vibrator_ready && s_vibration_enabled.load()) xTaskCreate(pi_notify_task, "approval_notify", VIBRATION_TASK_STACK_BYTES, reinterpret_cast<void *>(2U), 3, nullptr);
         if (s_audio_ready) audio_backend_notify(audio_notification_t::APPROVAL);
     }
     const bool normal=s_input_modal==InputModal::NONE && !s_approval.modal();
@@ -1717,7 +1735,7 @@ void board_update(void)
         !s_approval.modal() && (normal || s_input_modal==InputModal::SETTINGS));
     log_stable_edge(false,b_down,s_button_b);
     handle_gesture(false,b,s_button_b.epoch,now_ms);
-    if (s_feedback_until && now_ms>=s_feedback_until) { cancel_input(false,"feedback-timeout");s_page=Page::PI;draw_status(); }
+    if (s_feedback_until && now_ms>=s_feedback_until) { cancel_input(false,"feedback-timeout");sync_display_page();draw_status(); }
     if (s_input_modal!=InputModal::NONE && now_ms-s_modal_redraw>=1000) {s_modal_redraw=now_ms;draw_status("modal_tick");}
     if (s_message_visible && now_ms >= s_message_until_ms) {
         s_message_visible = false;
@@ -1730,6 +1748,11 @@ void board_update(void)
             s_pi_redraw_pending = true;
         }
     }
+    const uint64_t shell_interval=!strcmp(s_pi.state,"RUNNING")?1000:
+        s_ui.app==wearable::App::Watch?60000:6000;
+    if(now_ms/shell_interval!=s_last_shell_tick && !page_overlay_visible() && s_ui.app!=wearable::App::Monitor) {
+        s_last_shell_tick=now_ms/shell_interval;draw_status("shell_clock_tick");
+    }
     if (s_pi.selected && agent_snapshot_needs_redraw(s_page,page_overlay_visible(),s_pi_redraw_pending) &&
         now_ms - s_last_pi_redraw_ms >= PI_REDRAW_MIN_INTERVAL_MS) {
         s_pi_redraw_pending = false;
@@ -1921,3 +1944,10 @@ void board_agent_activity(const char *agent, const char *session_id, const char
         set_pi_tail(recent);s_pi_redraw_pending=true;
     } else board_append_pi_activity(type,activity);
 }
+
+void board_sync_utc_time(double server_time_ms)
+{
+    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
+    const bool accepted=s_clock.sync(server_time_ms,esp_timer_get_time()/1000);
+    ESP_LOGI(TAG,"[TIME] gateway UTC accepted=%d",accepted);
+}
diff --git a/boards/m5stack_stopwatch/device_preferences_nvs.cpp b/boards/m5stack_stopwatch/device_preferences_nvs.cpp
new file mode 100644
index 0000000..a66a797
--- /dev/null
+++ b/boards/m5stack_stopwatch/device_preferences_nvs.cpp
@@ -0,0 +1,54 @@
+#include "device_preferences_nvs.h"
+#include "nvs.h"
+#include <cstring>
+
+namespace {
+constexpr const char* keys[] = {"prefs_a", "prefs_b"};
+struct Handle {
+    nvs_handle_t value=0;
+    ~Handle() { if(value) nvs_close(value); }
+};
+}
+wearable::LoadedPreferences preferences_load() {
+    Handle h;
+    const auto opened=nvs_open("wearable", NVS_READONLY, &h.value);
+    if(opened==ESP_ERR_NVS_NOT_FOUND) return {};
+    if(opened!=ESP_OK) { wearable::LoadedPreferences p; p.read_only=true; return p; }
+    std::array<uint8_t,wearable::MAX_BLOB_SIZE> data[2]{};
+    size_t sizes[2]={data[0].size(),data[1].size()};
+    bool read_error=false;
+    for(int i=0;i<2;++i) {
+        const auto result=nvs_get_blob(h.value,keys[i],data[i].data(),&sizes[i]);
+        if(result!=ESP_OK) {
+            sizes[i]=0;
+            if(result!=ESP_ERR_NVS_NOT_FOUND) read_error=true;
+        }
+    }
+    auto p=wearable::load(data[0].data(),sizes[0],data[1].data(),sizes[1]);
+    p.read_only=p.read_only || read_error;
+    return p;
+}
+esp_err_t preferences_save(wearable::LoadedPreferences& saved, const wearable::DevicePreferences& value) {
+    if(saved.read_only || saved.generation==UINT32_MAX) return ESP_ERR_INVALID_STATE;
+    wearable::Blob blob{};
+    if(!wearable::encode(value,saved.generation+1,blob)) return ESP_ERR_INVALID_ARG;
+    const int slot=saved.slot==0?1:0;
+    {
+        Handle h;
+        auto result=nvs_open("wearable",NVS_READWRITE,&h.value);
+        if(result!=ESP_OK) return result;
+        result=nvs_set_blob(h.value,keys[slot],blob.data(),blob.size());
+        if(result!=ESP_OK) return result;
+        result=nvs_commit(h.value);
+        if(result!=ESP_OK) return result; // Never erase or replace the previous slot.
+    }
+    Handle verify;
+    auto result=nvs_open("wearable",NVS_READONLY,&verify.value);
+    if(result!=ESP_OK) return result;
+    wearable::Blob readback{}; size_t size=readback.size();
+    result=nvs_get_blob(verify.value,keys[slot],readback.data(),&size);
+    if(result!=ESP_OK) return result;
+    if(size!=blob.size() || readback!=blob) return ESP_FAIL;
+    saved.value=value; saved.generation++; saved.slot=slot; saved.migration_needed=false;
+    return ESP_OK;
+}
diff --git a/boards/m5stack_stopwatch/device_preferences_nvs.h b/boards/m5stack_stopwatch/device_preferences_nvs.h
new file mode 100644
index 0000000..87de94b
--- /dev/null
+++ b/boards/m5stack_stopwatch/device_preferences_nvs.h
@@ -0,0 +1,5 @@
+#pragma once
+#include "device_preferences.h"
+#include "esp_err.h"
+wearable::LoadedPreferences preferences_load();
+esp_err_t preferences_save(wearable::LoadedPreferences& saved, const wearable::DevicePreferences& value);
diff --git a/docs/protocol.md b/docs/protocol.md
index fc64672..d688a71 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -231,3 +231,13 @@ Admin: `POST /api/devices/{id}/speech` accepts target/mode;
 `POST /api/devices/{id}/speech-test` accepts only target and speaks a fixed sentence.
 Existing Cloudflare Access protection applies. Model/effort settings are unchanged.
 See [Speech design](spoken-agent-design.md) for UI, provider and final-reply semantics.
+
+## 0.6.0 additive UTC field
+
+Authenticated `hello_ack` may include integer `server_time_ms` (UTC Unix
+milliseconds). Old clients ignore this optional field; CONTROL_VERSION remains
+0.5.0. New clients accept finite integer timestamps in 2024..2100 and record the
+receive-time monotonic base. Initial connect and reconnect refresh that base;
+no polling protocol is added. Missing/invalid time leaves the previous base
+unchanged. UTC storage is independent of the fixed Asia/Shanghai display offset.
+No RTC, NTP, alarm, or Voice/Speech protocol changes are included.
diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt
index 8554f79..a59970c 100644
--- a/firmware/CMakeLists.txt
+++ b/firmware/CMakeLists.txt
@@ -13,4 +13,4 @@ set(EXTRA_COMPONENT_DIRS
 )
 
 include($ENV{IDF_PATH}/tools/cmake/project.cmake)
-project(device_lab VERSION 0.5.1)
+project(device_lab VERSION 0.6.0)
diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt
index f8b8483..b8dafc9 100644
--- a/firmware/main/CMakeLists.txt
+++ b/firmware/main/CMakeLists.txt
@@ -4,4 +4,4 @@ idf_component_register(
     REQUIRES m5stack_stopwatch esp_event esp_netif esp_wifi esp_timer esp_websocket_client json nvs_flash mbedtls
 )
 
-target_compile_definitions(${COMPONENT_LIB} PRIVATE DEVICE_LAB_FIRMWARE_VERSION="0.5.1")
+target_compile_definitions(${COMPONENT_LIB} PRIVATE DEVICE_LAB_FIRMWARE_VERSION="0.6.0")
diff --git a/firmware/main/app_main.cpp b/firmware/main/app_main.cpp
index 23b5f4a..634e185 100644
--- a/firmware/main/app_main.cpp
+++ b/firmware/main/app_main.cpp
@@ -12,11 +12,11 @@ extern "C" void app_main(void)
     ESP_LOGI(TAG, "[BOOT] firmware %s", DEVICE_LAB_FIRMWARE_VERSION);
 
     esp_err_t nvs_result = nvs_flash_init();
-    if (nvs_result == ESP_ERR_NVS_NO_FREE_PAGES || nvs_result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
-        ESP_ERROR_CHECK(nvs_flash_erase());
-        nvs_result = nvs_flash_init();
+    // Preserve preferences/future NVS formats. Local defaults remain usable;
+    // an unavailable store reports APPLIED / NOT SAVED rather than erasing it.
+    if (nvs_result != ESP_OK) {
+        ESP_LOGW(TAG, "[PREFS] NVS unavailable: %s; preserving partition", esp_err_to_name(nvs_result));
     }
-    ESP_ERROR_CHECK(nvs_result);
 
     const esp_err_t board_result = board_init(DEVICE_LAB_FIRMWARE_VERSION);
     if (board_result != ESP_OK) {
diff --git a/firmware/main/cloud_client.cpp b/firmware/main/cloud_client.cpp
index 90ddae3..870e085 100644
--- a/firmware/main/cloud_client.cpp
+++ b/firmware/main/cloud_client.cpp
@@ -154,6 +154,8 @@ static void handle_message(const char *payload, size_t length)
     const cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type");
     if (cJSON_IsString(type) && strcmp(type->valuestring, "hello_ack") == 0) {
         ESP_LOGI(TAG, "[CLOUD] hello acknowledged");
+        const auto *server_time=cJSON_GetObjectItemCaseSensitive(root,"server_time_ms");
+        if(cJSON_IsNumber(server_time)) board_sync_utc_time(server_time->valuedouble);
         s_link.hello = true;
         mark_synchronized();
     } else if (cJSON_IsString(type) && strcmp(type->valuestring, "approval_request") == 0) {
diff --git a/firmware/main/wifi_manager.cpp b/firmware/main/wifi_manager.cpp
index a1c40a0..adfd398 100644
--- a/firmware/main/wifi_manager.cpp
+++ b/firmware/main/wifi_manager.cpp
@@ -59,7 +59,11 @@ esp_err_t wifi_manager_start(void)
     esp_netif_create_default_wifi_sta();
 
     wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
+    // Credentials are already supplied by Kconfig on every boot. Keep Wi-Fi
+    // independent of preference-store failure and never write its namespace.
+    init.nvs_enable = false;
     ESP_ERROR_CHECK(esp_wifi_init(&init));
+    ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
     ESP_ERROR_CHECK(esp_event_handler_instance_register(
         WIFI_EVENT, ESP_EVENT_ANY_ID, &handle_wifi_event, nullptr, &s_wifi_handler));
     ESP_ERROR_CHECK(esp_event_handler_instance_register(
diff --git a/firmware/tests/agent_view_test.cpp b/firmware/tests/agent_view_test.cpp
index 70eb368..f785f5f 100644
--- a/firmware/tests/agent_view_test.cpp
+++ b/firmware/tests/agent_view_test.cpp
@@ -1,4 +1,5 @@
 #include "board.h"
+#include "wearable_renderer.h"
 #ifdef HAS_FINAL_TEXT
 #include "agent_display.h"
 #endif
@@ -23,7 +24,9 @@ struct Display {
     int width() { return 468; }
     void setTextDatum(int) {}
     void setTextColor(int, int) {}
-    void setTextSize(int) {}
+    void setTextSize(float) {}
+    int fontHeight() {return 16;}
+    void fillRoundRect(int,int,int,int,int,int) {}
     void drawFastHLine(int, int, int, int) {}
     const Font *getFont() { return font; }
     void setFont(const Font *value) { font=value; }
@@ -65,6 +68,15 @@ struct { double cpu_percent=0, load_1m=0, load_5m=0, memory_percent=0, swap_perc
                 disk_percent=0, network_rx_bps=0, network_tx_bps=0; } s_server;
 int64_t esp_timer_get_time() { return 5000000; }
 void draw_header(const char *) {}
+void draw_shell_app() {
+    wearable::UiState ui;ui.preferences.text_size=wearable::TextSize::Compact;
+    wearable::ShellView view;
+    view.selected=s_pi.selected;view.state=s_pi.state;view.agent=s_pi.agent;
+    view.project=view.session=s_pi.name;view.activity_type=s_pi.activity_type;
+    view.activity=s_pi.latest_activity;view.recent=s_pi_tail;
+    view.final_text=s_pi.has_final_text?s_pi.final_text:nullptr;
+    wearable::ShellRenderer<Display,Font>(s_display,s_transcript_font,ui,view).agent();
+}
 #include "agent_view.inc"
 
 unsigned failures=0;
diff --git a/firmware/tests/network_resilience_test.cpp b/firmware/tests/network_resilience_test.cpp
index 0bab17f..1faf62f 100644
--- a/firmware/tests/network_resilience_test.cpp
+++ b/firmware/tests/network_resilience_test.cpp
@@ -1,4 +1,5 @@
 #include "board.h"
+#include "device_status.h"
 #include "cloud_link_state.h"
 #include "websocket_text_receiver.h"
 #include "cJSON.h"
@@ -57,6 +58,7 @@ void log_link_health(const char *, size_t, unsigned, int64_t) {}
 std::atomic<bool> s_connected{false}, s_ready{false};
 std::atomic<uint32_t> s_generation{0};
 CloudLinkState s_link;
+wearable::WallClock s_clock;
 esp_websocket_client_handle_t s_client=reinterpret_cast<void *>(1);
 char s_device_id[24]="host-test";
 WebSocketTextReceiver s_text_receiver;
@@ -65,7 +67,7 @@ unsigned s_rx_parts=0;
 
 enum class InputModal { NONE, VOICE, ERROR, SETTINGS };
 InputModal s_input_modal=InputModal::NONE;
-std::string s_voice_phase, s_modal_error, s_notified_approval;
+std::string s_voice_phase, s_modal_error, s_notified_approval, s_settings_phase;
 uint64_t s_voice_serial=1;
 bool s_cloud_connected=false, s_message_visible=false;
 std::deque<std::string> s_control_queue;
@@ -116,6 +118,12 @@ int main() {
     receive("{\"type\":\"hello_ack\"}"); assert(!s_cloud_connected);
     receive("{\"type\":\"agent_state\",\"agent\":\"pi\",\"session_id\":\"s\",\"name\":\"Selected Pi\",\"state\":\"RUNNING\",\"elapsed_seconds\":12}");
     assert(s_cloud_connected && s_ready && state=="RUNNING");
+    receive("{\"type\":\"hello_ack\",\"server_time_ms\":1791072000000}");
+    assert(s_clock.utc_ms(now_ms)==1791072000000);
+    receive("{\"type\":\"hello_ack\",\"server_time_ms\":\"invalid\"}");
+    assert(s_clock.utc_ms(now_ms)==1791072000000);
+    receive("{\"type\":\"hello_ack\",\"server_time_ms\":1}");
+    assert(s_clock.utc_ms(now_ms)==1791072000000);
     final_text="previous final"; s_control_queue.push_back("old approval"); s_approval_action.id="old";
     s_input_modal=InputModal::VOICE; s_voice_phase="RECORDING";
     now_ms=2000;
@@ -139,6 +147,11 @@ int main() {
     s_voice_serial=2; s_input_modal=InputModal::VOICE; board_voice_interrupted(1);
     assert(s_input_modal==InputModal::VOICE); board_voice_interrupted(2);
     assert(s_input_modal==InputModal::ERROR);
+    // Device/Display local settings are still usable across a transient link loss.
+    s_input_modal=InputModal::SETTINGS;s_settings_phase="MENU";s_cloud_connected=true;
+    board_set_cloud_status(false);
+    assert(s_input_modal==InputModal::SETTINGS);
+    s_settings_phase="MODEL";board_set_cloud_status(false);assert(s_input_modal==InputModal::NONE);
     for (const auto &log:logs) {
         assert(log.find("synthetic-only")==std::string::npos);
         assert(log.find("离线完成")==std::string::npos);
diff --git a/firmware/tests/nvs_stub/esp_err.h b/firmware/tests/nvs_stub/esp_err.h
new file mode 100644
index 0000000..9b61509
--- /dev/null
+++ b/firmware/tests/nvs_stub/esp_err.h
@@ -0,0 +1,4 @@
+#pragma once
+using esp_err_t=int;
+constexpr int ESP_OK=0, ESP_FAIL=-1, ESP_ERR_INVALID_STATE=1, ESP_ERR_INVALID_ARG=2,
+    ESP_ERR_NVS_NOT_FOUND=3, ESP_ERR_NVS_INVALID_LENGTH=4;
diff --git a/firmware/tests/nvs_stub/nvs.h b/firmware/tests/nvs_stub/nvs.h
new file mode 100644
index 0000000..e84488e
--- /dev/null
+++ b/firmware/tests/nvs_stub/nvs.h
@@ -0,0 +1,11 @@
+#pragma once
+#include "esp_err.h"
+#include <cstddef>
+#include <cstdint>
+using nvs_handle_t=uint32_t;
+constexpr int NVS_READONLY=0,NVS_READWRITE=1;
+esp_err_t nvs_open(const char*,int,nvs_handle_t*);
+void nvs_close(nvs_handle_t);
+esp_err_t nvs_get_blob(nvs_handle_t,const char*,void*,size_t*);
+esp_err_t nvs_set_blob(nvs_handle_t,const char*,const void*,size_t);
+esp_err_t nvs_commit(nvs_handle_t);
diff --git a/firmware/tests/run_network_resilience_test.py b/firmware/tests/run_network_resilience_test.py
index 807849e..ea8dd34 100644
--- a/firmware/tests/run_network_resilience_test.py
+++ b/firmware/tests/run_network_resilience_test.py
@@ -23,7 +23,7 @@ with tempfile.TemporaryDirectory(prefix='network-resilience-') as folder:
     (temp/'esp_err.h').write_text('typedef int esp_err_t;\n')
     (temp/'cloud.inc').write_text(functions(cloud, ('link_error_type', 'log_connection', 'mark_synchronized',
         'json_number', 'json_string', 'send_json', 'send_hello', 'handle_message', 'websocket_event')))
-    (temp/'board_link.inc').write_text(functions(board, ('board_set_cloud_status', 'board_voice_interrupted')))
+    (temp/'board_link.inc').write_text(functions(board, ('board_set_cloud_status', 'board_voice_interrupted', 'board_sync_utc_time')))
     subprocess.run(['cc', '-c', str(cjson/'cJSON.c'), '-I', str(cjson), '-o', str(temp/'cjson.o')], check=True)
     subprocess.run(['c++', '-std=c++17', '-Wall', '-Wextra', '-Werror', '-I', str(temp),
         '-I', str(repo/'boards/common/include'), '-I', str(repo/'firmware/main'), '-I', str(cjson),
diff --git a/firmware/tests/run_shell_input_test.py b/firmware/tests/run_shell_input_test.py
new file mode 100644
index 0000000..f42059d
--- /dev/null
+++ b/firmware/tests/run_shell_input_test.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python3
+"""Compile the production modal/button arbiter and shell navigation on host."""
+from pathlib import Path
+import re
+import subprocess
+import tempfile
+repo=Path(__file__).resolve().parents[2]
+source=(repo/'boards/m5stack_stopwatch/board.cpp').read_text()
+names=('handle_gesture','board_next_page','board_previous_page')
+with tempfile.TemporaryDirectory(prefix='shell-input-') as folder:
+    temp=Path(folder)
+    text='\n'.join(re.search(r'^[^\n]*\b'+name+r'\([^;]*?\)\s*\{.*?^}',source,re.M|re.S).group() for name in names)
+    (temp/'shell_input.inc').write_text(text)
+    subprocess.run(['c++','-std=c++17','-Wall','-Wextra','-Werror','-I',str(temp),
+        '-I',str(repo/'firmware/tests/nvs_stub'),'-I',str(repo/'boards/common/include'),
+        str(repo/'firmware/tests/shell_input_test.cpp'),'-o',str(temp/'test')],check=True)
+    subprocess.run([str(temp/'test')],check=True)
diff --git a/firmware/tests/shell_input_test.cpp b/firmware/tests/shell_input_test.cpp
new file mode 100644
index 0000000..be86213
--- /dev/null
+++ b/firmware/tests/shell_input_test.cpp
@@ -0,0 +1,77 @@
+#include "board.h"
+#include "ui_state.h"
+#include "button_gesture.h"
+#include "approval_control.h"
+#include <atomic>
+#include <cassert>
+#include <cstdio>
+#include <string>
+#include <vector>
+using namespace wearable;
+constexpr const char* TAG="host";
+void ESP_LOGI(const char*,const char*,...) {}
+enum class InputModal { NONE, VOICE, SETTINGS, ERROR };
+InputModal s_input_modal=InputModal::NONE;
+UiState s_ui;
+ApprovalControl s_approval;ApprovalAction s_approval_action;
+LoadedPreferences s_saved_preferences;
+uint64_t s_ui_epoch=1,s_voice_serial=0,s_voice_started=0;
+std::string s_settings_phase,s_settings_id,s_settings_target,s_preference_feedback,s_voice_id,s_voice_phase,s_modal_error;
+bool s_cloud_connected=false,s_message_visible=false;
+struct {bool selected=true;} s_pi;
+std::atomic<bool> s_vibration_enabled{true};
+struct QuickModel {std::string id,default_effort;std::vector<std::string> efforts;};
+std::vector<QuickModel> s_models;
+size_t s_model_index=0,s_effort_index=0;
+board_button_t s_page_click=BOARD_BUTTON_NONE;
+int s_page=0;
+const char* page_name(int) {return "host";}
+const char* modal_name(InputModal) {return "modal";}
+const char* gesture_name(Gesture) {return "gesture";}
+unsigned record_start=0,record_stop=0,saves=0,remote_requests=0,voice_send=0,voice_cancel=0;
+bool record_cancelled=false;
+struct {unsigned brightness=0;void setPanelBrightness(unsigned v) {brightness=v;}} s_display;
+void lock_display() {}
+void unlock_display() {}
+void draw_status() {}
+void log_modal_transition(InputModal,const char*) {}
+void stop_speech() {}
+bool audio_backend_record_start(uint64_t) {++record_start;return true;}
+void audio_backend_record_stop(bool cancelled) {++record_stop;record_cancelled=cancelled;}
+std::string new_identity(uint64_t* serial=nullptr) {if(serial) *serial=17;return "test-id";}
+std::string selected_target() {return "agent:pi:retained";}
+void voice_command(const char*,const char* decision) {if(std::string(decision)=="send") ++voice_send;else ++voice_cancel;}
+void settings_command(const char*) {++remote_requests;}
+void cancel_input(bool,const char*) {s_input_modal=InputModal::NONE;s_ui.close_settings();++s_ui_epoch;}
+esp_err_t preferences_save(LoadedPreferences&,const DevicePreferences&) {++saves;return ESP_FAIL;}
+void sync_display_page() {}
+#include "shell_input.inc"
+void gesture(bool a,Gesture g) {handle_gesture(a,g,s_ui_epoch+s_approval.generation,1000);}
+int main() {
+    s_ui.boot({});
+    // Offline Long B opens local settings, with no remote request or flash write.
+    gesture(false,Gesture::LONG);assert(s_input_modal==InputModal::SETTINGS && s_ui.settings_open && !remote_requests && !saves);
+    gesture(false,Gesture::LONG_RELEASE);assert(s_ui.menu==Menu::Root && s_ui.index==0);
+    gesture(true,Gesture::CLICK);gesture(false,Gesture::CLICK);assert(s_ui.menu==Menu::Device);
+    gesture(false,Gesture::CLICK);gesture(true,Gesture::CLICK);assert(!saves && s_ui.preferences.brightness==80);
+    gesture(false,Gesture::CLICK);assert(saves==1 && s_display.brightness==230 && s_preference_feedback=="APPLIED / NOT SAVED");
+    gesture(false,Gesture::LONG);assert(s_input_modal==InputModal::NONE && s_ui.app==App::Agent);
+    board_previous_page();board_next_page();board_previous_page();assert(s_ui.app==App::Monitor && s_pi.selected);
+    // Navigation never alters current Agent. Voice uses the existing capture path.
+    s_cloud_connected=true;gesture(true,Gesture::LONG);assert(record_start==1 && s_input_modal==InputModal::VOICE);
+    const auto app=s_ui.app;board_previous_page();assert(s_ui.app==app);
+    gesture(true,Gesture::LONG_RELEASE);assert(record_stop==1 && !record_cancelled && s_voice_phase=="TRANSCRIBING");
+    s_voice_phase="AWAITING_CONFIRMATION";gesture(true,Gesture::CLICK);assert(voice_send==1 && s_voice_phase=="SENDING" && !s_page_click);
+    cancel_input(false,"host");s_input_modal=InputModal::VOICE;s_voice_phase="AWAITING_CONFIRMATION";
+    gesture(false,Gesture::CLICK);assert(voice_cancel==1 && s_input_modal==InputModal::NONE);
+    // Approval owns A/B regardless of the backing app and suppresses long presses.
+    s_approval.receive({"approval-a","pi","command","details",0},1000);
+    gesture(true,Gesture::LONG);assert(record_start==1);
+    gesture(true,Gesture::CLICK);assert(s_approval_action.id=="approval-a" && s_approval_action.approve);
+    s_approval.disconnect();s_approval.receive({"approval-b","pi","command","details",0},1000);
+    gesture(false,Gesture::CLICK);assert(s_approval_action.id=="approval-b" && !s_approval_action.approve);
+    s_approval.disconnect();
+    const auto epoch=s_ui_epoch+s_approval.generation;
+    handle_gesture(false,Gesture::CLICK,epoch-1,1000);assert(s_page_click==BOARD_BUTTON_NONE);
+    std::puts("Production shell gestures / offline settings / Apply failure / Voice / Approval modal priority PASS (host)");
+}
diff --git a/firmware/tests/transcript_glyph_test.py b/firmware/tests/transcript_glyph_test.py
index 13f0215..e3072fd 100644
--- a/firmware/tests/transcript_glyph_test.py
+++ b/firmware/tests/transcript_glyph_test.py
@@ -43,7 +43,7 @@ with open(sys.argv[1], "rb") as handle:
 
 # Fallback assumes identical line height and baseline. Verify compiled assets.
 assert cn[10:13] == tw[10:13], "Chinese font line metrics differ"
-for character in "这是录音测试现在电這是錄音測試現在電哈喽你好正在检查已完成依赖不可用":
+for character in "这是录音测试现在电這是錄音測試現在電哈喽你好正在检查已完成依赖不可用很长的中文名称结果第二段不丢失繁體混合":
     assert has_glyph(cn, ord(character)) or has_glyph(tw, ord(character)), \
         f"missing test glyph U+{ord(character):04X}"
 for character in "現電":
diff --git a/firmware/tests/wearable_preferences_test.cpp b/firmware/tests/wearable_preferences_test.cpp
new file mode 100644
index 0000000..303fee2
--- /dev/null
+++ b/firmware/tests/wearable_preferences_test.cpp
@@ -0,0 +1,68 @@
+#include "device_preferences_nvs.h"
+#include "nvs.h"
+#include <cassert>
+#include <cstdio>
+#include <cstring>
+#include <map>
+#include <string>
+#include <vector>
+using namespace wearable;
+std::map<std::string,std::vector<uint8_t>> disk,pending;
+bool fail_set=false,fail_commit=false,fail_read=false;
+unsigned writes=0,commits=0;
+esp_err_t nvs_open(const char* space,int mode,nvs_handle_t* h) {
+    assert(std::strcmp(space,"wearable")==0); if(disk.empty() && mode==NVS_READONLY) return ESP_ERR_NVS_NOT_FOUND;
+    *h=1; return ESP_OK;
+}
+void nvs_close(nvs_handle_t) {pending.clear();}
+esp_err_t nvs_get_blob(nvs_handle_t,const char* key,void* out,size_t* size) {
+    if(fail_read) return ESP_FAIL;
+    auto it=disk.find(key);if(it==disk.end()) return ESP_ERR_NVS_NOT_FOUND;
+    if(*size<it->second.size()) return ESP_ERR_NVS_INVALID_LENGTH;
+    *size=it->second.size();std::memcpy(out,it->second.data(),*size);return ESP_OK;
+}
+esp_err_t nvs_set_blob(nvs_handle_t,const char* key,const void* data,size_t size) {
+    ++writes;if(fail_set) return ESP_FAIL;
+    pending[key]=std::vector<uint8_t>((const uint8_t*)data,(const uint8_t*)data+size);return ESP_OK;
+}
+esp_err_t nvs_commit(nvs_handle_t) {
+    ++commits;if(fail_commit) return ESP_FAIL;
+    for(auto& entry:pending) { disk[entry.first]=entry.second; }
+    return ESP_OK;
+}
+int main() {
+    assert(crc32((const uint8_t*)"123456789",9)==0xcbf43926);
+    auto p=preferences_load();assert(p.slot==-1 && !p.read_only && p.value.home_screen==Home::Agent);
+    assert(panel_value(80)==204 && panel_value(10)==26 && panel_value(100)==255);
+    auto value=p.value;value.brightness=30;
+    assert(preferences_save(p,value)==ESP_OK && p.slot==0 && p.generation==1);
+    auto first=disk["prefs_a"];
+    value.home_screen=Home::Watch;
+    assert(preferences_save(p,value)==ESP_OK && p.slot==1 && p.generation==2);
+    auto reboot=preferences_load();assert(reboot.generation==2 && reboot.value.home_screen==Home::Watch);
+    disk["prefs_b"][10]^=1;
+    reboot=preferences_load();assert(reboot.slot==0 && reboot.value.brightness==30);
+    disk["prefs_a"][0]=0;
+    reboot=preferences_load();assert(reboot.slot==-1 && reboot.value.brightness==80);
+    disk.clear();disk["prefs_a"]=first;p=preferences_load();
+    value.brightness=90;fail_commit=true;
+    assert(preferences_save(p,value)==ESP_FAIL && p.generation==1 && p.value.brightness==30);
+    assert(disk["prefs_a"]==first && preferences_load().value.brightness==30);
+    fail_commit=false;fail_set=true;assert(preferences_save(p,value)==ESP_FAIL && disk["prefs_a"]==first);fail_set=false;
+    auto future=first;future[4]=3;put32(future.data()+17,crc32(future.data(),17));disk["prefs_b"]=future;
+    p=preferences_load();assert(p.read_only && p.slot==0 && p.value.brightness==30);
+    const auto before=writes;assert(preferences_save(p,value)==ESP_ERR_INVALID_STATE && writes==before);
+    // Future envelope larger than the original prototype's 64-byte limit is protected.
+    future.resize(114);future[5]=100;put32(future.data()+110,crc32(future.data(),110));disk["prefs_b"]=future;
+    assert(preferences_load().read_only);
+    disk.erase("prefs_b");fail_read=true;assert(preferences_load().read_only);fail_read=false;
+    Blob max{};encode(value,UINT32_MAX,max);disk["prefs_a"]={max.begin(),max.end()};assert(preferences_load().read_only);
+    value.brightness=0;assert(!encode(value,1,max));value.brightness=80;
+    value.theme=Theme(9);assert(!encode(value,1,max));value.theme=Theme::Dark;
+    // Synthesized v1 migration fixture; no production 0.5 format was persisted.
+    encode(value,7,max);std::vector<uint8_t> old(max.begin(),max.begin()+15);old[4]=1;old[5]=5;
+    old.resize(19);put32(old.data()+15,crc32(old.data(),15));disk["prefs_a"]=old;
+    p=preferences_load();assert(p.migration_needed && p.generation==7);
+    assert(preferences_save(p,p.value)==ESP_OK && p.generation==8 && !p.migration_needed);
+    std::puts("Wearable preferences/NVS failure and reboot recovery PASS");
+}
diff --git a/firmware/tests/wearable_renderer_test.cpp b/firmware/tests/wearable_renderer_test.cpp
new file mode 100644
index 0000000..3bdadf0
--- /dev/null
+++ b/firmware/tests/wearable_renderer_test.cpp
@@ -0,0 +1,71 @@
+#include "wearable_renderer.h"
+#include <cassert>
+#include <cmath>
+#include <cstdio>
+#include <string>
+#include <vector>
+struct Font {} font;
+struct Draw {std::string text;int x,y,w,h;bool chinese;};
+struct Display {
+    float scale=1;bool chinese=false;std::vector<Draw> drawn;
+    void setFont(const Font*) {chinese=true;}
+    void setTextSize(float s) {scale=s;}
+    void setTextColor(unsigned,unsigned) {}
+    void setTextDatum(int) {}
+    int fontHeight() {return int(16*scale);}
+    int textWidth(const char* t) {
+        int units=0;for(const auto* p=t;*p;++p) {
+            auto c=(unsigned char)*p;if((c&0xc0)==0x80) continue;
+            units+=c<128?8:16;
+        }
+        return int(units*scale);
+    }
+    void drawString(const char* t,int x,int y) {drawn.push_back({t,x,y,textWidth(t),fontHeight(),chinese});}
+    void fillRoundRect(int x,int y,int w,int h,int,unsigned) {check(x,y,w,h);}
+    static void check(int x,int y,int w,int h) {
+        for(int px:{x,x+w}) for(int py:{y,y+h}) {
+            if((px-234)*(px-234)+(py-233)*(py-233)>217*217) {
+                std::fprintf(stderr,"unsafe box x=%d y=%d w=%d h=%d\n",x,y,w,h);assert(false);
+            }
+        }
+    }
+    void verify() {for(const auto& d:drawn) {check(d.x,d.y,d.w,d.h);assert(d.chinese);}drawn.clear();}
+    bool has(const char* t) {for(const auto& d:drawn) if(d.text.find(t)!=std::string::npos) return true;return false;}
+};
+int main() {
+    wearable::UiState ui;ui.boot({});wearable::ShellView v;
+    v.selected=true;v.agent="PI";v.project="esp32-dev-很长的中文和English-project-name";v.session="Long session 中文名稱";
+    v.state="RUNNING";v.activity_type="tool";v.activity="Running tests...";v.recent="正在检查中文。\nMixed English activity。";
+    v.time="18:42";v.date="Sun Oct 04";v.battery="BAT 3.91V";v.firmware="0.6.0";
+    Display g;
+    for(auto size:{wearable::TextSize::Compact,wearable::TextSize::Normal,wearable::TextSize::Large}) {
+        ui.preferences.text_size=size;
+        for(auto theme:{wearable::Theme::Dark,wearable::Theme::Dim}) {
+            ui.preferences.theme=theme;
+            for(bool online:{false,true}) {
+                v.gateway=online;v.wifi=online;
+                wearable::ShellRenderer<Display,Font> renderer(g,font,ui,v);
+                renderer.watch();assert(g.has("RUNNING"));g.verify();
+                ui.agent_detail=0;renderer.agent();assert(g.has("SUMMARY") || g.has("TOOL"));g.verify();
+                ui.agent_detail=1;renderer.agent();g.verify();renderer.home();g.verify();
+                for(auto menu:{wearable::Menu::Root,wearable::Menu::Agent,wearable::Menu::Device,wearable::Menu::Display,wearable::Menu::System}) {
+                    ui.menu=menu;
+                    for(unsigned i=0;i<ui.items();++i) {ui.index=i;renderer.settings();g.verify();}
+                }
+                ui.menu=wearable::Menu::Edit;
+                for(unsigned i=0;i<5;++i) {ui.field=wearable::Field(i);renderer.settings();g.verify();}
+            }
+        }
+    }
+    ui.preferences.text_size=wearable::TextSize::Large;
+    ui.agent_detail=2;v.state="DONE";v.final_text="你好，检查已经完成。English result with enough text to require multiple pages.\n第二段很长的结果保持可读取。第三段还是结果。第四段不丢失。";
+    std::string seen;
+    for(int page=0;page<20;++page) {
+        v.now_ms=page*6000;
+        wearable::ShellRenderer<Display,Font>(g,font,ui,v).agent();
+        for(const auto& line:g.drawn) if(line.y>=254 && line.y<350) seen+=line.text;
+        assert(!g.has("Running tests"));g.verify();
+    }
+    assert(seen.find("不丢失")!=std::string::npos);
+    std::puts("Wearable production renderer round-safe boxes / mixed Chinese / all sizes / long final paging PASS (host metrics)");
+}
diff --git a/firmware/tests/wearable_shell_test.cpp b/firmware/tests/wearable_shell_test.cpp
new file mode 100644
index 0000000..fb5d3ad
--- /dev/null
+++ b/firmware/tests/wearable_shell_test.cpp
@@ -0,0 +1,31 @@
+#include "ui_state.h"
+#include "device_status.h"
+#include <cassert>
+#include <cstdio>
+#include <limits>
+using namespace wearable;
+int main() {
+    UiState ui;ui.boot({});assert(ui.app==App::Agent);
+    ui.navigate(false);assert(ui.app==App::Home && ui.home_selection==1);
+    ui.navigate(true);ui.navigate(false);assert(ui.app==App::Monitor);
+    for(int i=0;i<5;++i) { ui.navigate(true); }
+    assert(ui.monitor_page==0);
+    ui.open_settings();ui.index=1;ui.settings_click(false);assert(ui.menu==Menu::Device);
+    ui.settings_click(false);assert(ui.menu==Menu::Edit);
+    for(int i=0;i<3;++i) assert(ui.settings_click(true)==UiEffect::None);
+    assert(ui.preferences.brightness==80 && ui.draft.brightness==10);
+    ui.close_settings();assert(ui.app==App::Monitor && ui.preferences.brightness==80);
+    ui.open_settings();ui.index=1;ui.settings_click(false);ui.settings_click(false);
+    ui.settings_click(true);assert(ui.settings_click(false)==UiEffect::Apply && ui.preferences.brightness==90);
+    ui.index=1;ui.settings_click(false);ui.settings_click(true);assert(ui.settings_click(false)==UiEffect::Apply);
+    assert(!ui.preferences.vibration_enabled);
+    ui.close_settings();ui.navigate(false);ui.navigate(true);ui.navigate(false);assert(ui.app==App::Watch);
+    ui.navigate(true);assert(ui.app==App::Agent);
+    ui.navigate(true);assert(ui.agent_detail==1);ui.navigate(true);assert(ui.agent_detail==2);ui.navigate(true);assert(ui.agent_detail==0);
+    WallClock clock;assert(clock.utc_ms(0)==0);
+    assert(!clock.sync(0,0) && !clock.sync(std::numeric_limits<double>::infinity(),0));
+    assert(!clock.sync(1791072000000.5,0));assert(clock.sync(1791072000000,100));
+    assert(clock.utc_ms(6100)==1791072006000); // Keeps advancing without network truth.
+    assert(!clock.sync(1,8000) && clock.utc_ms(8100)==1791072008000);
+    std::puts("Wearable shell navigation/draft and UTC clock PASS");
+}
diff --git a/scripts/test-wearable-shell.sh b/scripts/test-wearable-shell.sh
new file mode 100755
index 0000000..b1eee65
--- /dev/null
+++ b/scripts/test-wearable-shell.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+test_bin="$(mktemp)"
+trap 'rm -f "$test_bin"' EXIT
+c++ -std=c++17 -Wall -Wextra -Werror -I "$repo_dir/boards/common/include" \
+    -I "$repo_dir/boards/m5stack_stopwatch" -I "$repo_dir/firmware/tests/nvs_stub" \
+    "$repo_dir/firmware/tests/wearable_preferences_test.cpp" \
+    "$repo_dir/boards/m5stack_stopwatch/device_preferences_nvs.cpp" -o "$test_bin"
+"$test_bin"
+c++ -std=c++17 -Wall -Wextra -Werror -I "$repo_dir/boards/common/include" \
+    "$repo_dir/firmware/tests/wearable_shell_test.cpp" -o "$test_bin"
+"$test_bin"
+c++ -std=c++17 -Wall -Wextra -Werror -I "$repo_dir/boards/common/include" \
+    "$repo_dir/firmware/tests/wearable_renderer_test.cpp" -o "$test_bin"
+"$test_bin"
+python3 "$repo_dir/firmware/tests/run_shell_input_test.py"
diff --git a/server/device_gateway/app.py b/server/device_gateway/app.py
index 3324f27..3b24baa 100644
--- a/server/device_gateway/app.py
+++ b/server/device_gateway/app.py
@@ -742,7 +742,7 @@ async def device_websocket(websocket: WebSocket) -> None:
                 if isinstance(hello.get("capabilities"), list) else set(),
         )
         devices[device_id] = device
-        await send_to_device(device, {"type": "hello_ack"})
+        await send_to_device(device, {"type": "hello_ack", "server_time_ms": int(utc_now().timestamp() * 1000)})
         snapshot = system_collector.latest or system_collector.collect()
         await send_to_device(device, snapshot.model_dump())
         target = target_store.get(device_id)
diff --git a/server/tests/test_gateway.py b/server/tests/test_gateway.py
index 64bc576..817a961 100644
--- a/server/tests/test_gateway.py
+++ b/server/tests/test_gateway.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import asyncio
 import logging
 from typing import Any
+import time
 
 import pytest
 from fastapi import HTTPException, WebSocketDisconnect
@@ -92,7 +93,10 @@ def test_bidirectional_device_flow(caplog: pytest.LogCaptureFixture, tmp_path: A
         )
         task = asyncio.create_task(device_websocket(websocket))  # type: ignore[arg-type]
 
-        assert await asyncio.wait_for(websocket.sent.get(), 1) == {"type": "hello_ack"}
+        ack = await asyncio.wait_for(websocket.sent.get(), 1)
+        assert ack["type"] == "hello_ack"
+        assert isinstance(ack["server_time_ms"], int)
+        assert abs(ack["server_time_ms"] - time.time() * 1000) < 5000
         snapshot = await asyncio.wait_for(websocket.sent.get(), 1)
         assert snapshot["type"] == "server_snapshot"
         assert snapshot["network_rx_bps"] >= 0
