dsh-mobile 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@
2
2
 
3
3
  [English](MOBILE_PLUGIN_ADAPTATION.en.md) · [返回文档索引](README.md)
4
4
 
5
+ > 验收级别与数字出处见[插件移动端基线](MOBILE_PLUGIN_BASELINE.md);本文负责做法与示例。
6
+
5
7
  这份指南面向 DSH 插件作者,目标是**小屏可用,桌面功能不回退**。DSH Mobile 提供连接、页面布局和公共入口,不会逐个重排第三方插件的内部控件。可以先用浏览器完成适配,再补充真机验证;没有 Android 设备并不妨碍开始。
6
8
 
7
9
  ## 先确认面板所在位置
@@ -124,6 +126,22 @@ h('button', { className: 'icon-button', 'aria-label': label }, h(Icon, { 'aria-h
124
126
 
125
127
  在窄屏与宽屏下,分别打开抽屉、详情和设置,检查实际可见性、焦点及 `document.elementFromPoint()` 命中。`smoke:plugin-portal-layers` 的受控场景检查正层级 body 菜单和浏览器顶层对话框,不代表所有社区插件均已验收。
126
128
 
129
+ ## 宿主兜底:触控命中区
130
+
131
+ 主槽打开第三方插件面板时,移动层会做三件兜底的事:
132
+
133
+ - 粗指针下,面板中的普通按钮、选择框和文本输入控件获得 **44×44 CSS px 的最小布局尺寸**。这会改变控件盒子及其视觉尺寸,不是不可见的命中区扩展;开关、复选框、范围滑块等控件保持自有几何,多行输入由面板自己处理;
134
+ - 面板根若是 `width:100vw` 或固定像素宽,会被收进可用宽度,不再横向溢出;
135
+ - 面板内容区可以滚动并阻止滚动链,不把整个主区也变成第二层滚动容器;插件自己的滚动容器仍是第一选择。
136
+
137
+ 这是**兜底**,不是替插件做适配。插件若自带一套触控几何、不希望被覆盖,在根节点加:
138
+
139
+ ```html
140
+ <main data-dsh-adapt="off">…</main>
141
+ ```
142
+
143
+ 根节点退出时,尺寸和外层滚动覆盖也一起退出;内部控件组也可以使用该属性保留自有控件尺寸。这些规则只在粗指针下生效,不修改细指针页面的布局或滚动。完整适配仍由插件作者按可用空间实现;这不是任意插件组合的兼容保证。
144
+
127
145
  ## 验证:代码检查、浏览器与真机分别证明什么
128
146
 
129
147
  ### 结构检查
@@ -0,0 +1,117 @@
1
+ # Plugin mobile baseline
2
+
3
+ [中文](MOBILE_PLUGIN_BASELINE.md) · [Documentation index](README.md) · [Adaptation guide (how to)](MOBILE_PLUGIN_ADAPTATION.en.md)
4
+
5
+ This document gives a plugin-owned Web panel in a browser or WebView a baseline that can be accepted or rejected: three levels, the source of every number, a stylesheet to copy, and how to prove it holds. It answers "how good is good enough"; **how** to get there is in the [adaptation guide](MOBILE_PLUGIN_ADAPTATION.en.md), and this project's own visual numbers live in the [design reference](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md).
6
+
7
+ It is an **informative adaptation guide** and does not change the existing boundary: the mobile layer owns connectivity and page layout, it does not rearrange a third-party plugin's internal controls, and a panel's small-screen usability is the plugin author's responsibility. The levels are not a release gate; they exist for issue checklists, PR self-review, or an agreement between an author and their users. Missing a level is not a defect — per the [contributing guide](https://github.com/saya-ch/dsh-mobile/blob/main/CONTRIBUTING.md), a compatibility request is not a guarantee for every third-party combination. **This baseline is this project's adaptation guidance, not an official DSH standard, and does not represent the DSH team's position.**
8
+
9
+ ## Three levels
10
+
11
+ | Level | Name | Requirement (must / should) | Typical evidence |
12
+ | --- | --- | --- | --- |
13
+ | L1 | Tappable | **Must**: main Web actions have a hit region of at least 44×44 CSS px (48px for a primary action where space allows); adjacent actions keep spacing; inputs and editable areas inherit at least 16px type; keyboard focus stays visible; no page-level horizontal overflow at 375px; collapsing width never hides the only entry point | Renders at 375px and 390px widths with hit-region measurements |
14
+ | L2 | Comfortable | **Should**: switch on the panel's **measured width** (600px or less suggested); horizontal chip/tab strips scroll sideways; inline text actions collapse to an icon with an accessible name; body text follows `--dsh-content-font-size`; edge-to-edge areas reserve `env(safe-area-inset-*)`; an independent scroll container sets `overscroll-behavior: contain`; forms collapse to one column in the narrow tier | The same panel in a narrow and a wide container |
15
+ | L3 | Regressible | **Should**: the static preview can render at a requested width; the structural assertions for L1/L2 are wired into the render check; the desktop render has a baseline to compare; device-only gaps are written down | Assertion output, screenshot baseline, PR notes |
16
+
17
+ L1 can be prechecked in a desktop browser at 390px; that does not cover a device's keyboard, text scaling or safe areas. L2 must be checked where the **panel width differs from the viewport width** (a narrow desktop column, a drawer). L3 pins the first two; it is not a new interaction requirement.
18
+
19
+ ## Where the numbers come from
20
+
21
+ Web, iOS and Android use different units and scopes. Project recommendations do not replace platform or accessibility requirements, and pt, dp and CSS px must not be treated as equivalent.
22
+
23
+ | Requirement | Source | Note |
24
+ | --- | --- | --- |
25
+ | 44×44 CSS px hit region | This project's [design reference](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md) (Web targets ≥44px, 48px for primary actions); [Apple HIG · Buttons](https://developer.apple.com/design/human-interface-guidelines/buttons) (hit region of at least 44×44 pt); [WCAG 2.2 SC 2.5.5](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) (AAA, 44×44 CSS px) | pt, dp and CSS px are three different units; nothing is converted here, only the magnitude is shared |
26
+ | 24×24 CSS px | [WCAG 2.2 SC 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) (AA, with a spacing exception) | A minimum, not a target; this project uses 44 |
27
+ | 48dp | [Android minimum interactive component size](https://developer.android.com/reference/kotlin/androidx/compose/material/minimumInteractiveComponentSize.modifier) | Native controls only; do not convert it into a CSS px value for a Web panel |
28
+ | Input type ≥16px | [Design reference](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md) (the mobile shell already enforces it); iOS zooms the page when a focused form control's font size is below 16px | Do not shrink inputs below that to make a layout fit |
29
+ | `env(safe-area-inset-*)` | [MDN · env()](https://developer.mozilla.org/en-US/docs/Web/CSS/env) | Requires `viewport-fit=cover`; add it only where the content really reaches the edge and the shell has not already handled it |
30
+ | `@container` | [MDN · Container queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries) | Components lay out by container width; all major browsers have supported it since 2023, so keep a fluid fallback for older engines |
31
+ | `overscroll-behavior: contain` | [MDN · overscroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) | Stops scroll chaining: reaching the panel's end must not drag the page behind it |
32
+ | `prefers-reduced-motion` | [MDN · prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) | Drop decorative animation; a loading indicator may stay |
33
+
34
+ ### Two common misuses of 44px
35
+
36
+ - **A hit region is not a visual size.** A round checkbox may stay visually 24px and use an associated label for its full click target; enlarging the whole card is not adaptation.
37
+ - **Do not grow hit areas by covering neighbours.** Expanded regions must not overlap adjacent controls or change wrapping and focus order; use spacing, not overlap.
38
+
39
+ ## A stylesheet to copy
40
+
41
+ Replace `plugin-panel` with your own root class. Enable `data-mobile="1"` only when the measured panel enters its narrow tier. Wide panels are unchanged; a narrow desktop column can also enable it. The switch itself (container query or measured width) is in the [adaptation guide](MOBILE_PLUGIN_ADAPTATION.en.md#adapt-to-available-space).
42
+
43
+ The attribute name is the plugin's own convention — the guide's script example uses `data-narrow`. The name does not matter; what matters is that the whole narrow tier hangs off **one** switch, and that wide panels omit it; the value names a width tier, not a device type.
44
+
45
+ ```css
46
+ /* Type follows the shell setting; without the variable it stays inherited, so the desktop is unchanged. */
47
+ .plugin-panel[data-mobile="1"] { font-size: var(--dsh-content-font-size, inherit); }
48
+
49
+ /* Apply these classes to ordinary actions/text fields, not native switches or ranges. Layout boxes change. */
50
+ .plugin-panel[data-mobile="1"] :is(.action, .icon-button, .primary-action, .text-input, select) { min-width: 44px; min-height: 44px; }
51
+ .plugin-panel[data-mobile="1"] .icon-button { min-width: 44px; }
52
+ .plugin-panel[data-mobile="1"] .primary-action { min-height: 48px; }
53
+
54
+ /* Use <label class="check-label"><input type="checkbox">Name</label>, without covering adjacent controls. */
55
+ .plugin-panel[data-mobile="1"] .check-label { display: inline-flex; align-items: center; gap: 8px; min-width: 44px; min-height: 44px; padding: 10px; box-sizing: border-box; }
56
+ .plugin-panel[data-mobile="1"] .check-label input { width: 24px; height: 24px; margin: 0; }
57
+
58
+ /* Horizontal chip strips scroll sideways; a 1px divider would hang alone in a scrolling row. */
59
+ .plugin-panel[data-mobile="1"] .chips { display: flex; flex-wrap: nowrap; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
60
+ .plugin-panel[data-mobile="1"] .chips::-webkit-scrollbar { display: none; }
61
+ .plugin-panel[data-mobile="1"] .chip { flex: 0 0 auto; min-height: 44px; }
62
+ .plugin-panel[data-mobile="1"] .chips .divider { display: none; }
63
+
64
+ /* Inline text actions collapse to icons; the text stays in the menu or detail view, and the accessible name must stay. */
65
+ .plugin-panel[data-mobile="1"] .action-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
66
+ .plugin-panel[data-mobile="1"] .row-action { min-width: 44px; padding: 0; justify-content: center; }
67
+
68
+ /* Hide only repeated decorative shortcuts, not the sole operating instructions. */
69
+ .plugin-panel[data-mobile="1"] .kbd { display: none; }
70
+
71
+ /* Edge-to-edge areas reserve the safe area; the panel's own scroll container stops chaining. */
72
+ .plugin-panel[data-mobile="1"] .content { padding-bottom: max(28px, calc(14px + env(safe-area-inset-bottom))); }
73
+ .plugin-panel[data-mobile="1"] .scroll-body { overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
74
+
75
+ /* Forms collapse to one column; a multi-line input keeps a usable height. */
76
+ .plugin-panel[data-mobile="1"] .form-row { grid-template-columns: minmax(0, 1fr); }
77
+ .plugin-panel[data-mobile="1"] textarea { min-height: 96px; }
78
+
79
+ /* Collapse decorative/repeated copy only, never essential operating instructions. */
80
+ .plugin-panel[data-mobile="1"] .title { font-size: 1.2em; line-height: 1.4; }
81
+ .plugin-panel[data-mobile="1"] .subtitle { display: none; }
82
+
83
+ @media (prefers-reduced-motion: reduce) {
84
+ .plugin-panel[data-mobile="1"] .chips { scroll-behavior: auto; }
85
+ }
86
+ ```
87
+
88
+ ## How to prove it
89
+
90
+ | Level | Evidence | Note |
91
+ | --- | --- | --- |
92
+ | L1 | A 390×844 render plus hit-region measurements | Measure the layout box, not the CSS declaration; a `min-height` can still be compressed, clipped or covered |
93
+ | L2 | One render in a narrow container and one in a wide container | The narrow container means a ≤600px panel, not a shrunken viewport — that is exactly what the measured width or container query is for |
94
+ | L3 | Structural assertions + a desktop baseline | Assert at least: the narrow marker appears, the 44px rule exists, the safe-area and type rules exist, every narrow rule is scoped to the same switch, and collapsed actions keep an accessible name; compare the desktop baseline in a fixed browser/font/viewport environment and explain expected differences; string-presence assertions do not replace geometry or interaction checks |
95
+
96
+ Static checks establish only the declarations they inspect. Geometry and hit testing need a browser, and the following additionally need device evidence with explicit coverage:
97
+
98
+ - the visible area and focus scrolling after the soft keyboard opens;
99
+ - the real `env(safe-area-inset-*)` values;
100
+ - momentum scrolling and rebound;
101
+ - wrapping and truncation at a larger system font size.
102
+
103
+ ## How this sits with the other documents
104
+
105
+ | Document | Question it answers |
106
+ | --- | --- |
107
+ | [Adaptation guide](MOBILE_PLUGIN_ADAPTATION.en.md) | How: host behavior, example code, item-by-item technique, overlay layering |
108
+ | This document | What counts as done: levels, number provenance, baseline stylesheet, acceptance evidence |
109
+ | [Design reference](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md) | This project's visual and numeric rules (color, type, radius, hit region) |
110
+ | [Contributing guide](https://github.com/saya-ch/dsh-mobile/blob/main/CONTRIBUTING.md) | How to submit, which checks to run, the boundary of compatibility requests |
111
+
112
+ ## Boundaries
113
+
114
+ - It covers plugin-owned Web panels only; background services and plugins that depend on native windows are out of scope.
115
+ - It does not ask for a redesign and does not justify a second component tree per platform; lists and details should stay one implementation.
116
+ - The mobile layer must not inject third-party CSS by class name: a renamed class breaks the rule and the desktop at the same time.
117
+ - Do not use a user agent or `pointer: coarse` alone instead of a measured width; hybrid input (touch laptops, tablets with a mouse) makes both signals wrong.
@@ -0,0 +1,117 @@
1
+ # 插件移动端基线
2
+
3
+ [English](MOBILE_PLUGIN_BASELINE.en.md) · [返回文档索引](README.md) · [适配指南(做法与示例)](MOBILE_PLUGIN_ADAPTATION.md)
4
+
5
+ 这份文档给**插件自有的网页面板(浏览器或 WebView)**定一个可验收的基线:三级要求、每条数字的出处、一份可直接复制的样式,以及怎么证明它成立。它回答「做到什么程度算过」;怎么做见[适配指南](MOBILE_PLUGIN_ADAPTATION.md),本项目自己的视觉与数值口径见[设计参考](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md)。
6
+
7
+ 这是**说明性适配指南**,不改变既有边界:移动层负责连接与页面布局,不重排第三方插件的内部控件;面板在小屏上是否好用由插件作者负责。级别不是发布门槛,用于 issue 勾选、PR 自检,或作者与用户之间的约定。未达标不等于缺陷——按[贡献指南](https://github.com/saya-ch/dsh-mobile/blob/main/CONTRIBUTING.md)的口径,兼容请求不构成对任意第三方组合的承诺。**本基线是本项目的适配建议,不是 DSH 官方标准,也不代表 DSH 官方立场。**
8
+
9
+ ## 三个级别
10
+
11
+ | 级别 | 名称 | 要求(必须 / 应当) | 典型证据 |
12
+ | --- | --- | --- | --- |
13
+ | L1 | 可点 | **必须**:主要 Web 操作命中区至少 44×44 CSS px(主操作有空间时 48px);相邻操作保留间距;输入/可编辑区域继承不小于 16px 的字号;键盘焦点可见;375px 宽下没有页面级横向溢出;不因收窄而隐藏唯一入口 | 375px 与 390px 宽度的截图,附命中区尺寸 |
14
+ | L2 | 顺手 | **应当**:按面板**实测宽度**切档(建议 ≤600px);横向 chip/标签条可横滑;行内文字动作收成图标并保留可访问名称;正文跟随 `--dsh-content-font-size`;贴边区域让出 `env(safe-area-inset-*)`;独立滚动容器设 `overscroll-behavior: contain`;表单在窄档收成单列 | 同一面板在窄容器与宽容器各一张 |
15
+ | L3 | 可回归 | **应当**:静态预览能指定渲染宽度;把 L1/L2 的结构断言接进渲染检查;桌面渲染有基线可对比;真机未验证项写明 | 断言输出、截图基线、PR 说明 |
16
+
17
+ L1 可以先在 390px 桌面浏览器预检,但这不覆盖真机键盘、字体缩放和安全区;L2 必须在**面板宽度不等于视口宽度**的场合验(桌面窄栏、抽屉);L3 是把前两级钉住的手段,不是新的交互要求。
18
+
19
+ ## 数字与出处的对照
20
+
21
+ Web、iOS 和 Android 的单位与适用范围不同。项目建议不代替平台规范或无障碍要求,也不能把 pt、dp 与 CSS px 直接等同。
22
+
23
+ | 要求 | 出处 | 说明 |
24
+ | --- | --- | --- |
25
+ | 44×44 CSS px 命中区 | 本项目[设计参考](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md)(Web 目标 ≥44px,主操作 48px);[Apple HIG · Buttons](https://developer.apple.com/design/human-interface-guidelines/buttons)(命中区至少 44×44 pt);[WCAG 2.2 SC 2.5.5](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html)(AAA,44×44 CSS px) | pt、dp、CSS px 是三种单位,不做换算;这里只是沿用同一个数量级 |
26
+ | 24×24 CSS px | [WCAG 2.2 SC 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)(AA,含间距例外) | 这是最小值,不是目标;本项目取 44 |
27
+ | 48dp | [Android 交互组件最小尺寸](https://developer.android.com/reference/kotlin/androidx/compose/material/minimumInteractiveComponentSize.modifier) | 只适用于原生控件;不要换算成 CSS px 写进 Web 面板 |
28
+ | 输入字号 ≥16px | [设计参考](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md)(移动外壳已强制);iOS 在聚焦字号小于 16px 的表单控件时会自动放大页面 | 插件不要把输入框写成更小的固定字号来「塞进」布局 |
29
+ | `env(safe-area-inset-*)` | [MDN · env()](https://developer.mozilla.org/en-US/docs/Web/CSS/env) | 需要 `viewport-fit=cover`;只在确实贴边、且外壳未处理的区域追加,避免重复留白 |
30
+ | `@container` | [MDN · 容器查询](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries) | 组件按容器宽度排版;主流浏览器自 2023 年起支持,更旧的引擎要保留流式回退 |
31
+ | `overscroll-behavior: contain` | [MDN · overscroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) | 阻止滚动链:面板到底时不要把背后的页面带走 |
32
+ | `prefers-reduced-motion` | [MDN · prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) | 收起装饰性动画;加载指示可以保留 |
33
+
34
+ ### 关于 44px 的两点常见误用
35
+
36
+ - **命中区不是视觉尺寸**。圆形勾选框可以保持 24px 视觉,用关联的标签提供完整点击区域;把整张卡片放大不是适配。
37
+ - **不要把相邻操作「盖」大**。扩大的命中区不应覆盖邻居,也不应改变换行与焦点顺序;用间距而不是重叠来满足 44px。
38
+
39
+ ## 一份可复制的基线样式
40
+
41
+ 把 `plugin-panel` 换成你自己的根类名;`data-mobile="1"` 只在面板实测宽度进入窄档时启用,宽档不命中这些规则,桌面窄栏也可以启用。开关本身(容器查询或实测宽度)见[适配指南](MOBILE_PLUGIN_ADAPTATION.md#按可用空间适配)。
42
+
43
+ 属性名是插件自己的约定,指南的脚本示例用的是 `data-narrow`;叫什么不重要,重要的是**整档样式只挂在同一个开关上**,并且宽档不出现该值;它表示窄档,不是设备类型。
44
+
45
+ ```css
46
+ /* 字号跟随外壳设置;没有该变量时保持继承,不影响桌面。 */
47
+ .plugin-panel[data-mobile="1"] { font-size: var(--dsh-content-font-size, inherit); }
48
+
49
+ /* 给普通动作和文本输入加对应类;不要用于原生开关或范围滑块。这会改变布局尺寸。 */
50
+ .plugin-panel[data-mobile="1"] :is(.action, .icon-button, .primary-action, .text-input, select) { min-width: 44px; min-height: 44px; }
51
+ .plugin-panel[data-mobile="1"] .icon-button { min-width: 44px; }
52
+ .plugin-panel[data-mobile="1"] .primary-action { min-height: 48px; }
53
+
54
+ /* 使用 <label class="check-label"><input type="checkbox">名称</label>,不覆盖邻近控件。 */
55
+ .plugin-panel[data-mobile="1"] .check-label { display: inline-flex; align-items: center; gap: 8px; min-width: 44px; min-height: 44px; padding: 10px; box-sizing: border-box; }
56
+ .plugin-panel[data-mobile="1"] .check-label input { width: 24px; height: 24px; margin: 0; }
57
+
58
+ /* 横向 chip 条改为可横滑;分隔条在横滑行里会挂成孤线。 */
59
+ .plugin-panel[data-mobile="1"] .chips { display: flex; flex-wrap: nowrap; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
60
+ .plugin-panel[data-mobile="1"] .chips::-webkit-scrollbar { display: none; }
61
+ .plugin-panel[data-mobile="1"] .chip { flex: 0 0 auto; min-height: 44px; }
62
+ .plugin-panel[data-mobile="1"] .chips .divider { display: none; }
63
+
64
+ /* 行内文字动作收成图标;文字仍留在菜单/详情里,可访问名称不能丢。 */
65
+ .plugin-panel[data-mobile="1"] .action-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
66
+ .plugin-panel[data-mobile="1"] .row-action { min-width: 44px; padding: 0; justify-content: center; }
67
+
68
+ /* 仅隐藏重复的装饰性快捷键提示,唯一的操作说明应保留。 */
69
+ .plugin-panel[data-mobile="1"] .kbd { display: none; }
70
+
71
+ /* 贴边区域让出安全区;面板自己的滚动容器阻止滚动链。 */
72
+ .plugin-panel[data-mobile="1"] .content { padding-bottom: max(28px, calc(14px + env(safe-area-inset-bottom))); }
73
+ .plugin-panel[data-mobile="1"] .scroll-body { overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
74
+
75
+ /* 表单收成单列,多行输入保留可用高度。 */
76
+ .plugin-panel[data-mobile="1"] .form-row { grid-template-columns: minmax(0, 1fr); }
77
+ .plugin-panel[data-mobile="1"] textarea { min-height: 96px; }
78
+
79
+ /* 仅对装饰性、重复文案使用收起规则,不隐藏必要操作说明。 */
80
+ .plugin-panel[data-mobile="1"] .title { font-size: 1.2em; line-height: 1.4; }
81
+ .plugin-panel[data-mobile="1"] .subtitle { display: none; }
82
+
83
+ @media (prefers-reduced-motion: reduce) {
84
+ .plugin-panel[data-mobile="1"] .chips { scroll-behavior: auto; }
85
+ }
86
+ ```
87
+
88
+ ## 怎么证明
89
+
90
+ | 级别 | 证据 | 说明 |
91
+ | --- | --- | --- |
92
+ | L1 | 390×844 渲染 + 命中区尺寸 | 用实际布局盒量,不要只读 CSS 声明;`min-height` 仍可能被压缩、裁切或遮挡 |
93
+ | L2 | 窄容器与宽容器各一张 | 窄容器指 ≤600px 的面板,不是把视口缩小;这正是实测宽度与容器查询要解决的问题 |
94
+ | L3 | 结构断言 + 桌面基线 | 断言至少覆盖:窄档标记出现、44px 规则存在、安全区与字号变量存在、窄档规则都挂在同一个开关下、被收起的动作仍有可访问名称;在固定浏览器、字体和尺寸下对比桌面基线,说明预期差异;字符串存在断言不代替几何和交互检查 |
95
+
96
+ 静态检查只能证明相应声明存在。几何和命中需要浏览器验证,以下项目还需真机验证并注明覆盖范围:
97
+
98
+ - 软键盘弹出后的可视区与焦点滚动;
99
+ - 真实的 `env(safe-area-inset-*)` 数值;
100
+ - 惯性滚动与回弹手感;
101
+ - 系统字号放大后的换行与截断。
102
+
103
+ ## 与既有文档的分工
104
+
105
+ | 文档 | 回答的问题 |
106
+ | --- | --- |
107
+ | [适配指南](MOBILE_PLUGIN_ADAPTATION.md) | 怎么做:宿主行为、示例代码、逐项做法、浮层层级 |
108
+ | 本文 | 做到什么算过:级别、数字出处、基线样式、验收口径 |
109
+ | [设计参考](https://github.com/saya-ch/dsh-mobile/blob/main/design-system/dsh-mobile/MASTER.md) | 本项目自己的视觉与数值口径(颜色、字号、圆角、命中区) |
110
+ | [贡献指南](https://github.com/saya-ch/dsh-mobile/blob/main/CONTRIBUTING.md) | 怎么提交、跑哪些检查、兼容请求的边界 |
111
+
112
+ ## 边界
113
+
114
+ - 只覆盖插件自有的网页面板(浏览器或 WebView);后台服务型、依赖原生窗口的插件不适用。
115
+ - 不要求重做交互,不要求复制一套移动端组件树;详情与列表仍应只有一份实现。
116
+ - 不在移动层按类名注入第三方 CSS:类名一变就碎,而且会同时影响桌面。
117
+ - 不以 UA 或单纯 `pointer: coarse` 代替实测宽度;混合输入设备(触屏笔记本、带鼠标的平板)会让这两类判断都失真。
package/docs/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  | 你想做什么 | 中文 | English |
8
8
  | --- | --- | --- |
9
9
  | 使用 Android App、了解返回键、权限与升级 | [App 手册](../apps/mobile/README.zh-CN.md) | [Android app](../apps/mobile/README.md) |
10
+ | 用 iPhone/iPad 的 Safari 配对、添加到主屏幕(PWA) | [iPhone/iPad 入门](<../README.md#iphone-与-ipad主屏幕-web-apppwa>) | [iPhone/iPad quick start](<../README.en.md#iphone-and-ipad-home-screen-web-app-pwa>) |
11
+ | 在 iPhone/iPad 主屏幕 App 开启六类网页推送通知(0.8.0 起) | [网页推送](WEB_PUSH.md) | [Web Push](WEB_PUSH.en.md) |
10
12
  | 使用固定 Cloudflare 域名,而非临时随机地址 | [命名隧道](CLOUDFLARE_TUNNEL.md) | [Named tunnel](CLOUDFLARE_TUNNEL.en.md) |
11
13
  | 请求 cpolar 账号已预留的固定子域名(0.6.7 起) | [固定子域名](CPOLAR_SUBDOMAIN.md) | [Reserved subdomain](CPOLAR_SUBDOMAIN.en.md) |
12
14
  | 在已有 VPS 上新部署 FRP 与 HTTPS 入口 | [自建 FRP](SELF_HOSTED_FRP.md) | [Self-hosted FRP](SELF_HOSTED_FRP.en.md) |
@@ -41,6 +43,7 @@
41
43
  ## 开发与安全 / Development and security
42
44
 
43
45
  0.6.3 的 [扩展 Worker 指南](EXTENSION_WORKERS.md)/[Worker guide](EXTENSION_WORKERS.en.md) 介绍按扩展启用、预算与显式恢复;此功能不迁移普通 Cordis 插件,也不是权限沙箱。
46
+ [插件移动端基线](MOBILE_PLUGIN_BASELINE.md)/[Plugin mobile baseline](MOBILE_PLUGIN_BASELINE.en.md) 把「小屏可用、桌面不回退」拆成三级要求与验收证据;做法与示例见[适配指南](MOBILE_PLUGIN_ADAPTATION.md)/[guide](MOBILE_PLUGIN_ADAPTATION.en.md)。
44
47
 
45
48
  - [贡献指南](https://github.com/saya-ch/dsh-mobile/blob/main/CONTRIBUTING.md):本地检查、浏览器与 Android 验证、发布要求。
46
49
  - [安全说明](../SECURITY.md):配对授权、TLS、组件清理与私密漏洞反馈。
@@ -84,9 +84,9 @@ Settings and defaults were checked against DSH 0.2.0-rc.2: [API gateway configur
84
84
 
85
85
  ## Optional WebSocket compression
86
86
 
87
- DSH Mobile 0.5.4 can compress selected live-connection paths. This suits limited bandwidth, metered links, or large Session data, trading extra CPU and memory use for reduced transfer volume. The path list is empty by default, retaining the existing uncompressed relay.
87
+ DSH Mobile has supported compression on selected live paths since 0.5.4; **0.8.0 enables it by default on `/api/remote.mux`**. This trades extra CPU and memory for reduced transfer volume on limited-bandwidth or metered links and large Sessions. Set `paths: []` to return to the uncompressed relay.
88
88
 
89
- Add the following property to the existing `config` of the current `mobile-access` entry. This is a **configuration fragment, not a complete profile patch**. Keep the entry's existing interface, certificate, pairing-state, and management settings; do not replace the whole `config` with the fragment.
89
+ It is enabled by default; to state or adjust it explicitly, add the following property to the existing `config` of the current `mobile-access` entry. This is a **configuration fragment, not a complete profile patch**. Keep the entry's existing interface, certificate, pairing-state, and management settings; do not replace the whole `config` with the fragment.
90
90
 
91
91
  ```yaml
92
92
  websocketCompression:
@@ -114,12 +114,16 @@ Compression is most useful when large, compressible live messages cross a bandwi
114
114
 
115
115
  With the default 1 KiB threshold and no-context-takeover negotiation, the tested 1,023-byte message stayed uncompressed. Incompressible 64 KiB data grew by 22 bytes and added about 1.1 ms to the local round trip. A local bandwidth-shaped test showed lower delivery latency for repetitive messages, but does not predict any particular cellular, relay or LAN result; sustained-load memory and physical-device behavior were not measured.
116
116
 
117
- Compare the same workload with and without compression before drawing a conclusion. HTTP history responses and `/api/remote.mux` frames are separate paths: a faster Session switch after several simultaneous upgrades cannot be attributed to WebSocket compression alone. Compression remains opt-in.
117
+ Compare the same workload with and without compression before drawing a conclusion. HTTP history responses and `/api/remote.mux` frames are separate paths: a faster Session switch after several simultaneous upgrades cannot be attributed to WebSocket compression alone. Since 0.8.0, compression defaults on for the main live path; other paths still need explicit configuration.
118
118
 
119
119
  ## Static-resource caching and repeated downloads
120
120
 
121
121
  Owned static resources (`/mobile-access/compat.js`, `mobile-layout.js` and custom CSS/JS) use quoted content ETags and `Cache-Control: private, no-cache`. A browser may store the body but must revalidate before reuse. The gateway checks device authorization and the current resource before processing conditional GET/HEAD requests: unchanged content receives `304`, while a file edit returns the new body. Actual cache reuse still depends on browser behavior.
122
122
 
123
+ Owned static assets of at least 1 KiB use gzip when the client accepts it, maintain `Vary: Accept-Encoding`, and use separate content ETags for gzip and identity representations. Smaller assets stay uncompressed. A `304` has no body and does not run compression. Savings depend on content and client cache behavior, not a guaranteed cellular speedup.
124
+
125
+ Since 0.8.0, the start document names the compatibility bundle by content hash (`/mobile-access/compat-<sha256>.js`) with `private, max-age=31536000, immutable`. A browser retaining its cache can reuse the same revision; changed bytes get a new URL, and a digest that no longer matches returns `404`. The older `/mobile-access/compat.js` retains conditional revalidation. Requests reaching the server still require authorization; a locally cached script contains no credentials and grants no API access.
126
+
123
127
  The Mobile start HTML includes a page identifier bound to the current authenticated Session and remains `no-store`; it does not reuse another page's or Session's start document. Pairing pages and API responses also remain `no-store`. When the gateway applies streaming gzip to an upstream static resource, a valid upstream ETag can remain usable as a weak validator with `Vary: Accept-Encoding`. Identity lengths and range metadata are not reused as compressed-representation metadata. Failed responses are not cached, and API requests receive no new caching policy.
124
128
 
125
129
  Every compressible text response uses that streaming pipeline: static assets, HTML documents and `/api/` JSON are gzipped when the phone advertises `Accept-Encoding: gzip`, with `Vary` maintained. Static assets under `/plugins/` and `/assets/` **keep their existing decision unchanged**, including paths the upstream already compressed. Outside static assets, `text/event-stream` stays identity so gzip blocks cannot buffer event frames, and a response that declares fewer than 1KB or no length at all stays identity because compressing it costs more than it saves or may be consumed incrementally. Range requests, already encoded responses and 304s keep their previous behavior, and a client that sends no `Accept-Encoding` receives the same bytes as before.
@@ -84,9 +84,9 @@ DSH 0.2.0-rc.2 的 API 网关默认每 **2 秒**发送一次 WebSocket Ping,
84
84
 
85
85
  ## 可选的 WebSocket 压缩
86
86
 
87
- 从 DSH Mobile 0.5.4 起,可以为指定的实时连接路径启用压缩。它适合带宽有限、计量网络或会话数据较大的场景,以额外 CPU 和内存开销换取较少的传输量;默认路径列表为空,仍使用原来的无压缩转发。
87
+ 从 DSH Mobile 0.5.4 起,可以为指定的实时连接路径启用压缩;**0.8.0 起默认压缩 `/api/remote.mux`**。它以额外 CPU 和内存开销换取较少的传输量,适合带宽有限、计量网络或会话数据较大的场景。如需回到不压缩的转发,把 `paths` 设为 `[]`。
88
88
 
89
- 在当前 `mobile-access` 条目的已有 `config` 中加入下面这段属性;这是**配置片段,不是完整 profile patch**。保留该条目已有的网卡、证书、配对状态与管理设置,不要用这个片段替换整个 `config`。
89
+ 默认已经启用;若要显式声明或调整,可在当前 `mobile-access` 条目的已有 `config` 中加入下面这段属性;这是**配置片段,不是完整 profile patch**。保留该条目已有的网卡、证书、配对状态与管理设置,不要用这个片段替换整个 `config`。
90
90
 
91
91
  ```yaml
92
92
  websocketCompression:
@@ -114,12 +114,16 @@ websocketCompression:
114
114
 
115
115
  使用默认 1 KiB 阈值并协商禁用上下文复用时,测试中的 1,023 字节消息没有压缩。不可压缩的 64 KiB 数据反而增加 22 字节,本地往返增加约 1.1 ms。局部限速测试中,重复文本的传递延迟有所下降,但不能据此预测具体蜂窝、转发或局域网体验;本轮未测量持续负载内存和真机行为。
116
116
 
117
- 请对相同工作负载分别开关压缩再比较。HTTP 历史响应与 `/api/remote.mux` 消息是不同路径,不能将同时升级多个组件后的会话切换改善全部归因于 WebSocket 压缩。该功能仍默认关闭、按需开启。
117
+ 请对相同工作负载分别开关压缩再比较。HTTP 历史响应与 `/api/remote.mux` 消息是不同路径,不能将同时升级多个组件后的会话切换改善全部归因于 WebSocket 压缩。0.8.0 起主实时路径默认开启,其他路径仍须明确配置。
118
118
 
119
119
  ## 静态资源缓存与重复下载
120
120
 
121
121
  网关自有静态资源(`/mobile-access/compat.js`、`mobile-layout.js`、自定义 CSS/JS)使用带引号的内容 ETag 和 `Cache-Control: private, no-cache`。浏览器可保存正文,但每次复用前必须重新校验;网关先检查设备授权和当前资源,再处理 GET/HEAD 条件请求,内容未变时返回 `304`,文件改动后返回新正文。实际缓存复用仍取决于浏览器行为。
122
122
 
123
+ 自有静态资源达到 1 KiB 且客户端接受 gzip 时会压缩,维护 `Vary: Accept-Encoding`,并为 gzip 和未压缩表示分别使用内容 ETag。低于阈值的资源保持未压缩。`304` 不携带正文,也不执行压缩;实际节省取决于文件内容和客户端缓存行为,不能据此保证蜂窝网络速度。
124
+
125
+ 0.8.0 起,启动文档按内容哈希引用兼容性脚本(`/mobile-access/compat-<sha256>.js`),该路径使用 `private, max-age=31536000, immutable`。浏览器保留缓存时可直接复用同版本脚本,内容变化则使用新地址;摘要与当前正文不符时返回 `404`。旧的 `/mobile-access/compat.js` 地址继续提供条件校验。服务器收到请求后仍先验证授权;本地已缓存的脚本不含凭据,也不赋予 API 访问权限。
126
+
123
127
  移动启动 HTML 包含绑定当前认证 Session 的页面标识,保持 `no-store`,不复用其他页面或 Session 的启动文档;配对页和 API 响应也保持 `no-store`。网关对上游静态资源做流式 gzip 时,可保留合法上游 ETag 的弱校验形式,并维护 `Vary: Accept-Encoding`;不会把原始内容的长度或范围元信息当作压缩内容的元信息,也不会缓存失败响应或为 API 请求新增缓存。
124
128
 
125
129
  可压缩的文本响应都走这条流式管道:静态资源、HTML 文档和 `/api/` 的 JSON 都会在手机声明 `Accept-Encoding: gzip` 时压缩,并维护 `Vary`。`/plugins/`、`/assets/` 下的静态资源**沿用原有判定,行为不变**(包括上游原本就会压缩的路径)。静态路径之外,`text/event-stream` 保持不压缩,否则逐帧事件会被 gzip 块缓冲;声明长度小于 1KB 或没有声明长度的响应也保持原样,压缩开销大于收益或可能被逐段消费。范围请求、已编码响应和 304 仍按原规则处理,不带 `Accept-Encoding` 的客户端收到的字节与以前一致。
@@ -0,0 +1,118 @@
1
+ # iPhone and iPad Web Push
2
+
3
+ [简体中文](<WEB_PUSH.md>) · [Documentation](<README.md>)
4
+
5
+ > **Plugin 0.8.0 adds iPhone / iPad Web Push.** Pair in Safari, add the page to your Home Screen, then explicitly enable notifications inside DSH. Push targets capable iOS/iPadOS **18.4+ Home Screen web apps**; no native iOS app or notification SaaS account is needed.
6
+
7
+ ## Using DSH Mobile on iPhone and iPad
8
+
9
+ DSH Mobile runs as a plugin on your **computer**. On iPhone/iPad, open its dedicated Mobile Access page in **Safari**, pair with the computer, then optionally add it to the Home Screen as a web app (PWA). No native iOS app download is needed. Start with the [connection guide](<../README.en.md#connection-guide>); you can use DSH sessions and Workspaces without enabling notifications.
10
+
11
+ Home Screen installation does not provide offline DSH: the computer, DSH and its Mobile Gateway must stay running, and the phone must be able to reach the gateway to use it. The **18.4+ target below applies to optional Web Push**, not to ordinary browser access; browser compatibility still depends on the DSH frontend.
12
+
13
+ ## Requirements
14
+
15
+ - A capable iPhone/iPad Home Screen web app, opened in standalone mode, with the page-level `window.pushManager` API. The plugin detects capabilities rather than trusting a browser version string. Ordinary Safari tabs and unsupported browsers do not get a service-worker fallback.
16
+ - A **stable, trusted HTTPS origin** and an existing DSH Mobile browser pairing. HTTP and continuing through a certificate warning are not supported push configurations. The Android app's private-CA pin does not make an iPhone browser trust that CA.
17
+ - Plugin **0.8.0 or later** on the computer, with DSH and the Mobile Gateway for the exact HTTPS origin running. Packed push automation covers DSH **0.2.1-rc.2 / rc.3**; other versions depend on the required public services. Missing services disable push without disabling ordinary Mobile access.
18
+ - Outbound access to Apple's push infrastructure from the host and phone. No Apple developer membership, notification SaaS account or new public inbound port is required. This feature does not create a VPN, change certificates, open a firewall or configure a proxy.
19
+
20
+ A publicly trusted private HTTPS hostname is architecturally eligible. The phone must still reach that origin to install, enable, authenticate and open a conversation. Delivery over a private/Tailscale origin is **unverified**; do not expose a private backend just to try push. Random tunnel hostnames are unsuitable for stable installation consent: an origin change never migrates authorization or subscriptions automatically. For persistent addresses, see [Cloudflare named tunnels](<CLOUDFLARE_TUNNEL.en.md>) or [your own HTTPS reverse proxy](<SELF_HOSTED_ORIGIN.en.md>); configuring an address alone does not prove push delivery.
21
+
22
+ ## Enable explicitly
23
+
24
+ 1. Open and pair the dedicated DSH Mobile page on the intended HTTPS origin. Use Safari's **Share → Add to Home Screen**, then launch that icon rather than an ordinary tab. If a pairing-code form appears, follow the [fresh-code recovery steps](<#if-the-home-screen-app-shows-a-pairing-form>); do not reuse Safari's consumed code or clear existing pairing/site data as a shortcut.
25
+ 2. **Inside the DSH page**, open **Settings → General** and find **Web push notifications**, separate from the Android **Task notifications** action. This is not the iPhone/iPad system Settings app.
26
+ 3. Select **Enable** and respond to the browser's permission prompt. Page load, reconnect and host restart never request permission or subscribe automatically. Enable completes only after the host saves consent.
27
+ 4. Choose the categories you want. **Send test** allows one request per installation per minute, with a 60-second TTL; its success means **accepted by the push provider**, not delivered, displayed, read or answered on the phone.
28
+
29
+ The browser installation ID only distinguishes containers; it is not a credential. Another browser/profile or origin has separate consent. The host derives ownership from the authenticated paired device, not an ID supplied by the page.
30
+
31
+ ### If the Home Screen app shows a pairing form
32
+
33
+ Safari and the installed app may have separate login state. This recovery is for a **Pairing code** form, not the ordinary reconnect page that renews an existing pairing; it is also independent of whether you enable push.
34
+
35
+ 1. On the computer, stay on the **same LAN/Remote connection and HTTPS origin** used for the Home Screen installation. For LAN, select **Generate pairing QR code**, then **Copy pairing link**. For Remote, select **Generate remote pairing QR code**, then **Copy remote pairing link**. Copy reuses the current code; generate a new one before pairing another installation instead of copying an already consumed link again.
36
+ 2. From that fresh link's fragment (after `#`), copy **only the 43-character value of `token=`** into the installed app's **Pairing code** field. Do not paste the whole URL or an Android `dsh1.`/`dsh2.` key. If the link has no `token=` field, check that you selected a browser-compatible HTTPS entry; do not extract a code from an Android key or bypass a certificate warning.
37
+ 3. Complete pairing in the **Home Screen app** before the new code expires; do not consume it in Safari first. Pairing codes work once, so generate a new link after use or expiry. Treat the entire link and code as secrets: do not post them in issues or logs. Keep existing installations, paired-device records and site data.
38
+
39
+ ### Everyday notification expectations
40
+
41
+ Push is best effort, not an alarm service. Host sleep/shutdown, disabled origin, phone connectivity, Focus settings and platform scheduling can delay or prevent delivery.
42
+
43
+ Closing the page does **not** disable saved push consent. Use **Disable** to stop future eligible sends; explicit logout and device revocation have the separate effects listed [below](<#consent-logout-and-revocation>). A notification tap may require login and never answers or approves anything; **Send test** opens the app root, not a conversation.
44
+
45
+ ## What can notify
46
+
47
+ All six categories default on **after** master opt-in. They cover authorized user-facing sessions, including off-screen sessions and ordinary forks; they are not restricted to the conversation currently open on the phone.
48
+
49
+ | Category | Meaning |
50
+ | --- | --- |
51
+ | Questions | A public input request remains unresolved/answerable. A timed question that continues after timeout may still qualify; a queued matching answer suppresses the obsolete alert. |
52
+ | Plans | An actual unresolved plan-review request. |
53
+ | Approvals | A permission request actually dispatched through the public request lifetime, not merely an audit entry. |
54
+ | Responses | An ordinary non-goal response has committed and remains current after coalescing. |
55
+ | Goals | A committed goal completion or genuine blocked outcome, not each continuation round. |
56
+ | Failures | A confirmed unrecovered terminal failure requiring attention, not every error or retry. |
57
+
58
+ Routine streaming/progress, runtime-child completion chatter and user-requested Stop do not notify. A child blocker is observed only when DSH exposes an actual human-directed request through the owning workflow; the plugin never fabricates parent questions or inspects private child state. Quick auto-decisions can settle before the attention delay and produce no alert. A notification says an event occurred, not that someone currently owns a wait.
59
+
60
+ Lock-screen text is generic and localized in English, Simplified Chinese or Italian. It contains no question body, tool arguments, goal objective, conversation excerpt or raw error. Notification tags do not guarantee replacement or withdrawal.
61
+
62
+ ## Consent, logout and revocation
63
+
64
+ | Action/event | Effect |
65
+ | --- | --- |
66
+ | Close the page or let its short login session expire | Saved push consent remains while persistent pairing authorization and the origin remain valid. Opening DSH still requires authentication. |
67
+ | Select Disable | The host durably stops sending first, then the page best-effort unsubscribes only a subscription it owns. A failed browser unsubscribe does not restore host consent. |
68
+ | Explicit Mobile logout | Disable this paired device's push on the current origin before clearing its short session; retain the pairing. A failed disable must not report a completed logout. |
69
+ | Revoke/reset a device or let its persistent credential expire | Purge affected push registrations and queued work, including devices with no open page. |
70
+ | Stop a Gateway/provider or change its origin | Stop that origin's sends. Other origins keep their own consent. Re-enabling on a new origin is explicit. |
71
+ | Restart DSH | Preserve keys, consent and bounded fresh delivery state, then recheck authorization and source eligibility. Do not replay historical conversations. |
72
+
73
+ A send already accepted/in flight at the provider may still arrive after disable or revocation; it cannot reliably be recalled. Re-enabling does not announce old history to a newly enrolled installation. Stale settings must be refreshed before a new user action; old Enable/preferences/Disable requests are never automatically replayed after an authentication or generation failure.
74
+
75
+ ## Tap behavior and privacy
76
+
77
+ A payload opens the same-origin root with an opaque delivery reference. The reference contains no conversation text or authorization token. The Gateway authenticates the paired device and checks scope, device, origin and expiry before resolving it. Expired login uses the existing safe root-return flow; an opaque reference does not bypass login.
78
+
79
+ After the public Workspace and Session lists are ready, Mobile rechecks that the session exists and opens it through DSH's normal UI action. A resolved request can open its session with a neutral notice; a tap never answers, approves or reopens an old prompt. An expired, foreign, missing or deleted target gives a neutral unavailable/session-list fallback. Consuming the reference removes only its query parameter without forcing a reload.
80
+
81
+ The browser chooses warm versus cold navigation; do not assume unsent drafts or in-flight sends survive every notification navigation. A fresh DSH launch may initially show the most recently active conversation; select the intended session manually when needed. The plugin does not register or unregister a service worker or cache authenticated pages/API data.
82
+
83
+ ## Limits and delivery failures
84
+
85
+ - At most **4 registrations per paired device**, **256 registrations and 256 pending deliveries per authorization scope**, and **4 concurrent sends**. Overflow rejects new work rather than deleting another installation or blocking DSH.
86
+ - Attention expires **5 minutes** after the original request; outcomes expire **30 minutes** after their source event. Retries do not renew freshness.
87
+ - At most **3 attempts**, each with an absolute **10-second** budget; retry delays start at **1 second**, then **5 seconds**, with bounded Retry-After only inside the original lifetime.
88
+ - HTTP 404/410 removes a dead registration. HTTP 401/403 suspends delivery for configuration/key repair without clearing pairings. Other permanent errors drop the job; network/429/5xx retries remain bounded.
89
+ - Receipts and tap references are bounded. A crash after provider acceptance but before saving the receipt can cause a duplicate; delivery is not exactly once.
90
+
91
+ The sender accepts only validated Apple HTTPS push endpoints, rejects redirects and unsafe DNS destinations, and encrypts payloads with Web Push/VAPID. Subscription endpoints, keys and private notification state are credentials: do not paste them into issues or logs. A notification storage/network failure must not change a DSH answer, approval or turn result.
92
+
93
+ ## Troubleshooting and deliberate repair
94
+
95
+ | State/problem | Next step |
96
+ | --- | --- |
97
+ | No row / unavailable | Check required page-level capability, trusted HTTPS, authenticated dedicated browser context and DSH public services. There is no worker or old-iOS fallback. |
98
+ | Install guidance | Launch the Home Screen icon rather than an ordinary browser tab. |
99
+ | Permission denied | Change the site's notification permission in system/browser settings, return to the app and make a new explicit Enable action. No prompt is forced. |
100
+ | Registration failed / stale settings | Refresh/reopen to read current state, then explicitly retry. Do not repeatedly resubmit an old request. |
101
+ | Existing subscription uses another application key | Do not automatically unsubscribe it. After a host key reset, retrying Enable alone will not fix this conflict; identify its owner and arrange deliberate browser-side repair. |
102
+ | Suspended / repair-required | Check host key/configuration and private-file access. Do not delete paired-device state or silently replace VAPID keys. |
103
+ | Logout temporarily unavailable | Saved notification consent cannot yet be safely disabled. Keep the page and explicitly retry after the computer's push services recover; closing the page is not disabling notifications. |
104
+ | Test accepted but no alert | Check phone permission/Focus/network, whether the host and gateway are running, and original freshness; provider acceptance is not a delivery receipt. |
105
+
106
+ Lost/corrupt VAPID material with existing registrations fails closed. A host administrator may deliberately reset **notification state only** with `POST /api/mobile-access/push/reset` and JSON `{ "confirm": "reset-web-push" }` through the existing trusted local desktop-admin API. This is a destructive notification reset, not a phone endpoint or an automatic repair button: all browser push consents require explicit re-enrollment afterwards, while pairings remain intact. The reset is **host-registry-wide** — it clears registrations across attached devices and origins, not only the browser that later meets the conflict — so an existing subscription can keep ordinary re-enable failing; no validated destructive repair recipe is promised here. Keep the normal desktop Host/Origin/source protections; never expose this administration path publicly or manually remove paired-device files.
107
+
108
+ ## Independent channels and acceptance
109
+
110
+ Android's native permission/SSE path and APK/signing remain unchanged. The independently maintained **dsh-ping** channel is not modified or automatically disabled; enabling both can produce separate alerts. Web Push adds no cross-channel deduplication or foreground suppression.
111
+
112
+ For everyday setup, send a test notification first, then check the categories you need. Keep the existing Home Screen installation and pairing data; reopen the page after updating the computer plugin to load its new interface. See [contribution checks](<../CONTRIBUTING.md#select-behavior-checks>) for packed runtime validation and maintenance commands.
113
+
114
+ ## Verification status
115
+
116
+ This release accepts the contributor's development testing on **iPhone 17 Pro Max / iOS 27.0.1**, covering six notification categories, background/locked/closed-page delivery, taps, disable/revocation, offline windows and host restarts. The maintainer has not independently repeated Apple-device tests; no corresponding physical iPad or other device/OS record is available.
117
+
118
+ Automation covers settings, authentication, encrypted payloads, tap ownership, restart and cleanup, not actual Apple delivery. The missing-target fixture uses a never-created session; a genuinely persisted-then-deleted session remains untested because the current public DSH API does not expose the needed delete operation. Push is best effort and does not replace manual confirmation of important tasks.