diff --git a/client/client.js b/client/client.js
index d614db0..2a3b4fc 100644
--- a/client/client.js
+++ b/client/client.js
@@ -108,6 +108,87 @@ function redactStatus(s) {
};
}
+// client/pocket-nav-icon.mjs
+var NAV_ICON_MARKER = "data-dsh-pocket-nav-icon";
+var NAV_ROW_SELECTOR = '[role="dialog"] nav button';
+var NAV_ICON_SIZE = 16;
+var NAV_ICON_SIZE_NARROW = 14;
+var NARROW_MEDIA_QUERY = "(max-width: 1023px)";
+var PHONE_GLYPH_SVG = '';
+function phoneMaskUrl(svg = PHONE_GLYPH_SVG) {
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
+}
+function isOwnNavRow(rowText, wantedLabel) {
+ const wanted = String(wantedLabel ?? "").trim();
+ if (wanted.length === 0) return false;
+ return String(rowText ?? "").trim() === wanted;
+}
+function navIconCss(maskUrl) {
+ return [
+ `[${NAV_ICON_MARKER}] > svg { display: none; }`,
+ `[${NAV_ICON_MARKER}]::before {`,
+ ` content: '';`,
+ ` flex: none;`,
+ ` width: ${NAV_ICON_SIZE}px;`,
+ ` height: ${NAV_ICON_SIZE}px;`,
+ ` background-color: currentColor;`,
+ ` -webkit-mask-image: url("${maskUrl}");`,
+ ` mask-image: url("${maskUrl}");`,
+ ` -webkit-mask-repeat: no-repeat;`,
+ ` mask-repeat: no-repeat;`,
+ ` -webkit-mask-position: center;`,
+ ` mask-position: center;`,
+ ` -webkit-mask-size: ${NAV_ICON_SIZE}px ${NAV_ICON_SIZE}px;`,
+ ` mask-size: ${NAV_ICON_SIZE}px ${NAV_ICON_SIZE}px;`,
+ `}`,
+ `@media ${NARROW_MEDIA_QUERY} {`,
+ ` [${NAV_ICON_MARKER}]::before {`,
+ ` width: ${NAV_ICON_SIZE_NARROW}px;`,
+ ` height: ${NAV_ICON_SIZE_NARROW}px;`,
+ ` -webkit-mask-size: ${NAV_ICON_SIZE_NARROW}px ${NAV_ICON_SIZE_NARROW}px;`,
+ ` mask-size: ${NAV_ICON_SIZE_NARROW}px ${NAV_ICON_SIZE_NARROW}px;`,
+ ` }`,
+ `}`
+ ].join("\n");
+}
+function installSettingsNavIcon(ctx, resolveLabel) {
+ if (typeof document === "undefined") return;
+ ctx.effect(() => {
+ const tag = document.createElement("style");
+ tag.dataset.plugin = "dsh-pocket";
+ tag.dataset.pluginCss = "dsh-pocket/nav-icon";
+ tag.textContent = navIconCss(phoneMaskUrl());
+ document.head.appendChild(tag);
+ let disposed = false;
+ let scheduled = false;
+ const sync = () => {
+ scheduled = false;
+ if (disposed) return;
+ const wanted = resolveLabel();
+ for (const row of document.querySelectorAll(NAV_ROW_SELECTOR)) {
+ if (isOwnNavRow(row.textContent, wanted)) row.setAttribute(NAV_ICON_MARKER, "");
+ else row.removeAttribute(NAV_ICON_MARKER);
+ }
+ };
+ const schedule = () => {
+ if (scheduled || disposed) return;
+ scheduled = true;
+ queueMicrotask(sync);
+ };
+ sync();
+ const observer = new MutationObserver(schedule);
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
+ return () => {
+ disposed = true;
+ observer.disconnect();
+ for (const row of document.querySelectorAll(`[${NAV_ICON_MARKER}]`)) {
+ row.removeAttribute(NAV_ICON_MARKER);
+ }
+ tag.remove();
+ };
+ }, "dsh-pocket: settings nav icon");
+}
+
// client/mobile/MobileNavToggle.tsx
var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
function MobileNavToggle({ toggleSidebar, t }) {
@@ -2774,6 +2855,7 @@ function apply(ctx) {
const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(POCKET_RPC_CHANNEL, endpoint, payload, signal);
const translate = ctx.locale.bind(NS2);
ctx.effect(() => ctx.locale.register(NS2, { zh: zh2, en: en2 }), "dsh-pocket: pocket locale dictionaries");
+ installSettingsNavIcon(ctx, () => translate("section"));
ctx.slots.inject(
"settings.section",
() => ctx.slots.register(
diff --git a/client/index.jsx b/client/index.jsx
index 1227511..9cc938e 100644
--- a/client/index.jsx
+++ b/client/index.jsx
@@ -10,6 +10,7 @@
import { createElement as h, useEffect, useRef, useState } from 'react';
import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, MOBILE_RIGHTBAR_ATTRIBUTE, MOBILE_RIGHTBAR_EVENT, redactStatus, compareVersions } from './api.js';
+import { installSettingsNavIcon } from './pocket-nav-icon.mjs';
import { mobileApply } from './mobile/mobile-apply.tsx';
import { NS as POCKET_NS, zh as POCKET_ZH, en as POCKET_EN } from './pocket-locales.js';
@@ -676,6 +677,10 @@ export function apply(ctx) {
const translate = ctx.locale.bind(POCKET_NS);
ctx.effect(() => ctx.locale.register(POCKET_NS, { zh: POCKET_ZH, en: POCKET_EN }), 'dsh-pocket: pocket locale dictionaries');
+ // 设置页导航一级入口的图标:外壳 navIcon() 只给内置 id 配图标、其余兜底齿轮,
+ // 而 settings.section 没有 icon 字段可传 —— 这里按本行文案认行后换成手机图标。
+ installSettingsNavIcon(ctx, () => translate('section'));
+
// 设置一级入口(与 通用设置/模型/插件 同级,order 1 = 通用之后、最外层)
ctx.slots.inject('settings.section', () =>
ctx.slots.register(
diff --git a/client/pocket-nav-icon.mjs b/client/pocket-nav-icon.mjs
new file mode 100644
index 0000000..194bb5a
--- /dev/null
+++ b/client/pocket-nav-icon.mjs
@@ -0,0 +1,156 @@
+// 设置页「手机访问」的导航图标。
+//
+// 背景:DSH 0.1.x 的设置外壳(dsh-client-ui-settings-general)把导航图标写死在
+// 内部的 navIcon(id) 里——只认 models / agent-presets / plugins 三个内置 id,
+// 其余一律兜底成齿轮;而 settings.section 的注册项由外壳只投影 id / order / label
+// (见 ui-settings 的 slots 契约与运行时 slot 清单),没有 icon 字段可传。
+// 所以在设置弹层挂载后,按「自己那一行的可见文案」认出自己的导航按钮,打一个
+// data 标记,再由 CSS 把兜底齿轮换成设计稿的手机图标。
+//
+// 边界:只认自己这一行(文案是本插件自己的本地化文案,见 pocket-locales.js 的
+// section),不碰外壳结构;标记随 fiber 释放一起移除,locale 切换后由
+// MutationObserver 重新认行。上游若给 settings.section 增加 icon 字段,本模块可整个删掉。
+//
+// 认行判定 isOwnNavRow 是纯函数、不碰 DOM,便于 test/ 里用桩元素喂进来
+// (与 client/mobile/nav-targets.mjs、layout-mode.mjs 同款做法)。
+
+/** 标记属性:只落在本插件自己的那一行导航上。 */
+export const NAV_ICON_MARKER = 'data-dsh-pocket-nav-icon';
+
+/** 认行范围:设置弹层导航里的按钮(外壳 SettingsPanel 的 DOM 契约)。 */
+export const NAV_ROW_SELECTOR = '[role="dialog"] nav button';
+
+/** 图标盒尺寸(px)。与外壳 navIcon 渲染的 size 一致。 */
+export const NAV_ICON_SIZE = 16;
+
+/** 窄屏下 client/mobile/mobile.css.ts 把设置页导航图标缩到 14px,这里跟着缩,
+ * 免得自己这一格比别的大一圈。 */
+export const NAV_ICON_SIZE_NARROW = 14;
+
+/** 窄屏断点:与 mobile.css.ts 的 `@media (max-width: 1023px)` 对齐。 */
+export const NARROW_MEDIA_QUERY = '(max-width: 1023px)';
+
+/**
+ * 设计稿手机图标(16×16 网格):圆角机身 + 顶部听筒 + 底部 Home 键。
+ *
+ * 作为 CSS mask 使用,所以固定用黑色(黑 = 不透明 = 显示);实际颜色由
+ * `background-color: currentColor` 决定,随主题/选中态走。
+ *
+ * 比例取自设计稿(机身 345×535、听筒 102×27、Home 键外径 80、壁厚 19),
+ * 但按 16px 网格重新落点:设计稿描边只占机身宽的 7.8%,等比缩到 16px 是 0.78px,
+ * 与 DSH 图标族 1.5px 的描边不一致,故描边统一取 1.5px;听筒与 Home 键改为
+ * 「按留白落点」——留出与机身边框的间隙,否则在 16px 下会和边框糊在一起。
+ * Home 键缩到 16px 后壁厚只剩 0.53px,无法成环,落成同直径实心点。
+ */
+export const PHONE_GLYPH_SVG =
+ '';
+
+/** mask 用的 data URI(运行时编码,避免手写转义)。 */
+export function phoneMaskUrl(svg = PHONE_GLYPH_SVG) {
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
+}
+
+/**
+ * 这一行是不是本插件的导航行。
+ *
+ * 纯函数,判定依据只有「该行的可见文案 === 本插件当前 locale 下的文案」。
+ * wantedLabel 为空时一律不认——否则文案还没解析出来时会把标记撒到别的分区上。
+ *
+ * @param rowText - 候选导航按钮的可见文案(通常取 textContent)。
+ * @param wantedLabel - 本插件当前 locale 的 section 文案。
+ * @returns 命中为 true。
+ */
+export function isOwnNavRow(rowText, wantedLabel) {
+ const wanted = String(wantedLabel ?? '').trim();
+ if (wanted.length === 0) return false;
+ return String(rowText ?? '').trim() === wanted;
+}
+
+/** 注入的样式文本:隐藏外壳兜底的齿轮 svg,用 mask 画设计稿图标。 */
+export function navIconCss(maskUrl) {
+ return [
+ `[${NAV_ICON_MARKER}] > svg { display: none; }`,
+ `[${NAV_ICON_MARKER}]::before {`,
+ ` content: '';`,
+ ` flex: none;`,
+ ` width: ${NAV_ICON_SIZE}px;`,
+ ` height: ${NAV_ICON_SIZE}px;`,
+ ` background-color: currentColor;`,
+ ` -webkit-mask-image: url("${maskUrl}");`,
+ ` mask-image: url("${maskUrl}");`,
+ ` -webkit-mask-repeat: no-repeat;`,
+ ` mask-repeat: no-repeat;`,
+ ` -webkit-mask-position: center;`,
+ ` mask-position: center;`,
+ ` -webkit-mask-size: ${NAV_ICON_SIZE}px ${NAV_ICON_SIZE}px;`,
+ ` mask-size: ${NAV_ICON_SIZE}px ${NAV_ICON_SIZE}px;`,
+ `}`,
+ `@media ${NARROW_MEDIA_QUERY} {`,
+ ` [${NAV_ICON_MARKER}]::before {`,
+ ` width: ${NAV_ICON_SIZE_NARROW}px;`,
+ ` height: ${NAV_ICON_SIZE_NARROW}px;`,
+ ` -webkit-mask-size: ${NAV_ICON_SIZE_NARROW}px ${NAV_ICON_SIZE_NARROW}px;`,
+ ` mask-size: ${NAV_ICON_SIZE_NARROW}px ${NAV_ICON_SIZE_NARROW}px;`,
+ ` }`,
+ `}`,
+ ].join('\n');
+}
+
+/**
+ * 把「手机访问」那一行的兜底齿轮换成设计稿的手机图标。
+ *
+ * 只做两件事:给认出来的导航按钮打标记,注入一条只认该标记的样式。认行依据是
+ * resolveLabel() 返回的本插件本地化文案,所以 locale 切换、设置弹层开关、外壳
+ * 重渲染都能自愈。
+ *
+ * @param ctx - client 根上下文(取其 effect 做随 fiber 释放的副作用登记)。
+ * @param resolveLabel - 返回当前 locale 下「手机访问」文案的函数。
+ */
+export function installSettingsNavIcon(ctx, resolveLabel) {
+ if (typeof document === 'undefined') return;
+
+ ctx.effect(() => {
+ const tag = document.createElement('style');
+ tag.dataset.plugin = 'dsh-pocket';
+ tag.dataset.pluginCss = 'dsh-pocket/nav-icon';
+ tag.textContent = navIconCss(phoneMaskUrl());
+ document.head.appendChild(tag);
+
+ let disposed = false;
+ let scheduled = false;
+
+ const sync = () => {
+ scheduled = false;
+ if (disposed) return;
+ const wanted = resolveLabel();
+ for (const row of document.querySelectorAll(NAV_ROW_SELECTOR)) {
+ if (isOwnNavRow(row.textContent, wanted)) row.setAttribute(NAV_ICON_MARKER, '');
+ else row.removeAttribute(NAV_ICON_MARKER);
+ }
+ };
+
+ // 合并同一帧内的多次 DOM 变更,且在绘制前落地,避免图标闪一下齿轮。
+ const schedule = () => {
+ if (scheduled || disposed) return;
+ scheduled = true;
+ queueMicrotask(sync);
+ };
+
+ sync();
+ const observer = new MutationObserver(schedule);
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
+
+ return () => {
+ disposed = true;
+ observer.disconnect();
+ for (const row of document.querySelectorAll(`[${NAV_ICON_MARKER}]`)) {
+ row.removeAttribute(NAV_ICON_MARKER);
+ }
+ tag.remove();
+ };
+ }, 'dsh-pocket: settings nav icon');
+}
diff --git a/test/pocket-nav-icon.test.js b/test/pocket-nav-icon.test.js
new file mode 100644
index 0000000..44233e2
--- /dev/null
+++ b/test/pocket-nav-icon.test.js
@@ -0,0 +1,139 @@
+// 设置页「手机访问」导航图标(client/pocket-nav-icon.mjs)。
+//
+// 没有 DOM 可用,所以认行判定是纯函数 isOwnNavRow(),用字符串/桩元素喂进去即可;
+// 样式与图标几何按文本断言(与 test/mobile-nav.test.js 同款做法)。
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const {
+ NAV_ICON_MARKER,
+ NAV_ROW_SELECTOR,
+ NAV_ICON_SIZE,
+ NAV_ICON_SIZE_NARROW,
+ NARROW_MEDIA_QUERY,
+ PHONE_GLYPH_SVG,
+ phoneMaskUrl,
+ isOwnNavRow,
+ navIconCss,
+} = await import('../client/pocket-nav-icon.mjs');
+
+const { zh, en } = await import('../client/pocket-locales.js');
+
+test('认行:只认「手机访问」自己那一行', () => {
+ assert.equal(isOwnNavRow(zh.section, zh.section), true, '中文文案命中');
+ assert.equal(isOwnNavRow(en.section, en.section), true, '英文文案命中');
+ assert.equal(isOwnNavRow(` ${zh.section}\n`, zh.section), true, '两侧空白不影响');
+ // 其它分区不能被标记,否则会把别人的图标也换掉
+ assert.equal(isOwnNavRow('通用设置', zh.section), false);
+ assert.equal(isOwnNavRow('模型', zh.section), false);
+ assert.equal(isOwnNavRow('', zh.section), false);
+ assert.equal(isOwnNavRow(undefined, zh.section), false);
+ assert.equal(isOwnNavRow(null, zh.section), false);
+});
+
+test('认行:文案取不到时一律不认(不能撒网式标记)', () => {
+ for (const empty of ['', ' ', undefined, null]) {
+ assert.equal(isOwnNavRow(zh.section, empty), false, `wantedLabel=${JSON.stringify(empty)} 不应命中`);
+ assert.equal(isOwnNavRow('通用设置', empty), false);
+ }
+});
+
+test('认行:切到英文后旧的中文行不再命中', () => {
+ assert.equal(isOwnNavRow(zh.section, en.section), false);
+ assert.equal(isOwnNavRow(en.section, zh.section), false);
+});
+
+test('标记属性与认行范围是外壳契约的一部分', () => {
+ assert.match(NAV_ICON_MARKER, /^data-[a-z0-9-]+$/, '必须是合法的 data 属性名');
+ // 外壳 SettingsPanel:面板 role="dialog",导航在