Skip to content

fix(ui): localize the field required/optional marker app-wide - #2184

Merged
Astro-Han merged 2 commits into
mainfrom
fix/ui-localize-field-required-marker
Aug 4, 2026
Merged

fix(ui): localize the field required/optional marker app-wide#2184
Astro-Han merged 2 commits into
mainfrom
fix/ui-localize-field-required-marker

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Astryx's FieldLabel renders a field's required/optional marker as the literal English words 'Required' / 'Optional'. There is no message id, so AstryxLocaleProvider (packages/ui/src/astryx-i18n.tsx) cannot reach them. The consequence: every form that passes isRequired / isOptional prints an English word inside a Chinese-first UI — the provider add form, the MCP server editor, the WeChat bot login, the plan-reminder dialog.

This PR takes the app-wide answer: make those two words a copy entry in the catalog, where they belong.

  • patches/@astryxdesign+core+0.2.0.patchFieldLabel resolves @astryx.field.required / @astryx.field.optional through useTranslator(), and locales/en.json gains both keys. en still renders Required / Optional, now through the shipped catalog instead of a hard-coded string.
  • packages/ui/src/astryx-i18n.tsx — the zh values come from getSharedUiCopy(...).formControls, the same place every other Astryx override reads from.

aria-required is untouched. That is precisely why the marker is translated rather than hidden: isRequired is the only prop that writes aria-required onto the input (Astryx writes the attribute after spreading ...rest, so passing it through is silently overwritten). Suppressing the visible marker therefore means keeping the prop and deleting the only visible required affordance — and keying a CSS rule on "the one element child of a field label that wraps an aria-hidden separator", which is not a contract. That is exactly why the rule in apps/desktop/src/renderer/styles/astryx-field.css on #2155 was deliberately scoped to #maka-plan-reminder-form.

The two rejected alternatives and the reasoning against each are written up in the new section of patches/README.md, together with this patch's deletion condition.

One duplicate falls out of the fix: settings-provider-copy.ts used to hand-roll API Key(必填) into the label, which stacked with the component's own marker and rendered as API Key(必填) ∙ Required — two markers, one of them untranslated. The marker now comes only from the component; PasswordInput hangs it on the visible group label while the inner input keeps isRequired for the semantics.

Refs #2155

Review focus: the relationship to #2155

astryx-field.css exists only on #2155's branch — it is not on main — so this PR cannot delete it. #2155 should delete apps/desktop/src/renderer/styles/astryx-field.css and its stylesheet-entry import after rebasing onto this PR. Once the patch lands, that rule's only remaining effect is to re-hide the now-translated 「必填」 in the plan-reminder form.

Either PR can merge first, but whichever merges second owns this cleanup, and nothing catches it automatically: check-dead-css.mjs scans class selectors for consumers, and that rule is keyed on an id selector whose id genuinely exists in #2155's DOM. A forgotten deletion is green CI and a silent regression.

Verification

  • npm run format / npm run lint — clean
  • npm --workspace @maka/desktop run typecheck (four tsconfigs) — pass
  • npm --workspace @maka/ui run test — 324 pass
  • npm --workspace @maka/desktop run test — 1664 pass
  • npm run test:scripts — 110 pass
  • node scripts/check-dead-css.mjs --check — pass
  • npm --workspace @maka/desktop run smoke:storybook — 68 manifest checks + 82 catalog renders pass
  • CI — all jobs green, including both e2e_shard jobs

Playwright, and why it was run deliberately

This PR changes accessible names (API Key(必填)API Key ∙ 必填), and providers.spec.ts selects on them at lines 77 and 263 via getByRole('textbox', { name: /API Key/ }). Skipping the suite was not defensible.

CI runs both shards green. Locally the same spec is 1 pass / 1 fail: providers.spec.ts:71 times out on the delete-button click at line 231 because this machine has no network, the model-catalog fetch fails, and the resulting 「网络错误 · 当前继续显示静态列表」 toast intercepts the pointer. That is the same pre-existing failure #2155 recorded against an unmodified baseline. It is also the evidence that matters here: the run reaches line 231, which means the label selectors at 77 and 263 were never disturbed.

The patch guard actually fails without the patch

Three reverse scenarios, each verified by hand:

Reverted Result
The FieldLabel.js hunk (back to the upstream hard-coded line) the two zh tests go red, the rest green
@astryx.field.required from en.json the en test goes red
@astryx.field.optional from en.json the en test goes red

With the patch applied, all six pass. That is the deletion condition recorded in patches/README.md: the day those tests pass without the patch, delete it.

Rendered output (Storybook, zh)

product-settings-providers--add-provider:

API Key ∙ 必填
[{"type":"password","ariaRequired":"true"}]

product-module-hubs--extensions-mcp-configured → 添加 MCP:

[{"t":"服务器 ID ∙ 必填","req":"true"},
 {"t":"命令 ∙ 必填","req":"true"},
 {"t":"参数","req":null},
 {"t":"工作目录","req":null}]

External review

Pi / opencode-go/deepseek-v4-flash (effort max) reviewed the branch: no P0/P1/P2, three P3s. After verifying each independently:

  • Fixed — the en guard pinned only the required marker. Deleting just @astryx.field.optional left all six tests green while en leaked the raw key. Reproduced, then closed: that scenario now goes red.
  • Fixed (found while verifying) — the comment in password-input.tsx claimed the inner input's label is hidden. It is not rendered at all: a TextInput inside an InputGroup returns its bare input wrapper and skips Field entirely, which is why the marker has to ride on the group label rather than being a second copy of it.
  • DeferredPasswordInput's isOptional threading and the copy changes have no unit test. Both forms had zero coverage in the desktop suite before this PR; adding it is a new coverage layer, out of scope here. The provider form is exercised by the e2e run above.
  • Rejected — "the accessible name of grouped inputs now carries the marker and aria-required, announcing it twice". Checked against the vendored source: this is Astryx's existing shape for every standalone field (the marker sits inside the element that names the input). It cannot change without removing the visible marker, which is the point of this PR.

中文对照

概要

Astryx 的 FieldLabel 把字段的必填/选填标记写死成英文字面量 'Required' / 'Optional',没有 message id,AstryxLocaleProviderpackages/ui/src/astryx-i18n.tsx)够不到它。结果是:只要表单传了 isRequired / isOptional,中文界面里就会冒出一个英文词 —— 模型连接添加表单、MCP 服务器编辑器、微信 Bot 登录、计划提醒弹窗都中招。

这个 PR 给出全局答案:把这两个词变成 catalog 里的一条 copy,放回它该在的地方。

  • patches/@astryxdesign+core+0.2.0.patch —— FieldLabel 改用 useTranslator() 解析 @astryx.field.required / @astryx.field.optional,同时给 locales/en.json 补上这两个 key。en 仍然渲染 Required / Optional,只是改从 shipped catalog 走,而不是硬编码。
  • packages/ui/src/astryx-i18n.tsx —— zh 取值来自 getSharedUiCopy(...).formControls,和其他所有 Astryx override 同一个出处。

aria-required 完好无损。 这正是选「翻译」而不是「隐藏」的原因:isRequired 是唯一能把 aria-required 写到 input 上的入口(Astryx 在 ...rest 之后才写这个属性,自己传会被静默覆盖)。所以要去掉可见标记就只能保留 prop、并删掉唯一的可见必填提示 —— 还要把 CSS 规则 key 在「label 下唯一一个包着 aria-hidden 分隔符的元素子节点」这种非契约的 DOM 形状上。#2155apps/desktop/src/renderer/styles/astryx-field.css 那条规则正是因此才刻意只 scope 到 #maka-plan-reminder-form

两个被拒方案及其理由,连同这个 patch 的删除条件,写在 patches/README.md 新增的小节里。

顺带清掉一处重复:settings-provider-copy.ts 原本自己在 label 里拼了 API Key(必填),叠加组件自带的标记后实际渲染成 API Key(必填) ∙ Required —— 两个标记,其中一个还没翻译。现在标记只由组件出,PasswordInput 把它挂在可见的 group label 上,内层 input 继续拿 isRequired 保住语义。

Review focus:和 #2155 的关系

astryx-field.css 只存在于 #2155 的分支上,main 上没有,所以这个 PR 无法直接删掉它。#2155 应该在 rebase 到本 PR 之后,删除 apps/desktop/src/renderer/styles/astryx-field.css 及其样式入口引用。 patch 落地后,那条规则唯一的效果就是把计划提醒表单里已经翻译好的「必填」重新藏起来。

两个 PR 谁先合都行,但后合的那个要负责这次清理,而且没有任何自动兜底check-dead-css.mjs 只扫 class 选择器的消费者,而那条规则 key 在一个 id 选择器上,该 id 在 #2155 的 DOM 里真实存在。忘了删 = CI 全绿 + 静默回归。

验证

  • npm run format / npm run lint —— clean
  • npm --workspace @maka/desktop run typecheck(四个 tsconfig)—— pass
  • npm --workspace @maka/ui run test —— 324 pass
  • npm --workspace @maka/desktop run test —— 1664 pass
  • npm run test:scripts —— 110 pass
  • node scripts/check-dead-css.mjs --check —— pass
  • npm --workspace @maka/desktop run smoke:storybook —— 68 manifest checks + 82 catalog renders pass
  • CI —— 全绿,包括两个 e2e_shard

Playwright,以及为什么这次是特意跑的

本 PR 改了 accessible name(API Key(必填)API Key ∙ 必填),而 providers.spec.ts 第 77、263 行正是用 getByRole('textbox', { name: /API Key/ }) 选元素的。跳过这个套件说不过去。

CI 两个 shard 全绿。本机同一个 spec 是 1 pass / 1 fail:providers.spec.ts:71 卡在第 231 行的删除按钮点击上超时 —— 本机无网络,模型目录拉取失败,弹出的「网络错误 · 当前继续显示静态列表」toast 拦住了 pointer。这与 #2155 在未改动基线上记录的是同一个既有失败。它同时也是这里真正有用的证据:用例跑到了第 231 行,说明第 77、263 行的选择器完全没被打断。

补丁守卫确实会在缺少补丁时变红

三个反向场景,逐个手工验证:

还原对象 结果
FieldLabel.js hunk(回到上游硬编码那行) 两个 zh 测试红,其余绿
en.json 删掉 @astryx.field.required en 测试红
en.json 删掉 @astryx.field.optional en 测试红

补丁应用后六条全绿。这就是 patches/README.md 里记录的删除条件:哪天不打补丁这些测试也能过,就删掉它。

实际渲染(Storybook,zh)

见上方英文段落中的输出。

外部 review

Pi / opencode-go/deepseek-v4-flash(effort max)做了一轮 review:无 P0/P1/P2,三条 P3。逐条独立复核后:

  • 已修 —— en 守卫只钉了 required。单独删掉 @astryx.field.optional 时六条测试仍全绿,而 en 会泄漏 raw key。已复现并堵上:该场景现在会红。
  • 已修(复核时自己发现的) —— password-input.tsx 的注释说「内层 input 的 label 是 hidden 的」。它根本没被渲染:InputGroup 内的 TextInput 直接返回裸 input wrapper、完全跳过 Field,所以 marker 只能挂在 group label 上,不存在第二份。
  • 推迟 —— PasswordInputisOptional 穿线与文案改动没有单元测试。这两个表单在本 PR 之前于 desktop 套件里就是零覆盖;补齐属于新增覆盖层,不在本 PR 范围。provider 表单已由上面的 e2e 走到。
  • 驳回 —— 「分组输入框的 accessible name 现在同时带 marker 和 aria-required,等于宣告两次」。已核对 vendored 源码:这是 Astryx 对所有独立字段的既有形状(marker 就在给 input 命名的那个元素里)。不移除可见标记就无法改变,而可见标记正是本 PR 的目的。

Astryx's FieldLabel hard-codes the marker beside a label as the literal
words `Required` / `Optional`, with no message id, so AstryxLocaleProvider
cannot reach them: every form passing `isRequired` / `isOptional` printed
an English word inside a Chinese-first UI.

Patch @astryxdesign/core to resolve `@astryx.field.required` /
`@astryx.field.optional` through the i18n catalog, and supply the zh values
from the shared copy catalogue like every other Astryx override. `en` still
resolves to `Required` / `Optional`, now through the shipped catalog.

`aria-required` is untouched — the prop stays on the control, which is the
only thing that writes that attribute. That is why the marker is localized
rather than hidden: hiding it needs the prop kept and the visible required
affordance removed, and it keys on a DOM shape that is not a contract.

Drop the hand-rolled markers the provider copy carried (`API Key(必填)`),
which rendered twice next to the component's own — once translated, once
not — and let PasswordInput's group label take the marker instead.
The en guard asserted only the required marker, so dropping just
`@astryx.field.optional` from the patched catalog left every test green
while `en` leaked the raw key. Both entries are separate; assert both.

The PasswordInput comment said the inner input's label is hidden. It is
not rendered at all: a TextInput inside an InputGroup returns its bare
input wrapper and skips Field entirely, which is why the marker has to
ride on the group label rather than being a second copy of it.
@Astro-Han
Astro-Han marked this pull request as ready for review August 4, 2026 17:54
@Astro-Han
Astro-Han merged commit 5c33154 into main Aug 4, 2026
11 checks passed
@Astro-Han
Astro-Han deleted the fix/ui-localize-field-required-marker branch August 4, 2026 17:55
Astro-Han added a commit that referenced this pull request Aug 4, 2026
The reminder form hid Astryx's required/optional marker in CSS because the
vendor hard-coded it as the English words `Required` / `Optional` with no
message id, and an English word inside a Chinese form was worse than no
marker at all. That rule had to be scoped to this one form: unscoped, it
stripped the visible required affordance from the provider add flow, the MCP
page and the bot login too.

#2184 localized the marker at the source, so the premise is gone. Delete the
rule, its stylesheet and its import, and let the form show the same marker as
every other form in the app. `isRequired` stays where it is — it is still the
only route to `aria-required`.
Astro-Han added a commit that referenced this pull request Aug 4, 2026
The reminder form hid Astryx's required/optional marker in CSS because the
vendor hard-coded it as the English words `Required` / `Optional` with no
message id, and an English word inside a Chinese form was worse than no
marker at all. That rule had to be scoped to this one form: unscoped, it
stripped the visible required affordance from the provider add flow, the MCP
page and the bot login too.

#2184 localized the marker at the source, so the premise is gone. Delete the
rule, its stylesheet and its import, and let the form show the same marker as
every other form in the app. `isRequired` stays where it is — it is still the
only route to `aria-required`.
Astro-Han added a commit that referenced this pull request Aug 4, 2026
)

* refactor(automations): one page skeleton for 计划提醒 and 每日回顾

The two 定时任务 pages had grown independently and read as two products.
计划提醒 rendered a display-scale title with a lede and a primary action
through the shared PageHeader, then a tab bar with an Astryx Toolbar; 每日回顾
hand-rolled its header out of VStack/HStack/Heading, had no lede, sat its
range switch where a page action belongs, and drew a standalone Divider where
the other page used TabList `hasDivider`. Each also carried a private copy of
the same page-column block (.maka-plan-shell, .maka-daily-review-panel) that
had since drifted on gap, padding and clamp.

Extract that column into the shared .maka-module-page-* set next to the
.maka-module-main-header both 技能 and MCP already use, and put both pages on
it: same header (title, lede, hub selector, primary action), same control bar
(what you are looking at on the left, how it is filtered on the right) with
the hairline owned by the bar rather than by whichever control lands in it,
same panel rhythm. 每日回顾 keeps its own content — the range switch, period
stepper, totals strip, session list and report route are untouched apart from
where they are slotted.

Fewer rungs and fewer widgets while there:
- the report's title steps down to h3 and its sections to h4, so the page h2
  is once again the only page-level heading;
- the run-history row title drops from heading-4 to the body/600 the task rows
  already use, and the search-result count drops from heading-5 to supporting;
- the totals strip is a fixed 4-up instead of an auto-fit that wrapped 3 + 1
  whenever the page column was narrower than the viewport breakpoint saw.

The 计划提醒 split view keeps its own container: header and list share one
column so the title stays aligned with the rows when the form opens.

Retires .maka-plan-shell / -hero / -heading / -top-actions / -tabs /
-tabs-bar / -tab-panel, .maka-daily-review-panel / -route-frame / -toolbar,
.agents-inner-view-clamp, and the daily-review-only module-main grid override.

* refactor(automations): rebuild the page on Astryx's tracker archetype

Round 1 unified the two 定时任务 pages onto one skeleton. This round rebuilds
that skeleton against the official Astryx `incident-console` template — the
vendor's archetype for exactly this page: dense rows, not cards, beside a
resizable inspector.

- ModulePage is now an Astryx `Layout`: one-row header (title + one supporting
  count + actions), no lede paragraph, hub switch moved down into a control bar
  in the content area. It restates `padding={5}` because the app shell is a
  full-bleed Layout whose `--layout-padding-outer-x: 0` inherits down and left
  the page title 20px short of its own rows.
- Rows are `ListItem`s with a leading `StatusDot`. Astryx's List guidance is
  explicit that interactive elements do not belong inside an interactive list
  item, so the per-row switch and overflow menu are gone — which also fixes the
  completed-row misalignment, since the leading slot's width now comes from the
  component rather than a hand-held 40px placeholder.
- Every per-task control moved into PlanReminderInspector, opened by selecting a
  row: enable, trigger, snooze, the facts as a MetadataList, that task's run
  history, edit/duplicate/delete. Below 1024px the page drops the panel outright,
  as the vendor template does, rather than squeeze two columns into one.
- Create/edit is an Astryx `Dialog purpose="form"`, per Dialog's own guidance for
  input-bearing surfaces, replacing the hand-rolled split-view aside.
- Dialog copy now says 定时任务 like the rest of the page, and the page's dead
  copy keys (subtitle, per-row menu labels) are gone.

Stories: 13 → 10. Four that only pinned logic branches or duplicated pixels are
gone; the inspector gets one wide-only story of its own.

* refactor(automations): simplify the task form to plain Astryx fields

Two problems reported against the redesign.

The content plate could not be told apart from the sidebar. Its edge is the one
boundary on that seam by design, but a 2.5% tonal step is not an edge you can
see, so the two columns read as a single surface. §4 lists hairline rings
alongside near-white tonal steps as light-mode layering and the sidenav's own
hairline is already off, so the plate now carries the ring — one line on that
seam, not a doubled one.

The create/edit dialog had too many colours, components and type sizes. It is
now shaped after the vendor's own `DialogFormDialog` block: header, a vertical
FormLayout of plain labelled fields, footer. Removed:

- the borderless display-size title input (a raw input inside Field with
  hand-wired aria and its own CSS) → a plain TextInput whose `hasAutoFocus`
  emits the very `data-autofocus` hook Dialog looks for;
- `PlanValueMenu`, a bespoke ghost "value ⌄" menu standing in for a select →
  `Selector`, the same control the list page already uses;
- the two grey `--muted` inset group boxes, their labels and the hairline
  label/control row idiom → FormLayout;
- the four quick-time buttons → the DateTimeInput they shortcut, plus 使用模板
  for the "give me a sensible schedule" case;
- the loose delivery-help paragraph → the platform Selector's description slot.

That leaves one control idiom, one surface, and only the label and body sizes
Astryx's own inputs bring. `isRequired` / `isOptional` are deliberately not
used: Astryx hard-codes those markers as the English words "Required" /
"Optional" with no message id, so they cannot be translated and were rendering
English inside a Chinese form. The title's own validation message carries the
requirement, wired to aria-invalid and aria-describedby.

Also drops the copy, helper and CSS the old form owned, and the dialog's
duplicate focus-restore effect — the panel that opens it already owns that.

* fix(shell): stop the shell canvas repainting a page's own content area

The app shell painted its canvas with `.maka-shell-astryx
.astryx-layout-content`, but that class is on EVERY Astryx `LayoutContent`. As
a descendant selector it also reached the one a page renders inside the
floating plate and repainted the plate's whole body with the darker canvas
colour — the exact hazard behind Astryx's "do not nest Layout" guidance,
arriving through CSS rather than the component.

In light mode that was a 2.5% step nobody saw. In dark it painted the page the
sidebar's own colour (0.18 against the plate's 0.24), so the content column and
the sidebar fused into one flat near-black expanse — worse than before this
branch, which never rendered a second Layout.

Key the rule on the AppShell's own `#astryx-app-shell-main` instead. That is
the vendor's hook for this one region and cannot be nested, so the plate keeps
`--agents-content-area-bg` — Astryx's own `elevated` AppShell variant, wash nav
against an elevated content surface, which is the shape this shell already
wanted.

* refactor(automations): clamp the page into the app's shared column

Every other main page in this app — 技能 / MCP / 设置 — clamps its content to a
centred column; only these two ran edge to edge, which is why they read as
continuous with the sidebar instead of as a page on a plate.

`Layout.contentWidth` is Astryx's own mechanism for this and it aligns cleanly:
the same `--layout-content-width` reaches the header's inner wrapper and the
body row, and both centre inside the full plate width, so the title keeps the
rows' left edge whether or not the inspector is open. The earlier note here
claiming a clamp would break that alignment was wrong — it assumed each slot
clamps inside its own box.

The inspector rides inside that column rather than beside it, so its default
width drops from 380 to 320: sized against the column it shares with the rows,
not against the window.

* refactor(ui): keep lines for rows only, not page chrome

The two 定时任务 pages drew four kinds of hairline: a header rule, a
divider beside the inspector, a rule around the daily totals with three
more between them, and the list's own row dividers. Only the last says
anything — where a row ends. The rest restate boundaries the layout
already draws, and this app separates columns tonally (DESIGN.md, One
Working Plane), which is why the sidenav's own hairline is already off.

Drop the chrome lines. The page's control bar moves into the Layout
header as its last row, so the header/content split is spacing, not a
rule — and the rule it used to carry sat on LayoutHeader's OUTER element,
outside the content-width clamp, so it cut across the whole plate on a
page whose every other edge stops at the column.

`.maka-shell-astryx .astryx-resize-handle { top: var(--h-titlebar) }` was
aimed at the sidenav handle, which is absolutely positioned against the
column box and needs to clear the chrome strip. Every Astryx
ResizeHandle carries that class, so it also hit the page inspector's
handle — a `position: relative` flex item — and shoved its divider 36px
down, starting it below the header instead of at the top of the row. Key
it on the sidenav handle's own testid.

* fix(automations): keep every task action reachable and announced

External review of the rebuild found the inspector had become a single
point of failure, in two ways.

Reachability. Rows are inert by design — every per-item action moved to
the inspector — but the shell DROPPED the inspector below 1024px, and
this app's own floor is a 480px window (SAFE_MIN_WIDTH). Enable/pause,
trigger, snooze, edit, duplicate, clear and delete had no reachable path
at all on a narrow window. Narrow now changes the inspector's PLACEMENT,
not its existence: the same content opens as a dialog over the list.

Semantics. `inspectorLabel` named nothing — Astryx passes `role` and
`aria-label` straight to the div, and `aria-label` on a roleless div is
not an accessible name; the panel is `role="complementary"` now. The
resize grip stood permanently visible (`isAlwaysVisible` defaults true)
against a page that just deleted its standing rules, and read out as the
English "Resize handle" in a Chinese app.

`aria-required` came back the same way. Astryx writes that attribute
AFTER spreading `...rest`, so `isRequired` is the only route to it — and
the form had dropped the prop to avoid the vendor's hard-coded English
"Required" marker, losing the semantics with the text. The marker is
suppressed in CSS instead, which takes it out of the accessibility tree
too, so screen readers announce required in their own language.

Two smaller losses from the same review: the disconnected 每日回顾 state
dropped the module switch, which was the only in-page way back to 计划
提醒; and the module lazy fallback shrank to its own text width once the
shell became a flex row.

* test(automations): pin the page's layout and inspector contracts

The invariants this rebuild turns on — one clamped column, the title over
its own rows, no chrome hairline, and an inspector that follows the
window instead of vanishing with it — were held by nothing but a person
looking at a screenshot. Every one of them broke at least once during the
rebuild, and three broke in ways a screenshot did show but nobody caught
until the next round of review.

`module-page-shell` and `module-page-inspector` assert them where they
are cheap to assert: computed style and geometry in the Storybook smoke,
which already runs the page at wide, compact and floor. The inspector
story runs at all three viewports now — its narrow rows were opted out
precisely because the inspector used to disappear there.

The E2E journey follows one action end to end (select → inspector switch
→ main process → the row's own state) rather than sampling all seven,
and the panel unit test drops its assertions on Astryx's own `<label>`
markup, which is the vendor's DOM detail, not this page's contract.

* refactor(ui): drop the grouped-row display model

`planReminderDisplayRows` was kept unexported with a note to re-export it
"if/when a panel adopts it". The panel has now adopted the other model —
a flat Astryx List — so the question is settled and the second display
model is dead weight with a comment explaining why it survives.

* fix(automations): return focus after the inspector's own surfaces close

A second review round, run against the live app, measured
`document.activeElement` after each dismissal and found two places where
it lands on `body`, dropping a keyboard user at the top of the document.

Deleting from the inspector took the 删除 button, the inspector and the
row down together with nothing to catch focus. `main` had this covered —
it refocused the row's menu trigger after every menu action — and the
rebuild lost it with the menus. Focus now goes to the row that takes the
deleted one's place.

The narrow-window inspector dialog restored nothing either, because it
unmounted instead of closing: Astryx returns focus on the open→closed
transition, so a dialog that disappears never gets there. It stays
mounted for the whole narrow session and opens on `isOpen` now.

Crossing the 1024px breakpoint with a row selected also used to open that
dialog on its own — a modal appearing over the list, taking focus, with
no user action behind it. Crossing now clears the selection, so placement
only ever changes on the user's next click.

Two more from the same round. An exceptional lifecycle state (暂停 /
已完成) is row TEXT again, not only the dot's colour: the dot sits outside
the row's button, so tabbing a row announced no state at all, and colour
alone fails WCAG 1.4.1. 待触发 stays silent — naming the normal case on
every row is the noise this list exists to avoid.

And the field-marker suppression is scoped to this form. Unscoped, it
reached every `isRequired` field in the app — the provider add form, the
MCP page, the bot login — and silently removed their only visible
required affordance, which is not this change's to remove.

* fix(automations): focus a row after a delete, and pin both focus contracts

The delete-focus fix the previous commit describes is here, not there: an
external tool restored `plan-reminder-panel.tsx` from a stale copy while
that commit was being staged, and took the change with it. The 删除
button unmounts with the whole inspector, so focus fell to `body`;
it goes to the row that takes the deleted one's place, one frame later
so the closing confirm dialog's own restore cannot overwrite it.

Both of this round's focus regressions were invisible to the suite. The
delete journey asserted the row was gone and the inspector hidden, and
said nothing about where the keyboard went. The narrow window had no
journey at all — its Storybook check proves the inspector exists there,
not that dismissing it hands focus back or that a resize does not open it
unbidden.

Three assertions now cover exactly the failures that were measured:
focus lands on a row after a delete, crossing the breakpoint with a
selection opens nothing, and Escape on the narrow sheet returns focus to
the row that opened it.

* refactor(automations): drop the scoped required-marker suppression

The reminder form hid Astryx's required/optional marker in CSS because the
vendor hard-coded it as the English words `Required` / `Optional` with no
message id, and an English word inside a Chinese form was worse than no
marker at all. That rule had to be scoped to this one form: unscoped, it
stripped the visible required affordance from the provider add flow, the MCP
page and the bot login too.

#2184 localized the marker at the source, so the premise is gone. Delete the
rule, its stylesheet and its import, and let the form show the same marker as
every other form in the app. `isRequired` stays where it is — it is still the
only route to `aria-required`.

* fix(automations): keep the page usable at the 480px window floor

Two things on this page stopped working long before the app's own window
floor (SAFE_MIN_WIDTH = 480), both because a hand-rolled layout was replaced
by a vendor composition that does not wrap.

The header's title row is an Astryx `HStack` whose title column carries
`StackItem`'s own `min-width: 0`. On a nowrap row the actions keep their
width and the title absorbs the loss: measured 80×28 at 640px, 27×112 at
520px, and 20×112 at 480px — 定时任务 running one glyph per line down a
352px header. Let the row wrap and the actions take their own line instead.

The control bar's search / sort / filter controls carry fixed widths that
together need 618px, inside a Toolbar that is also a nowrap flex row. They
never shrink — measured identical from 1280px down to 480px — so past the
column's width they simply left the page: `elementFromPoint` puts the state
filter outside the plate at 640px and the sort control too at 480px, with no
scroll to bring either back. Wrap the toolbar, and below 560px give up the
tuned widths so each control takes the row.

Both contracts live in the e2e suite, which runs the real window. The
Storybook page stories render without the app shell, so their column is
content-sized: the 900px clamp assertion there passed regardless of the
clamp, and a bar-containment check added there could not fail even with the
wrap removed. Move the clamp assertion to e2e and drop the vacuous one.

The list controls only appear at eight reminders, so both the fixture and the
reference story gain four more — seeded older than the existing four, which
keeps the ordering the sort test pins.
jackwener added a commit that referenced this pull request Aug 5, 2026
* fix(plan): a failed delivery reads on the task row again

The row reported only the reminder's own lifecycle, so a reminder whose last
delivery failed rendered exactly like a healthy one — accent dot, no text —
and the only way to find a broken task was to open every entry in turn. That
is the opposite of what a monitoring list is for.

A failed or blocked last run now takes the row's dot tone and leads its
description as text. Lifecycle and delivery are two different questions ("is
it still on?" vs "did it work?"), and the second one is what this page is
scanned for. `triggered` stays quiet: it is a plain "it ran" record, not a
health signal.

The old assertion pinned the regression in place (doesNotMatch /失败/), so it
is replaced rather than deleted — but only the half that was wrong. The run's
MESSAGE still belongs to the inspector and 执行记录, never repeated on every
row, and that half is now asserted separately so the distinction cannot be
lost again.

* fix(plan): the inspector gets a surface, and the one-tap times come back

Two of the regressions from the tracker rebuild.

The inspector claimed a tonal separation it never carried. module-page.tsx
says this app separates columns tonally rather than with hairlines, but the
panel painted nothing and inherited the content column's white, so the two
columns were separated by neither tone nor line and the inspector read as
loose content on the page (owner msg `1158e599`).

It takes `--muted` rather than `--color-background-surface`: measured, that
token resolves to the same pure white as `--background` here, so it would have
been a no-op against a white column. The rule is scoped to the module page so
it cannot reach the session panel on the left, which is a different Layout
panel with its own canvas tone. The primitive's comment now describes what the
page does instead of what it intended.

Address is deliberately unchanged. The panel stays inside the 900px column
that every main page here is clamped to, which is what keeps the title, the
rows and the inspector on one left edge.

The quick-time presets return under the picker. The picker can express any
instant, which is why it stays, but reaching "in an hour" through a calendar
costs a date, an hour and a minute to say something the user already knew.
They write only the time field, so choosing 明天 9 点 on a weekly reminder
moves the next occurrence without silently making it one-off.

planReminderPresetRunAt comes back with the tests it never had — nothing
referenced it, which is what made deleting it look free. They pin the boundary
the modulo exists for: asking for "next Monday" on a Monday means the
following week, not the Monday already underway. Fault injection confirms
removing the `|| 7` fails them.

* fix(plan): open groups in the form, and the row's third tier back

The form asks three different questions — what to say, when to fire, where to
send — and the rebuild flattened them into eight undifferentiated fields, so
the reader re-derives that structure from the labels every time. Grouping is
information architecture, so it comes back.

The two grey inset cards do not. Those were drawn for a non-modal aside beside
the page, where a group needed a card to cut itself out of its surroundings.
Inside a Dialog the surface is already the group's own, and a filled card on it
is the card-in-a-card this app's settings rules forbid. A label and the
layout's own spacing carry the structure on one surface — the open-group idiom
from settings, translated into the dialog.

Also: the countdown returns to its own tone. `color="secondary"` had put it on
the description's level, so the row read as two equal lines plus a trailing
value of the same weight, and the tier that let "when" be scanned without
competing with "what" was gone.

And the delete action takes `variant="destructive"` instead of an inline
`--destructive-text` style, so its disabled and hover states come from the
component rather than from a colour that only ever applied to rest.

* chore(plan): drop a comment that described no rule

It sat above the shell-layout.css import claiming to suppress the
plan-reminder form's untranslatable required marker. shell-layout.css carries
no required-marker rule of any kind, and since #2184 the marker is localized
by the vendor patch rather than suppressed by CSS — so the comment pointed a
reader at a mechanism that does not exist, on an import that does something
else entirely.
Sign up for free to 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