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.
This commit is contained in:
Yichen Jiang
2026-08-10 23:18:26 +08:00
parent 72344fce93
commit 23995f88ce
6 changed files with 29 additions and 6 deletions

View File

@@ -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}

View File

@@ -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}

View File

@@ -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.