fix(ui): localize the field required/optional marker app-wide - #2184
Merged
Conversation
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
marked this pull request as ready for review
August 4, 2026 17:54
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Astryx's
FieldLabelrenders a field's required/optional marker as the literal English words'Required'/'Optional'. There is no message id, soAstryxLocaleProvider(packages/ui/src/astryx-i18n.tsx) cannot reach them. The consequence: every form that passesisRequired/isOptionalprints 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.patch—FieldLabelresolves@astryx.field.required/@astryx.field.optionalthroughuseTranslator(), andlocales/en.jsongains both keys.enstill rendersRequired/Optional, now through the shipped catalog instead of a hard-coded string.packages/ui/src/astryx-i18n.tsx— thezhvalues come fromgetSharedUiCopy(...).formControls, the same place every other Astryx override reads from.aria-requiredis untouched. That is precisely why the marker is translated rather than hidden:isRequiredis the only prop that writesaria-requiredonto 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 anaria-hiddenseparator", which is not a contract. That is exactly why the rule inapps/desktop/src/renderer/styles/astryx-field.csson #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.tsused to hand-rollAPI Key(必填)into the label, which stacked with the component's own marker and rendered asAPI Key(必填) ∙ Required— two markers, one of them untranslated. The marker now comes only from the component;PasswordInputhangs it on the visible group label while the inner input keepsisRequiredfor the semantics.Refs #2155
Review focus: the relationship to #2155
astryx-field.cssexists only on #2155's branch — it is not onmain— so this PR cannot delete it. #2155 should deleteapps/desktop/src/renderer/styles/astryx-field.cssand 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.mjsscans 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— cleannpm --workspace @maka/desktop run typecheck(four tsconfigs) — passnpm --workspace @maka/ui run test— 324 passnpm --workspace @maka/desktop run test— 1664 passnpm run test:scripts— 110 passnode scripts/check-dead-css.mjs --check— passnpm --workspace @maka/desktop run smoke:storybook— 68 manifest checks + 82 catalog renders passe2e_shardjobsPlaywright, and why it was run deliberately
This PR changes accessible names (
API Key(必填)→API Key ∙ 必填), andproviders.spec.tsselects on them at lines 77 and 263 viagetByRole('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:71times 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:
FieldLabel.jshunk (back to the upstream hard-coded line)zhtests go red, the rest green@astryx.field.requiredfromen.jsonentest goes red@astryx.field.optionalfromen.jsonentest goes redWith 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:product-module-hubs--extensions-mcp-configured→ 添加 MCP:External review
Pi /
opencode-go/deepseek-v4-flash(effortmax) reviewed the branch: no P0/P1/P2, three P3s. After verifying each independently:enguard pinned only the required marker. Deleting just@astryx.field.optionalleft all six tests green whileenleaked the raw key. Reproduced, then closed: that scenario now goes red.password-input.tsxclaimed the inner input's label is hidden. It is not rendered at all: aTextInputinside anInputGroupreturns its bare input wrapper and skipsFieldentirely, which is why the marker has to ride on the group label rather than being a second copy of it.PasswordInput'sisOptionalthreading 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.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,AstryxLocaleProvider(packages/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 形状上。#2155 里apps/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—— cleannpm --workspace @maka/desktop run typecheck(四个 tsconfig)—— passnpm --workspace @maka/ui run test—— 324 passnpm --workspace @maka/desktop run test—— 1664 passnpm run test:scripts—— 110 passnode scripts/check-dead-css.mjs --check—— passnpm --workspace @maka/desktop run smoke:storybook—— 68 manifest checks + 82 catalog renders passe2e_shardPlaywright,以及为什么这次是特意跑的
本 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.jshunk(回到上游硬编码那行)zh测试红,其余绿en.json删掉@astryx.field.requireden测试红en.json删掉@astryx.field.optionalen测试红补丁应用后六条全绿。这就是
patches/README.md里记录的删除条件:哪天不打补丁这些测试也能过,就删掉它。实际渲染(Storybook,zh)
见上方英文段落中的输出。
外部 review
Pi /
opencode-go/deepseek-v4-flash(effortmax)做了一轮 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 上,不存在第二份。PasswordInput的isOptional穿线与文案改动没有单元测试。这两个表单在本 PR 之前于 desktop 套件里就是零覆盖;补齐属于新增覆盖层,不在本 PR 范围。provider 表单已由上面的 e2e 走到。aria-required,等于宣告两次」。已核对 vendored 源码:这是 Astryx 对所有独立字段的既有形状(marker 就在给 input 命名的那个元素里)。不移除可见标记就无法改变,而可见标记正是本 PR 的目的。