feat(settings): let a settled value stay a row until you ask to edit it - #2139

Merged
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9
Aug 4, 2026
Merged

feat(settings): let a settled value stay a row until you ask to edit it#2139
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #141 —【Astryx 落地 ⑤】SettingsExpandableRow。预览已过设计审 + owner 授权设计侧拍板通过。

第一性原理的账(审美专家的裁定原文)

「显示名称」这个字段:读当前状态是高频(每次进设置页都看见),编辑是极低频(多数用户设一次或永不设)。

原来的常开输入框把界面成本花在低频动作上,还用 blur 自动保存剥夺了反悔路径

收起态一行让高频的「读」更好 ——「未设置,Maka 会称呼你"你"」比一个空输入框信息量高(空输入框说不出"空着会怎样");低频的「编辑」多付一次点击,换来显式保存 + 真正的取消。频率匹配 + 反悔路径,两头都是赚的。

只学形状,模板的三处硬伤都没抄

形状取自 Astryx settings-sidebar 模板的 ExpandableRow,实现没抄:

模板的问题我们的做法
trigger 是 <a href="#"> + preventDefault —— 自称链接、不响应 Space、指向 #真 Buttonvariant="ghost")。打开表单不是导航
完全没有焦点管理展开时焦点进编辑器,保存/取消收起时焦点回 trigger
Cancel 只是关闭(它的字段是实时生效的,没东西可丢弃)Cancel 真的丢弃草稿 —— 这才让"取消"有意义

焦点是实测的,不是声称的

预览阶段跑真实 app 探针:

FOCUS_AFTER_EXPAND INPUT ← 焦点确实进了输入框
FOCUS_AFTER_CANCEL 设置 ← 取消后确实回到 trigger

另加 wasEditingRef 守卫:只在用户驱动的状态切换时抢焦点。否则调用方若以展开态挂载,首帧就会抢走焦点。

按规格搭在 SettingsRow 上

SettingsRow(Item)而非裸 HStack —— settingsRowEnd 宽度上限和 rows.css 的容器查询照常生效;展开态用 SettingsField,和常开输入框走同一个全宽块。

标签在切换后保留:收起时行标签命名这个值,展开时若不保留就是个无标签输入框(用户点了「设置」之后不知道在设什么)。控件自身 label 设 isLabelHidden,名字只出现一次。

这个问题是我自己看预览截图时发现的 —— 第一版展开态确实把标签弄丢了。

范围:仅通用页

规格原本还点名了数据页的工作区路径,设计侧 review 时撤销:那一行是只读值 + 动作,没有"编辑"语义,ExpandableRow 不适配。

验证

build ✅ / typecheck 0 error ✅ / lint ✅ / format:check ✅ / check-dead-css ✅ / test:checks ✅。收起/展开两态截图在任务线程 #my-ai:46078eb1。

合入后按已批准的计划,单开任务把 provider-connection-detail.tsx 里的本地 ExpandableSettingRow(2 处在用)迁到这个 kit 组件并删除副本。

@maka-审美专家 请 review。

The display name is read every time Settings opens and changed once, if
ever. It was a permanently open text input — the interface spending its
space on the rare act and asking the user to fill in something already
filled in — and it saved on blur, so there was no way to back out of a
change once made.
It is a row now: the current value with one affordance to change it, which
swaps in place for the editor plus Save / Cancel. The collapsed line carries
more than the old input did — 「未设置,Maka 会称呼你"你"」 says what happens
when it is empty, which a blank field cannot. Editing costs one click and
buys an explicit save and a real Cancel.
The shape is Astryx's own settings-sidebar ExpandableRow. The template's
implementation is not, because it has three problems the new
SettingsExpandableRow exists not to have:
- its trigger is `<a href="#">` with preventDefault, which announces itself
as a link, ignores Space, and points nowhere. Opening a form is a button.
- it has no focus management at all. Expanding moves focus into the editor
and Save / Cancel return it to the trigger, verified against the running
app rather than asserted: FOCUS_AFTER_EXPAND reports INPUT and
FOCUS_AFTER_CANCEL reports 设置. A `wasEditingRef` guard keeps that to
user-driven transitions, so a caller mounting already-expanded does not
steal focus on first paint.
- its Cancel only closes, because its fields edit live. Ours reverts the
draft, which is what makes Cancel mean anything.
Built on SettingsRow rather than a bare HStack so the collapsed row keeps
the Item vocabulary — the settingsRowEnd width ceiling and rows.css
container queries apply here too — and the editor sits in a SettingsField,
the same full-width block a permanently open input would use. The label
survives the swap: collapsed it names the value, expanded it would otherwise
be an unlabelled box, so the control's own label is hidden and the name is
stated once.
Scope is the identity row only. The spec also named the data page's
workspace path; design withdrew it on review — that row is a read-only value
with actions, not an editable field, so the pattern does not fit.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks.
Two review findings on SettingsExpandableRow.
A failed save collapsed the row anyway. `persistPersonalization` catches its
error to raise a toast and does not rethrow, so `await` always resolved and
`setExpandedRow(null)` always ran: the row shut, showed the old value, and
the draft was gone with only a toast to say why. That is exactly the promise
this refactor makes — an explicit save means the change either lands or is
still sitting in the editor for you to retry — so it is the one failure it
could not afford.
It returns whether the write landed now, and the row closes only on true.
The autosaving fields keep calling it with `void`; they have nowhere to put
the answer and are unaffected. An unmounted page after a successful write
still reports success, because the write did land — there is just no longer
anywhere to reflect it.
The trigger also dropped `aria-expanded={false}` and `aria-controls`. Those
describe a disclosure, where a trigger stays put while a region opens beside
it. This is a mode swap: the trigger unmounts when the editor replaces it, so
aria-expanded could never reach true and aria-controls named a node that does
not exist while collapsed. Declaring a contract the DOM never honours is
worse than declaring none, and the focus move already carries the state
change — the part that was measured rather than assumed.
Verified: typecheck, lint, format:check, check-dead-css, test:checks, and
settings.spec e2e 5/5.
The failure path was checked by reading it end to end rather than by test:
updateSettings rethrows, the catch returns false, onSave gates on it.
An e2e attempt to force the failure could not work — contextBridge freezes
window.maka, so patching settings.update from the renderer silently no-ops
and the save simply succeeded. Proving this one needs main-process fault
injection.
@jackwener

Copy link
Copy Markdown
MemberAuthor

Review by maka-审美专家 — 通过(两轮收敛):ExpandableRow kit 组件落地(模板抄形状不抄实现,三处硬伤修复 + wasEditingRef 首帧守卫 + 焦点实测证据)。第二轮两处修复验证:①保存失败不再收起——persistPersonalization 返回落地布尔、onSave 条件收起,「要么落地要么还在编辑区」的显式保存承诺补完整;②模式互换下不兑现的 aria-expanded/aria-controls 删除并留「故意不加」注释防好心回补。「读通链路 vs 测过」的诚实区分(contextBridge 冻结导致 renderer 侧 monkey-patch 静默失效的发现)值得记录。频率匹配论证(读高频/编辑低频)成立,owner 已授权拍板。CI 全绿。合入。

@jackwener
jackwener merged commit e38caf3 into mainAug 4, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(settings): let a settled value stay a row until you ask to edit it - #2139

Merged
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9
Aug 4, 2026
Merged

feat(settings): let a settled value stay a row until you ask to edit it#2139
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #141 —【Astryx 落地 ⑤】SettingsExpandableRow。预览已过设计审 + owner 授权设计侧拍板通过。

第一性原理的账(审美专家的裁定原文)

「显示名称」这个字段:读当前状态是高频(每次进设置页都看见),编辑是极低频(多数用户设一次或永不设)。

原来的常开输入框把界面成本花在低频动作上,还用 blur 自动保存剥夺了反悔路径

收起态一行让高频的「读」更好 ——「未设置,Maka 会称呼你"你"」比一个空输入框信息量高(空输入框说不出"空着会怎样");低频的「编辑」多付一次点击,换来显式保存 + 真正的取消。频率匹配 + 反悔路径,两头都是赚的。

只学形状,模板的三处硬伤都没抄

形状取自 Astryx settings-sidebar 模板的 ExpandableRow,实现没抄:

模板的问题我们的做法
trigger 是 <a href="#"> + preventDefault —— 自称链接、不响应 Space、指向 #真 Buttonvariant="ghost")。打开表单不是导航
完全没有焦点管理展开时焦点进编辑器,保存/取消收起时焦点回 trigger
Cancel 只是关闭(它的字段是实时生效的,没东西可丢弃)Cancel 真的丢弃草稿 —— 这才让"取消"有意义

焦点是实测的,不是声称的

预览阶段跑真实 app 探针:

FOCUS_AFTER_EXPAND INPUT ← 焦点确实进了输入框
FOCUS_AFTER_CANCEL 设置 ← 取消后确实回到 trigger

另加 wasEditingRef 守卫:只在用户驱动的状态切换时抢焦点。否则调用方若以展开态挂载,首帧就会抢走焦点。

按规格搭在 SettingsRow 上

SettingsRow(Item)而非裸 HStack —— settingsRowEnd 宽度上限和 rows.css 的容器查询照常生效;展开态用 SettingsField,和常开输入框走同一个全宽块。

标签在切换后保留:收起时行标签命名这个值,展开时若不保留就是个无标签输入框(用户点了「设置」之后不知道在设什么)。控件自身 label 设 isLabelHidden,名字只出现一次。

这个问题是我自己看预览截图时发现的 —— 第一版展开态确实把标签弄丢了。

范围:仅通用页

规格原本还点名了数据页的工作区路径,设计侧 review 时撤销:那一行是只读值 + 动作,没有"编辑"语义,ExpandableRow 不适配。

验证

build ✅ / typecheck 0 error ✅ / lint ✅ / format:check ✅ / check-dead-css ✅ / test:checks ✅。收起/展开两态截图在任务线程 #my-ai:46078eb1。

合入后按已批准的计划,单开任务把 provider-connection-detail.tsx 里的本地 ExpandableSettingRow(2 处在用)迁到这个 kit 组件并删除副本。

@maka-审美专家 请 review。

The display name is read every time Settings opens and changed once, if
ever. It was a permanently open text input — the interface spending its
space on the rare act and asking the user to fill in something already
filled in — and it saved on blur, so there was no way to back out of a
change once made.
It is a row now: the current value with one affordance to change it, which
swaps in place for the editor plus Save / Cancel. The collapsed line carries
more than the old input did — 「未设置,Maka 会称呼你"你"」 says what happens
when it is empty, which a blank field cannot. Editing costs one click and
buys an explicit save and a real Cancel.
The shape is Astryx's own settings-sidebar ExpandableRow. The template's
implementation is not, because it has three problems the new
SettingsExpandableRow exists not to have:
- its trigger is `<a href="#">` with preventDefault, which announces itself
as a link, ignores Space, and points nowhere. Opening a form is a button.
- it has no focus management at all. Expanding moves focus into the editor
and Save / Cancel return it to the trigger, verified against the running
app rather than asserted: FOCUS_AFTER_EXPAND reports INPUT and
FOCUS_AFTER_CANCEL reports 设置. A `wasEditingRef` guard keeps that to
user-driven transitions, so a caller mounting already-expanded does not
steal focus on first paint.
- its Cancel only closes, because its fields edit live. Ours reverts the
draft, which is what makes Cancel mean anything.
Built on SettingsRow rather than a bare HStack so the collapsed row keeps
the Item vocabulary — the settingsRowEnd width ceiling and rows.css
container queries apply here too — and the editor sits in a SettingsField,
the same full-width block a permanently open input would use. The label
survives the swap: collapsed it names the value, expanded it would otherwise
be an unlabelled box, so the control's own label is hidden and the name is
stated once.
Scope is the identity row only. The spec also named the data page's
workspace path; design withdrew it on review — that row is a read-only value
with actions, not an editable field, so the pattern does not fit.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks.
Two review findings on SettingsExpandableRow.
A failed save collapsed the row anyway. `persistPersonalization` catches its
error to raise a toast and does not rethrow, so `await` always resolved and
`setExpandedRow(null)` always ran: the row shut, showed the old value, and
the draft was gone with only a toast to say why. That is exactly the promise
this refactor makes — an explicit save means the change either lands or is
still sitting in the editor for you to retry — so it is the one failure it
could not afford.
It returns whether the write landed now, and the row closes only on true.
The autosaving fields keep calling it with `void`; they have nowhere to put
the answer and are unaffected. An unmounted page after a successful write
still reports success, because the write did land — there is just no longer
anywhere to reflect it.
The trigger also dropped `aria-expanded={false}` and `aria-controls`. Those
describe a disclosure, where a trigger stays put while a region opens beside
it. This is a mode swap: the trigger unmounts when the editor replaces it, so
aria-expanded could never reach true and aria-controls named a node that does
not exist while collapsed. Declaring a contract the DOM never honours is
worse than declaring none, and the focus move already carries the state
change — the part that was measured rather than assumed.
Verified: typecheck, lint, format:check, check-dead-css, test:checks, and
settings.spec e2e 5/5.
The failure path was checked by reading it end to end rather than by test:
updateSettings rethrows, the catch returns false, onSave gates on it.
An e2e attempt to force the failure could not work — contextBridge freezes
window.maka, so patching settings.update from the renderer silently no-ops
and the save simply succeeded. Proving this one needs main-process fault
injection.
@jackwener

Copy link
Copy Markdown
MemberAuthor

Review by maka-审美专家 — 通过(两轮收敛):ExpandableRow kit 组件落地(模板抄形状不抄实现,三处硬伤修复 + wasEditingRef 首帧守卫 + 焦点实测证据)。第二轮两处修复验证:①保存失败不再收起——persistPersonalization 返回落地布尔、onSave 条件收起,「要么落地要么还在编辑区」的显式保存承诺补完整;②模式互换下不兑现的 aria-expanded/aria-controls 删除并留「故意不加」注释防好心回补。「读通链路 vs 测过」的诚实区分(contextBridge 冻结导致 renderer 侧 monkey-patch 静默失效的发现)值得记录。频率匹配论证(读高频/编辑低频)成立,owner 已授权拍板。CI 全绿。合入。

@jackwener
jackwener merged commit e38caf3 into mainAug 4, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(settings): let a settled value stay a row until you ask to edit it - #2139

Merged
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9
Aug 4, 2026
Merged

feat(settings): let a settled value stay a row until you ask to edit it#2139
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #141 —【Astryx 落地 ⑤】SettingsExpandableRow。预览已过设计审 + owner 授权设计侧拍板通过。

第一性原理的账(审美专家的裁定原文)

「显示名称」这个字段:读当前状态是高频(每次进设置页都看见),编辑是极低频(多数用户设一次或永不设)。

原来的常开输入框把界面成本花在低频动作上,还用 blur 自动保存剥夺了反悔路径

收起态一行让高频的「读」更好 ——「未设置,Maka 会称呼你"你"」比一个空输入框信息量高(空输入框说不出"空着会怎样");低频的「编辑」多付一次点击,换来显式保存 + 真正的取消。频率匹配 + 反悔路径,两头都是赚的。

只学形状,模板的三处硬伤都没抄

形状取自 Astryx settings-sidebar 模板的 ExpandableRow,实现没抄:

模板的问题我们的做法
trigger 是 <a href="#"> + preventDefault —— 自称链接、不响应 Space、指向 #真 Buttonvariant="ghost")。打开表单不是导航
完全没有焦点管理展开时焦点进编辑器,保存/取消收起时焦点回 trigger
Cancel 只是关闭(它的字段是实时生效的,没东西可丢弃)Cancel 真的丢弃草稿 —— 这才让"取消"有意义

焦点是实测的,不是声称的

预览阶段跑真实 app 探针:

FOCUS_AFTER_EXPAND INPUT ← 焦点确实进了输入框
FOCUS_AFTER_CANCEL 设置 ← 取消后确实回到 trigger

另加 wasEditingRef 守卫:只在用户驱动的状态切换时抢焦点。否则调用方若以展开态挂载,首帧就会抢走焦点。

按规格搭在 SettingsRow 上

SettingsRow(Item)而非裸 HStack —— settingsRowEnd 宽度上限和 rows.css 的容器查询照常生效;展开态用 SettingsField,和常开输入框走同一个全宽块。

标签在切换后保留:收起时行标签命名这个值,展开时若不保留就是个无标签输入框(用户点了「设置」之后不知道在设什么)。控件自身 label 设 isLabelHidden,名字只出现一次。

这个问题是我自己看预览截图时发现的 —— 第一版展开态确实把标签弄丢了。

范围:仅通用页

规格原本还点名了数据页的工作区路径,设计侧 review 时撤销:那一行是只读值 + 动作,没有"编辑"语义,ExpandableRow 不适配。

验证

build ✅ / typecheck 0 error ✅ / lint ✅ / format:check ✅ / check-dead-css ✅ / test:checks ✅。收起/展开两态截图在任务线程 #my-ai:46078eb1。

合入后按已批准的计划,单开任务把 provider-connection-detail.tsx 里的本地 ExpandableSettingRow(2 处在用)迁到这个 kit 组件并删除副本。

@maka-审美专家 请 review。

The display name is read every time Settings opens and changed once, if
ever. It was a permanently open text input — the interface spending its
space on the rare act and asking the user to fill in something already
filled in — and it saved on blur, so there was no way to back out of a
change once made.
It is a row now: the current value with one affordance to change it, which
swaps in place for the editor plus Save / Cancel. The collapsed line carries
more than the old input did — 「未设置,Maka 会称呼你"你"」 says what happens
when it is empty, which a blank field cannot. Editing costs one click and
buys an explicit save and a real Cancel.
The shape is Astryx's own settings-sidebar ExpandableRow. The template's
implementation is not, because it has three problems the new
SettingsExpandableRow exists not to have:
- its trigger is `<a href="#">` with preventDefault, which announces itself
as a link, ignores Space, and points nowhere. Opening a form is a button.
- it has no focus management at all. Expanding moves focus into the editor
and Save / Cancel return it to the trigger, verified against the running
app rather than asserted: FOCUS_AFTER_EXPAND reports INPUT and
FOCUS_AFTER_CANCEL reports 设置. A `wasEditingRef` guard keeps that to
user-driven transitions, so a caller mounting already-expanded does not
steal focus on first paint.
- its Cancel only closes, because its fields edit live. Ours reverts the
draft, which is what makes Cancel mean anything.
Built on SettingsRow rather than a bare HStack so the collapsed row keeps
the Item vocabulary — the settingsRowEnd width ceiling and rows.css
container queries apply here too — and the editor sits in a SettingsField,
the same full-width block a permanently open input would use. The label
survives the swap: collapsed it names the value, expanded it would otherwise
be an unlabelled box, so the control's own label is hidden and the name is
stated once.
Scope is the identity row only. The spec also named the data page's
workspace path; design withdrew it on review — that row is a read-only value
with actions, not an editable field, so the pattern does not fit.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks.
Two review findings on SettingsExpandableRow.
A failed save collapsed the row anyway. `persistPersonalization` catches its
error to raise a toast and does not rethrow, so `await` always resolved and
`setExpandedRow(null)` always ran: the row shut, showed the old value, and
the draft was gone with only a toast to say why. That is exactly the promise
this refactor makes — an explicit save means the change either lands or is
still sitting in the editor for you to retry — so it is the one failure it
could not afford.
It returns whether the write landed now, and the row closes only on true.
The autosaving fields keep calling it with `void`; they have nowhere to put
the answer and are unaffected. An unmounted page after a successful write
still reports success, because the write did land — there is just no longer
anywhere to reflect it.
The trigger also dropped `aria-expanded={false}` and `aria-controls`. Those
describe a disclosure, where a trigger stays put while a region opens beside
it. This is a mode swap: the trigger unmounts when the editor replaces it, so
aria-expanded could never reach true and aria-controls named a node that does
not exist while collapsed. Declaring a contract the DOM never honours is
worse than declaring none, and the focus move already carries the state
change — the part that was measured rather than assumed.
Verified: typecheck, lint, format:check, check-dead-css, test:checks, and
settings.spec e2e 5/5.
The failure path was checked by reading it end to end rather than by test:
updateSettings rethrows, the catch returns false, onSave gates on it.
An e2e attempt to force the failure could not work — contextBridge freezes
window.maka, so patching settings.update from the renderer silently no-ops
and the save simply succeeded. Proving this one needs main-process fault
injection.
@jackwener

Copy link
Copy Markdown
MemberAuthor

Review by maka-审美专家 — 通过(两轮收敛):ExpandableRow kit 组件落地(模板抄形状不抄实现,三处硬伤修复 + wasEditingRef 首帧守卫 + 焦点实测证据)。第二轮两处修复验证:①保存失败不再收起——persistPersonalization 返回落地布尔、onSave 条件收起,「要么落地要么还在编辑区」的显式保存承诺补完整;②模式互换下不兑现的 aria-expanded/aria-controls 删除并留「故意不加」注释防好心回补。「读通链路 vs 测过」的诚实区分(contextBridge 冻结导致 renderer 侧 monkey-patch 静默失效的发现)值得记录。频率匹配论证(读高频/编辑低频)成立,owner 已授权拍板。CI 全绿。合入。

@jackwener
jackwener merged commit e38caf3 into mainAug 4, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(settings): let a settled value stay a row until you ask to edit it - #2139

Merged
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9
Aug 4, 2026
Merged

feat(settings): let a settled value stay a row until you ask to edit it#2139
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #141 —【Astryx 落地 ⑤】SettingsExpandableRow。预览已过设计审 + owner 授权设计侧拍板通过。

第一性原理的账(审美专家的裁定原文)

「显示名称」这个字段:读当前状态是高频(每次进设置页都看见),编辑是极低频(多数用户设一次或永不设)。

原来的常开输入框把界面成本花在低频动作上,还用 blur 自动保存剥夺了反悔路径

收起态一行让高频的「读」更好 ——「未设置,Maka 会称呼你"你"」比一个空输入框信息量高(空输入框说不出"空着会怎样");低频的「编辑」多付一次点击,换来显式保存 + 真正的取消。频率匹配 + 反悔路径,两头都是赚的。

只学形状,模板的三处硬伤都没抄

形状取自 Astryx settings-sidebar 模板的 ExpandableRow,实现没抄:

模板的问题我们的做法
trigger 是 <a href="#"> + preventDefault —— 自称链接、不响应 Space、指向 #真 Buttonvariant="ghost")。打开表单不是导航
完全没有焦点管理展开时焦点进编辑器,保存/取消收起时焦点回 trigger
Cancel 只是关闭(它的字段是实时生效的,没东西可丢弃)Cancel 真的丢弃草稿 —— 这才让"取消"有意义

焦点是实测的,不是声称的

预览阶段跑真实 app 探针:

FOCUS_AFTER_EXPAND INPUT ← 焦点确实进了输入框
FOCUS_AFTER_CANCEL 设置 ← 取消后确实回到 trigger

另加 wasEditingRef 守卫:只在用户驱动的状态切换时抢焦点。否则调用方若以展开态挂载,首帧就会抢走焦点。

按规格搭在 SettingsRow 上

SettingsRow(Item)而非裸 HStack —— settingsRowEnd 宽度上限和 rows.css 的容器查询照常生效;展开态用 SettingsField,和常开输入框走同一个全宽块。

标签在切换后保留:收起时行标签命名这个值,展开时若不保留就是个无标签输入框(用户点了「设置」之后不知道在设什么)。控件自身 label 设 isLabelHidden,名字只出现一次。

这个问题是我自己看预览截图时发现的 —— 第一版展开态确实把标签弄丢了。

范围:仅通用页

规格原本还点名了数据页的工作区路径,设计侧 review 时撤销:那一行是只读值 + 动作,没有"编辑"语义,ExpandableRow 不适配。

验证

build ✅ / typecheck 0 error ✅ / lint ✅ / format:check ✅ / check-dead-css ✅ / test:checks ✅。收起/展开两态截图在任务线程 #my-ai:46078eb1。

合入后按已批准的计划,单开任务把 provider-connection-detail.tsx 里的本地 ExpandableSettingRow(2 处在用)迁到这个 kit 组件并删除副本。

@maka-审美专家 请 review。

The display name is read every time Settings opens and changed once, if
ever. It was a permanently open text input — the interface spending its
space on the rare act and asking the user to fill in something already
filled in — and it saved on blur, so there was no way to back out of a
change once made.
It is a row now: the current value with one affordance to change it, which
swaps in place for the editor plus Save / Cancel. The collapsed line carries
more than the old input did — 「未设置,Maka 会称呼你"你"」 says what happens
when it is empty, which a blank field cannot. Editing costs one click and
buys an explicit save and a real Cancel.
The shape is Astryx's own settings-sidebar ExpandableRow. The template's
implementation is not, because it has three problems the new
SettingsExpandableRow exists not to have:
- its trigger is `<a href="#">` with preventDefault, which announces itself
as a link, ignores Space, and points nowhere. Opening a form is a button.
- it has no focus management at all. Expanding moves focus into the editor
and Save / Cancel return it to the trigger, verified against the running
app rather than asserted: FOCUS_AFTER_EXPAND reports INPUT and
FOCUS_AFTER_CANCEL reports 设置. A `wasEditingRef` guard keeps that to
user-driven transitions, so a caller mounting already-expanded does not
steal focus on first paint.
- its Cancel only closes, because its fields edit live. Ours reverts the
draft, which is what makes Cancel mean anything.
Built on SettingsRow rather than a bare HStack so the collapsed row keeps
the Item vocabulary — the settingsRowEnd width ceiling and rows.css
container queries apply here too — and the editor sits in a SettingsField,
the same full-width block a permanently open input would use. The label
survives the swap: collapsed it names the value, expanded it would otherwise
be an unlabelled box, so the control's own label is hidden and the name is
stated once.
Scope is the identity row only. The spec also named the data page's
workspace path; design withdrew it on review — that row is a read-only value
with actions, not an editable field, so the pattern does not fit.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks.
Two review findings on SettingsExpandableRow.
A failed save collapsed the row anyway. `persistPersonalization` catches its
error to raise a toast and does not rethrow, so `await` always resolved and
`setExpandedRow(null)` always ran: the row shut, showed the old value, and
the draft was gone with only a toast to say why. That is exactly the promise
this refactor makes — an explicit save means the change either lands or is
still sitting in the editor for you to retry — so it is the one failure it
could not afford.
It returns whether the write landed now, and the row closes only on true.
The autosaving fields keep calling it with `void`; they have nowhere to put
the answer and are unaffected. An unmounted page after a successful write
still reports success, because the write did land — there is just no longer
anywhere to reflect it.
The trigger also dropped `aria-expanded={false}` and `aria-controls`. Those
describe a disclosure, where a trigger stays put while a region opens beside
it. This is a mode swap: the trigger unmounts when the editor replaces it, so
aria-expanded could never reach true and aria-controls named a node that does
not exist while collapsed. Declaring a contract the DOM never honours is
worse than declaring none, and the focus move already carries the state
change — the part that was measured rather than assumed.
Verified: typecheck, lint, format:check, check-dead-css, test:checks, and
settings.spec e2e 5/5.
The failure path was checked by reading it end to end rather than by test:
updateSettings rethrows, the catch returns false, onSave gates on it.
An e2e attempt to force the failure could not work — contextBridge freezes
window.maka, so patching settings.update from the renderer silently no-ops
and the save simply succeeded. Proving this one needs main-process fault
injection.
@jackwener

Copy link
Copy Markdown
MemberAuthor

Review by maka-审美专家 — 通过(两轮收敛):ExpandableRow kit 组件落地(模板抄形状不抄实现,三处硬伤修复 + wasEditingRef 首帧守卫 + 焦点实测证据)。第二轮两处修复验证:①保存失败不再收起——persistPersonalization 返回落地布尔、onSave 条件收起,「要么落地要么还在编辑区」的显式保存承诺补完整;②模式互换下不兑现的 aria-expanded/aria-controls 删除并留「故意不加」注释防好心回补。「读通链路 vs 测过」的诚实区分(contextBridge 冻结导致 renderer 侧 monkey-patch 静默失效的发现)值得记录。频率匹配论证(读高频/编辑低频)成立,owner 已授权拍板。CI 全绿。合入。

@jackwener
jackwener merged commit e38caf3 into mainAug 4, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(settings): let a settled value stay a row until you ask to edit it - #2139

Merged
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9
Aug 4, 2026
Merged

feat(settings): let a settled value stay a row until you ask to edit it#2139
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #141 —【Astryx 落地 ⑤】SettingsExpandableRow。预览已过设计审 + owner 授权设计侧拍板通过。

第一性原理的账(审美专家的裁定原文)

「显示名称」这个字段:读当前状态是高频(每次进设置页都看见),编辑是极低频(多数用户设一次或永不设)。

原来的常开输入框把界面成本花在低频动作上,还用 blur 自动保存剥夺了反悔路径

收起态一行让高频的「读」更好 ——「未设置,Maka 会称呼你"你"」比一个空输入框信息量高(空输入框说不出"空着会怎样");低频的「编辑」多付一次点击,换来显式保存 + 真正的取消。频率匹配 + 反悔路径,两头都是赚的。

只学形状,模板的三处硬伤都没抄

形状取自 Astryx settings-sidebar 模板的 ExpandableRow,实现没抄:

模板的问题我们的做法
trigger 是 <a href="#"> + preventDefault —— 自称链接、不响应 Space、指向 #真 Buttonvariant="ghost")。打开表单不是导航
完全没有焦点管理展开时焦点进编辑器,保存/取消收起时焦点回 trigger
Cancel 只是关闭(它的字段是实时生效的,没东西可丢弃)Cancel 真的丢弃草稿 —— 这才让"取消"有意义

焦点是实测的,不是声称的

预览阶段跑真实 app 探针:

FOCUS_AFTER_EXPAND INPUT ← 焦点确实进了输入框
FOCUS_AFTER_CANCEL 设置 ← 取消后确实回到 trigger

另加 wasEditingRef 守卫:只在用户驱动的状态切换时抢焦点。否则调用方若以展开态挂载,首帧就会抢走焦点。

按规格搭在 SettingsRow 上

SettingsRow(Item)而非裸 HStack —— settingsRowEnd 宽度上限和 rows.css 的容器查询照常生效;展开态用 SettingsField,和常开输入框走同一个全宽块。

标签在切换后保留:收起时行标签命名这个值,展开时若不保留就是个无标签输入框(用户点了「设置」之后不知道在设什么)。控件自身 label 设 isLabelHidden,名字只出现一次。

这个问题是我自己看预览截图时发现的 —— 第一版展开态确实把标签弄丢了。

范围:仅通用页

规格原本还点名了数据页的工作区路径,设计侧 review 时撤销:那一行是只读值 + 动作,没有"编辑"语义,ExpandableRow 不适配。

验证

build ✅ / typecheck 0 error ✅ / lint ✅ / format:check ✅ / check-dead-css ✅ / test:checks ✅。收起/展开两态截图在任务线程 #my-ai:46078eb1。

合入后按已批准的计划,单开任务把 provider-connection-detail.tsx 里的本地 ExpandableSettingRow(2 处在用)迁到这个 kit 组件并删除副本。

@maka-审美专家 请 review。

The display name is read every time Settings opens and changed once, if
ever. It was a permanently open text input — the interface spending its
space on the rare act and asking the user to fill in something already
filled in — and it saved on blur, so there was no way to back out of a
change once made.
It is a row now: the current value with one affordance to change it, which
swaps in place for the editor plus Save / Cancel. The collapsed line carries
more than the old input did — 「未设置,Maka 会称呼你"你"」 says what happens
when it is empty, which a blank field cannot. Editing costs one click and
buys an explicit save and a real Cancel.
The shape is Astryx's own settings-sidebar ExpandableRow. The template's
implementation is not, because it has three problems the new
SettingsExpandableRow exists not to have:
- its trigger is `<a href="#">` with preventDefault, which announces itself
as a link, ignores Space, and points nowhere. Opening a form is a button.
- it has no focus management at all. Expanding moves focus into the editor
and Save / Cancel return it to the trigger, verified against the running
app rather than asserted: FOCUS_AFTER_EXPAND reports INPUT and
FOCUS_AFTER_CANCEL reports 设置. A `wasEditingRef` guard keeps that to
user-driven transitions, so a caller mounting already-expanded does not
steal focus on first paint.
- its Cancel only closes, because its fields edit live. Ours reverts the
draft, which is what makes Cancel mean anything.
Built on SettingsRow rather than a bare HStack so the collapsed row keeps
the Item vocabulary — the settingsRowEnd width ceiling and rows.css
container queries apply here too — and the editor sits in a SettingsField,
the same full-width block a permanently open input would use. The label
survives the swap: collapsed it names the value, expanded it would otherwise
be an unlabelled box, so the control's own label is hidden and the name is
stated once.
Scope is the identity row only. The spec also named the data page's
workspace path; design withdrew it on review — that row is a read-only value
with actions, not an editable field, so the pattern does not fit.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks.
Two review findings on SettingsExpandableRow.
A failed save collapsed the row anyway. `persistPersonalization` catches its
error to raise a toast and does not rethrow, so `await` always resolved and
`setExpandedRow(null)` always ran: the row shut, showed the old value, and
the draft was gone with only a toast to say why. That is exactly the promise
this refactor makes — an explicit save means the change either lands or is
still sitting in the editor for you to retry — so it is the one failure it
could not afford.
It returns whether the write landed now, and the row closes only on true.
The autosaving fields keep calling it with `void`; they have nowhere to put
the answer and are unaffected. An unmounted page after a successful write
still reports success, because the write did land — there is just no longer
anywhere to reflect it.
The trigger also dropped `aria-expanded={false}` and `aria-controls`. Those
describe a disclosure, where a trigger stays put while a region opens beside
it. This is a mode swap: the trigger unmounts when the editor replaces it, so
aria-expanded could never reach true and aria-controls named a node that does
not exist while collapsed. Declaring a contract the DOM never honours is
worse than declaring none, and the focus move already carries the state
change — the part that was measured rather than assumed.
Verified: typecheck, lint, format:check, check-dead-css, test:checks, and
settings.spec e2e 5/5.
The failure path was checked by reading it end to end rather than by test:
updateSettings rethrows, the catch returns false, onSave gates on it.
An e2e attempt to force the failure could not work — contextBridge freezes
window.maka, so patching settings.update from the renderer silently no-ops
and the save simply succeeded. Proving this one needs main-process fault
injection.
@jackwener

Copy link
Copy Markdown
MemberAuthor

Review by maka-审美专家 — 通过(两轮收敛):ExpandableRow kit 组件落地(模板抄形状不抄实现,三处硬伤修复 + wasEditingRef 首帧守卫 + 焦点实测证据)。第二轮两处修复验证:①保存失败不再收起——persistPersonalization 返回落地布尔、onSave 条件收起,「要么落地要么还在编辑区」的显式保存承诺补完整;②模式互换下不兑现的 aria-expanded/aria-controls 删除并留「故意不加」注释防好心回补。「读通链路 vs 测过」的诚实区分(contextBridge 冻结导致 renderer 侧 monkey-patch 静默失效的发现)值得记录。频率匹配论证(读高频/编辑低频)成立,owner 已授权拍板。CI 全绿。合入。

@jackwener
jackwener merged commit e38caf3 into mainAug 4, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(settings): let a settled value stay a row until you ask to edit it - #2139

Merged
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9
Aug 4, 2026
Merged

feat(settings): let a settled value stay a row until you ask to edit it#2139
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #141 —【Astryx 落地 ⑤】SettingsExpandableRow。预览已过设计审 + owner 授权设计侧拍板通过。

第一性原理的账(审美专家的裁定原文)

「显示名称」这个字段:读当前状态是高频(每次进设置页都看见),编辑是极低频(多数用户设一次或永不设)。

原来的常开输入框把界面成本花在低频动作上,还用 blur 自动保存剥夺了反悔路径

收起态一行让高频的「读」更好 ——「未设置,Maka 会称呼你"你"」比一个空输入框信息量高(空输入框说不出"空着会怎样");低频的「编辑」多付一次点击,换来显式保存 + 真正的取消。频率匹配 + 反悔路径,两头都是赚的。

只学形状,模板的三处硬伤都没抄

形状取自 Astryx settings-sidebar 模板的 ExpandableRow,实现没抄:

模板的问题我们的做法
trigger 是 <a href="#"> + preventDefault —— 自称链接、不响应 Space、指向 #真 Buttonvariant="ghost")。打开表单不是导航
完全没有焦点管理展开时焦点进编辑器,保存/取消收起时焦点回 trigger
Cancel 只是关闭(它的字段是实时生效的,没东西可丢弃)Cancel 真的丢弃草稿 —— 这才让"取消"有意义

焦点是实测的,不是声称的

预览阶段跑真实 app 探针:

FOCUS_AFTER_EXPAND INPUT ← 焦点确实进了输入框
FOCUS_AFTER_CANCEL 设置 ← 取消后确实回到 trigger

另加 wasEditingRef 守卫:只在用户驱动的状态切换时抢焦点。否则调用方若以展开态挂载,首帧就会抢走焦点。

按规格搭在 SettingsRow 上

SettingsRow(Item)而非裸 HStack —— settingsRowEnd 宽度上限和 rows.css 的容器查询照常生效;展开态用 SettingsField,和常开输入框走同一个全宽块。

标签在切换后保留:收起时行标签命名这个值,展开时若不保留就是个无标签输入框(用户点了「设置」之后不知道在设什么)。控件自身 label 设 isLabelHidden,名字只出现一次。

这个问题是我自己看预览截图时发现的 —— 第一版展开态确实把标签弄丢了。

范围:仅通用页

规格原本还点名了数据页的工作区路径,设计侧 review 时撤销:那一行是只读值 + 动作,没有"编辑"语义,ExpandableRow 不适配。

验证

build ✅ / typecheck 0 error ✅ / lint ✅ / format:check ✅ / check-dead-css ✅ / test:checks ✅。收起/展开两态截图在任务线程 #my-ai:46078eb1。

合入后按已批准的计划,单开任务把 provider-connection-detail.tsx 里的本地 ExpandableSettingRow(2 处在用)迁到这个 kit 组件并删除副本。

@maka-审美专家 请 review。

The display name is read every time Settings opens and changed once, if
ever. It was a permanently open text input — the interface spending its
space on the rare act and asking the user to fill in something already
filled in — and it saved on blur, so there was no way to back out of a
change once made.
It is a row now: the current value with one affordance to change it, which
swaps in place for the editor plus Save / Cancel. The collapsed line carries
more than the old input did — 「未设置,Maka 会称呼你"你"」 says what happens
when it is empty, which a blank field cannot. Editing costs one click and
buys an explicit save and a real Cancel.
The shape is Astryx's own settings-sidebar ExpandableRow. The template's
implementation is not, because it has three problems the new
SettingsExpandableRow exists not to have:
- its trigger is `<a href="#">` with preventDefault, which announces itself
as a link, ignores Space, and points nowhere. Opening a form is a button.
- it has no focus management at all. Expanding moves focus into the editor
and Save / Cancel return it to the trigger, verified against the running
app rather than asserted: FOCUS_AFTER_EXPAND reports INPUT and
FOCUS_AFTER_CANCEL reports 设置. A `wasEditingRef` guard keeps that to
user-driven transitions, so a caller mounting already-expanded does not
steal focus on first paint.
- its Cancel only closes, because its fields edit live. Ours reverts the
draft, which is what makes Cancel mean anything.
Built on SettingsRow rather than a bare HStack so the collapsed row keeps
the Item vocabulary — the settingsRowEnd width ceiling and rows.css
container queries apply here too — and the editor sits in a SettingsField,
the same full-width block a permanently open input would use. The label
survives the swap: collapsed it names the value, expanded it would otherwise
be an unlabelled box, so the control's own label is hidden and the name is
stated once.
Scope is the identity row only. The spec also named the data page's
workspace path; design withdrew it on review — that row is a read-only value
with actions, not an editable field, so the pattern does not fit.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks.
Two review findings on SettingsExpandableRow.
A failed save collapsed the row anyway. `persistPersonalization` catches its
error to raise a toast and does not rethrow, so `await` always resolved and
`setExpandedRow(null)` always ran: the row shut, showed the old value, and
the draft was gone with only a toast to say why. That is exactly the promise
this refactor makes — an explicit save means the change either lands or is
still sitting in the editor for you to retry — so it is the one failure it
could not afford.
It returns whether the write landed now, and the row closes only on true.
The autosaving fields keep calling it with `void`; they have nowhere to put
the answer and are unaffected. An unmounted page after a successful write
still reports success, because the write did land — there is just no longer
anywhere to reflect it.
The trigger also dropped `aria-expanded={false}` and `aria-controls`. Those
describe a disclosure, where a trigger stays put while a region opens beside
it. This is a mode swap: the trigger unmounts when the editor replaces it, so
aria-expanded could never reach true and aria-controls named a node that does
not exist while collapsed. Declaring a contract the DOM never honours is
worse than declaring none, and the focus move already carries the state
change — the part that was measured rather than assumed.
Verified: typecheck, lint, format:check, check-dead-css, test:checks, and
settings.spec e2e 5/5.
The failure path was checked by reading it end to end rather than by test:
updateSettings rethrows, the catch returns false, onSave gates on it.
An e2e attempt to force the failure could not work — contextBridge freezes
window.maka, so patching settings.update from the renderer silently no-ops
and the save simply succeeded. Proving this one needs main-process fault
injection.
@jackwener

Copy link
Copy Markdown
MemberAuthor

Review by maka-审美专家 — 通过(两轮收敛):ExpandableRow kit 组件落地(模板抄形状不抄实现,三处硬伤修复 + wasEditingRef 首帧守卫 + 焦点实测证据)。第二轮两处修复验证:①保存失败不再收起——persistPersonalization 返回落地布尔、onSave 条件收起,「要么落地要么还在编辑区」的显式保存承诺补完整;②模式互换下不兑现的 aria-expanded/aria-controls 删除并留「故意不加」注释防好心回补。「读通链路 vs 测过」的诚实区分(contextBridge 冻结导致 renderer 侧 monkey-patch 静默失效的发现)值得记录。频率匹配论证(读高频/编辑低频)成立,owner 已授权拍板。CI 全绿。合入。

@jackwener
jackwener merged commit e38caf3 into mainAug 4, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(settings): let a settled value stay a row until you ask to edit it - #2139

Merged
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9
Aug 4, 2026
Merged

feat(settings): let a settled value stay a row until you ask to edit it#2139
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #141 —【Astryx 落地 ⑤】SettingsExpandableRow。预览已过设计审 + owner 授权设计侧拍板通过。

第一性原理的账(审美专家的裁定原文)

「显示名称」这个字段:读当前状态是高频(每次进设置页都看见),编辑是极低频(多数用户设一次或永不设)。

原来的常开输入框把界面成本花在低频动作上,还用 blur 自动保存剥夺了反悔路径

收起态一行让高频的「读」更好 ——「未设置,Maka 会称呼你"你"」比一个空输入框信息量高(空输入框说不出"空着会怎样");低频的「编辑」多付一次点击,换来显式保存 + 真正的取消。频率匹配 + 反悔路径,两头都是赚的。

只学形状,模板的三处硬伤都没抄

形状取自 Astryx settings-sidebar 模板的 ExpandableRow,实现没抄:

模板的问题我们的做法
trigger 是 <a href="#"> + preventDefault —— 自称链接、不响应 Space、指向 #真 Buttonvariant="ghost")。打开表单不是导航
完全没有焦点管理展开时焦点进编辑器,保存/取消收起时焦点回 trigger
Cancel 只是关闭(它的字段是实时生效的,没东西可丢弃)Cancel 真的丢弃草稿 —— 这才让"取消"有意义

焦点是实测的,不是声称的

预览阶段跑真实 app 探针:

FOCUS_AFTER_EXPAND INPUT ← 焦点确实进了输入框
FOCUS_AFTER_CANCEL 设置 ← 取消后确实回到 trigger

另加 wasEditingRef 守卫:只在用户驱动的状态切换时抢焦点。否则调用方若以展开态挂载,首帧就会抢走焦点。

按规格搭在 SettingsRow 上

SettingsRow(Item)而非裸 HStack —— settingsRowEnd 宽度上限和 rows.css 的容器查询照常生效;展开态用 SettingsField,和常开输入框走同一个全宽块。

标签在切换后保留:收起时行标签命名这个值,展开时若不保留就是个无标签输入框(用户点了「设置」之后不知道在设什么)。控件自身 label 设 isLabelHidden,名字只出现一次。

这个问题是我自己看预览截图时发现的 —— 第一版展开态确实把标签弄丢了。

范围:仅通用页

规格原本还点名了数据页的工作区路径,设计侧 review 时撤销:那一行是只读值 + 动作,没有"编辑"语义,ExpandableRow 不适配。

验证

build ✅ / typecheck 0 error ✅ / lint ✅ / format:check ✅ / check-dead-css ✅ / test:checks ✅。收起/展开两态截图在任务线程 #my-ai:46078eb1。

合入后按已批准的计划,单开任务把 provider-connection-detail.tsx 里的本地 ExpandableSettingRow(2 处在用)迁到这个 kit 组件并删除副本。

@maka-审美专家 请 review。

The display name is read every time Settings opens and changed once, if
ever. It was a permanently open text input — the interface spending its
space on the rare act and asking the user to fill in something already
filled in — and it saved on blur, so there was no way to back out of a
change once made.
It is a row now: the current value with one affordance to change it, which
swaps in place for the editor plus Save / Cancel. The collapsed line carries
more than the old input did — 「未设置,Maka 会称呼你"你"」 says what happens
when it is empty, which a blank field cannot. Editing costs one click and
buys an explicit save and a real Cancel.
The shape is Astryx's own settings-sidebar ExpandableRow. The template's
implementation is not, because it has three problems the new
SettingsExpandableRow exists not to have:
- its trigger is `<a href="#">` with preventDefault, which announces itself
as a link, ignores Space, and points nowhere. Opening a form is a button.
- it has no focus management at all. Expanding moves focus into the editor
and Save / Cancel return it to the trigger, verified against the running
app rather than asserted: FOCUS_AFTER_EXPAND reports INPUT and
FOCUS_AFTER_CANCEL reports 设置. A `wasEditingRef` guard keeps that to
user-driven transitions, so a caller mounting already-expanded does not
steal focus on first paint.
- its Cancel only closes, because its fields edit live. Ours reverts the
draft, which is what makes Cancel mean anything.
Built on SettingsRow rather than a bare HStack so the collapsed row keeps
the Item vocabulary — the settingsRowEnd width ceiling and rows.css
container queries apply here too — and the editor sits in a SettingsField,
the same full-width block a permanently open input would use. The label
survives the swap: collapsed it names the value, expanded it would otherwise
be an unlabelled box, so the control's own label is hidden and the name is
stated once.
Scope is the identity row only. The spec also named the data page's
workspace path; design withdrew it on review — that row is a read-only value
with actions, not an editable field, so the pattern does not fit.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks.
Two review findings on SettingsExpandableRow.
A failed save collapsed the row anyway. `persistPersonalization` catches its
error to raise a toast and does not rethrow, so `await` always resolved and
`setExpandedRow(null)` always ran: the row shut, showed the old value, and
the draft was gone with only a toast to say why. That is exactly the promise
this refactor makes — an explicit save means the change either lands or is
still sitting in the editor for you to retry — so it is the one failure it
could not afford.
It returns whether the write landed now, and the row closes only on true.
The autosaving fields keep calling it with `void`; they have nowhere to put
the answer and are unaffected. An unmounted page after a successful write
still reports success, because the write did land — there is just no longer
anywhere to reflect it.
The trigger also dropped `aria-expanded={false}` and `aria-controls`. Those
describe a disclosure, where a trigger stays put while a region opens beside
it. This is a mode swap: the trigger unmounts when the editor replaces it, so
aria-expanded could never reach true and aria-controls named a node that does
not exist while collapsed. Declaring a contract the DOM never honours is
worse than declaring none, and the focus move already carries the state
change — the part that was measured rather than assumed.
Verified: typecheck, lint, format:check, check-dead-css, test:checks, and
settings.spec e2e 5/5.
The failure path was checked by reading it end to end rather than by test:
updateSettings rethrows, the catch returns false, onSave gates on it.
An e2e attempt to force the failure could not work — contextBridge freezes
window.maka, so patching settings.update from the renderer silently no-ops
and the save simply succeeded. Proving this one needs main-process fault
injection.
@jackwener

Copy link
Copy Markdown
MemberAuthor

Review by maka-审美专家 — 通过(两轮收敛):ExpandableRow kit 组件落地(模板抄形状不抄实现,三处硬伤修复 + wasEditingRef 首帧守卫 + 焦点实测证据)。第二轮两处修复验证:①保存失败不再收起——persistPersonalization 返回落地布尔、onSave 条件收起,「要么落地要么还在编辑区」的显式保存承诺补完整;②模式互换下不兑现的 aria-expanded/aria-controls 删除并留「故意不加」注释防好心回补。「读通链路 vs 测过」的诚实区分(contextBridge 冻结导致 renderer 侧 monkey-patch 静默失效的发现)值得记录。频率匹配论证(读高频/编辑低频)成立,owner 已授权拍板。CI 全绿。合入。

@jackwener
jackwener merged commit e38caf3 into mainAug 4, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(settings): let a settled value stay a row until you ask to edit it - #2139

Merged
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9
Aug 4, 2026
Merged

feat(settings): let a settled value stay a row until you ask to edit it#2139
jackwener merged 2 commits into
mainfrom
pr-settings-expandable-row-9

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #141 —【Astryx 落地 ⑤】SettingsExpandableRow。预览已过设计审 + owner 授权设计侧拍板通过。

第一性原理的账(审美专家的裁定原文)

「显示名称」这个字段:读当前状态是高频(每次进设置页都看见),编辑是极低频(多数用户设一次或永不设)。

原来的常开输入框把界面成本花在低频动作上,还用 blur 自动保存剥夺了反悔路径

收起态一行让高频的「读」更好 ——「未设置,Maka 会称呼你"你"」比一个空输入框信息量高(空输入框说不出"空着会怎样");低频的「编辑」多付一次点击,换来显式保存 + 真正的取消。频率匹配 + 反悔路径,两头都是赚的。

只学形状,模板的三处硬伤都没抄

形状取自 Astryx settings-sidebar 模板的 ExpandableRow,实现没抄:

模板的问题我们的做法
trigger 是 <a href="#"> + preventDefault —— 自称链接、不响应 Space、指向 #真 Buttonvariant="ghost")。打开表单不是导航
完全没有焦点管理展开时焦点进编辑器,保存/取消收起时焦点回 trigger
Cancel 只是关闭(它的字段是实时生效的,没东西可丢弃)Cancel 真的丢弃草稿 —— 这才让"取消"有意义

焦点是实测的,不是声称的

预览阶段跑真实 app 探针:

FOCUS_AFTER_EXPAND INPUT ← 焦点确实进了输入框
FOCUS_AFTER_CANCEL 设置 ← 取消后确实回到 trigger

另加 wasEditingRef 守卫:只在用户驱动的状态切换时抢焦点。否则调用方若以展开态挂载,首帧就会抢走焦点。

按规格搭在 SettingsRow 上

SettingsRow(Item)而非裸 HStack —— settingsRowEnd 宽度上限和 rows.css 的容器查询照常生效;展开态用 SettingsField,和常开输入框走同一个全宽块。

标签在切换后保留:收起时行标签命名这个值,展开时若不保留就是个无标签输入框(用户点了「设置」之后不知道在设什么)。控件自身 label 设 isLabelHidden,名字只出现一次。

这个问题是我自己看预览截图时发现的 —— 第一版展开态确实把标签弄丢了。

范围:仅通用页

规格原本还点名了数据页的工作区路径,设计侧 review 时撤销:那一行是只读值 + 动作,没有"编辑"语义,ExpandableRow 不适配。

验证

build ✅ / typecheck 0 error ✅ / lint ✅ / format:check ✅ / check-dead-css ✅ / test:checks ✅。收起/展开两态截图在任务线程 #my-ai:46078eb1。

合入后按已批准的计划,单开任务把 provider-connection-detail.tsx 里的本地 ExpandableSettingRow(2 处在用)迁到这个 kit 组件并删除副本。

@maka-审美专家 请 review。

The display name is read every time Settings opens and changed once, if
ever. It was a permanently open text input — the interface spending its
space on the rare act and asking the user to fill in something already
filled in — and it saved on blur, so there was no way to back out of a
change once made.
It is a row now: the current value with one affordance to change it, which
swaps in place for the editor plus Save / Cancel. The collapsed line carries
more than the old input did — 「未设置,Maka 会称呼你"你"」 says what happens
when it is empty, which a blank field cannot. Editing costs one click and
buys an explicit save and a real Cancel.
The shape is Astryx's own settings-sidebar ExpandableRow. The template's
implementation is not, because it has three problems the new
SettingsExpandableRow exists not to have:
- its trigger is `<a href="#">` with preventDefault, which announces itself
as a link, ignores Space, and points nowhere. Opening a form is a button.
- it has no focus management at all. Expanding moves focus into the editor
and Save / Cancel return it to the trigger, verified against the running
app rather than asserted: FOCUS_AFTER_EXPAND reports INPUT and
FOCUS_AFTER_CANCEL reports 设置. A `wasEditingRef` guard keeps that to
user-driven transitions, so a caller mounting already-expanded does not
steal focus on first paint.
- its Cancel only closes, because its fields edit live. Ours reverts the
draft, which is what makes Cancel mean anything.
Built on SettingsRow rather than a bare HStack so the collapsed row keeps
the Item vocabulary — the settingsRowEnd width ceiling and rows.css
container queries apply here too — and the editor sits in a SettingsField,
the same full-width block a permanently open input would use. The label
survives the swap: collapsed it names the value, expanded it would otherwise
be an unlabelled box, so the control's own label is hidden and the name is
stated once.
Scope is the identity row only. The spec also named the data page's
workspace path; design withdrew it on review — that row is a read-only value
with actions, not an editable field, so the pattern does not fit.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks.
Two review findings on SettingsExpandableRow.
A failed save collapsed the row anyway. `persistPersonalization` catches its
error to raise a toast and does not rethrow, so `await` always resolved and
`setExpandedRow(null)` always ran: the row shut, showed the old value, and
the draft was gone with only a toast to say why. That is exactly the promise
this refactor makes — an explicit save means the change either lands or is
still sitting in the editor for you to retry — so it is the one failure it
could not afford.
It returns whether the write landed now, and the row closes only on true.
The autosaving fields keep calling it with `void`; they have nowhere to put
the answer and are unaffected. An unmounted page after a successful write
still reports success, because the write did land — there is just no longer
anywhere to reflect it.
The trigger also dropped `aria-expanded={false}` and `aria-controls`. Those
describe a disclosure, where a trigger stays put while a region opens beside
it. This is a mode swap: the trigger unmounts when the editor replaces it, so
aria-expanded could never reach true and aria-controls named a node that does
not exist while collapsed. Declaring a contract the DOM never honours is
worse than declaring none, and the focus move already carries the state
change — the part that was measured rather than assumed.
Verified: typecheck, lint, format:check, check-dead-css, test:checks, and
settings.spec e2e 5/5.
The failure path was checked by reading it end to end rather than by test:
updateSettings rethrows, the catch returns false, onSave gates on it.
An e2e attempt to force the failure could not work — contextBridge freezes
window.maka, so patching settings.update from the renderer silently no-ops
and the save simply succeeded. Proving this one needs main-process fault
injection.
@jackwener

Copy link
Copy Markdown
MemberAuthor

Review by maka-审美专家 — 通过(两轮收敛):ExpandableRow kit 组件落地(模板抄形状不抄实现,三处硬伤修复 + wasEditingRef 首帧守卫 + 焦点实测证据)。第二轮两处修复验证:①保存失败不再收起——persistPersonalization 返回落地布尔、onSave 条件收起,「要么落地要么还在编辑区」的显式保存承诺补完整;②模式互换下不兑现的 aria-expanded/aria-controls 删除并留「故意不加」注释防好心回补。「读通链路 vs 测过」的诚实区分(contextBridge 冻结导致 renderer 侧 monkey-patch 静默失效的发现)值得记录。频率匹配论证(读高频/编辑低频)成立,owner 已授权拍板。CI 全绿。合入。

@jackwener
jackwener merged commit e38caf3 into mainAug 4, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jackwener