Просмотр исходного кода

fix(web): give the protocol dropdowns the shared chevron

`select.input` caps the control at 240px, and a `<select>` carrying only
`.input` keeps the OS arrow, which paints flush inside that shrunk right
edge — visibly tighter than every other control on the page. Both
protocol dropdowns took `.input` alone; the provider select beside them
already added `.selectInput`, which removes the OS arrow, reserves the
right pad, and paints the shared 12px chevron.

The stylesheet spec now reads the package's own sources and fails any
`<select>` that takes `.input` without `.selectInput`, so the next one
cannot be added bare.
Yichen Jiang 1 месяц назад
Родитель
Сommit
23995f88ce

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md
-2026-08-04-declaring-a-provider-from-the-models-page.md: 3c63254364e3803e9bddab2a5aad4c092e4f0426
-2026-08-04-declaring-a-provider-from-the-models-page.zh.md: c38b0387b3c7778b93fb43232c22a7acb24dc281
+2026-08-04-declaring-a-provider-from-the-models-page.md: dc0f6826be4c878c0ae5ffbca076f2b3037f1182
+2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 5f25eff196869008d4e0d58776283c70de30e26a

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md

@@ -48,4 +48,4 @@ What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is th
 
 ## Testing
 
-`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename.
+`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The stylesheet gate reads the package's own sources and fails any `<select>` that takes `.input` without `.selectInput`, because the OS arrow it would otherwise keep sits flush inside the 240px cap `select.input` imposes. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename.

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md

@@ -48,4 +48,4 @@ Status: implemented
 
 ## Testing
 
-`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。
+`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。样式 gate 读取本包自己的源码,任何只取 `.input` 而不取 `.selectInput` 的 `<select>` 都会失败——否则它保留的系统箭头会紧贴 `select.input` 所设 240px 上限的右边缘。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。

+ 1 - 1
packages/client/ui-models/src/client/CustomProviderCard.tsx

@@ -236,7 +236,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
       <div className={styles['field']}>
         <span className={styles['fieldLabel']}>{t('customApi')}</span>
         <select
-          className={styles['input']}
+          className={`${styles['input']} ${styles['selectInput']}`}
           value={protocol}
           aria-label={t('customApi')}
           disabled={profileDisabled}

+ 1 - 1
packages/client/ui-models/src/client/ProviderEditor.tsx

@@ -396,7 +396,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                 <div className={styles['field']}>
                   <span className={styles['fieldLabel']}>{t('customApi')}</span>
                   <select
-                    className={styles['input']}
+                    className={`${styles['input']} ${styles['selectInput']}`}
                     value={probeApi ?? ''}
                     aria-label={t('customApi')}
                     disabled={disabled}

+ 23 - 0
packages/client/ui-models/tests/styles.spec.ts

@@ -61,6 +61,29 @@ describe('ModelsSection theme styles', () => {
     expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
   })
 
+  it('gives every dropdown the shared chevron instead of the OS arrow', () => {
+    // `select.input` caps the control at 240px, and the OS arrow is painted
+    // flush inside that shrunk right edge — visibly tighter than every other
+    // control on the page. `.selectInput` is what removes it, reserves the
+    // right pad, and paints the shared chevron; a `<select>` that takes
+    // `.input` alone silently keeps the OS one.
+    const sources = readdirSync(fileURLToPath(new URL('../src/client/', import.meta.url)))
+      .filter(name => name.endsWith('.tsx'))
+      .map(name => ({
+        name,
+        text: readFileSync(fileURLToPath(new URL(`../src/client/${name}`, import.meta.url)), 'utf8'),
+      }))
+    const bare = sources.flatMap(({ name, text }) => text
+      .split('<select')
+      .slice(1)
+      // The element's own attributes end at the first `>`; a child `<option>`
+      // carries no className of its own and must not answer for the select.
+      .map(rest => rest.slice(0, rest.indexOf('>')))
+      .filter(attributes => !attributes.includes('selectInput'))
+      .map(() => name))
+    expect(bare).toEqual([])
+  })
+
   it('never falls back to a literal colour', () => {
     // A token that resolves is never the problem; an undeclared one takes this
     // branch, and a literal here is a single colour for both themes.