From 5ffbda61a9e1dcae8bf0a2dd0ee606f91c25ccdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 10 Sep 2026 18:14:05 +0100 Subject: [PATCH] feat(ui): standardize responsive control sizing (#3563) --- .agents/skills/e2e/SKILL.md | 4 +- .agents/skills/mobile-parity/SKILL.md | 6 + .../references/control-sizing.md | 88 ++++ .../references/kandev-mobile-ui-language.md | 2 +- apps/packages/ui/src/button.tsx | 6 +- apps/packages/ui/src/command.tsx | 2 +- apps/packages/ui/src/control-sizing.tsx | 13 + apps/packages/ui/src/input-group.tsx | 3 +- apps/packages/ui/src/input.tsx | 15 +- apps/packages/ui/src/select.tsx | 3 +- apps/web/AGENTS.md | 2 +- .../components/agent-configuration-tab.tsx | 7 +- .../app/office/agents/agents-page-client.tsx | 9 +- .../office/components/new-task-bottom-bar.tsx | 8 +- .../components/new-task-participant-row.tsx | 4 +- .../components/new-task-selector-row.tsx | 6 +- .../app/office/components/new-task-stages.tsx | 2 +- .../office/components/routing/route-panel.tsx | 2 +- .../app/office/inbox/inbox-page-client.tsx | 3 +- .../office/projects/[id]/project-header.tsx | 11 +- .../office/projects/create-project-dialog.tsx | 5 +- .../projects/project-repository-picker.tsx | 8 +- apps/web/app/office/routines/routine-row.tsx | 4 +- apps/web/app/office/setup/close-button.tsx | 6 +- apps/web/app/office/setup/step-agent.tsx | 11 +- .../app/office/setup/step-tier-profiles.tsx | 6 +- .../tasks/[id]/advanced-panels/chat-panel.tsx | 2 +- .../workspace/activity/activity-feed.tsx | 5 +- .../workspace/costs/create-budget-form.tsx | 13 +- .../workspace/org/org-zoom-controls.tsx | 23 +- .../routing/components/role-tier-card.tsx | 8 +- .../settings/export/export-file-tree.tsx | 3 +- .../office/workspace/skills/skill-list.tsx | 22 +- apps/web/app/settings/agents/page.tsx | 14 +- apps/web/app/settings/executor/[id]/page.tsx | 4 +- .../[id]/profile/[profileId]/page.tsx | 10 +- .../executors/k8s/[executorId]/page.tsx | 13 +- .../new/[type]/kubernetes-create-page.tsx | 4 +- .../settings/executors/new/[type]/page.tsx | 4 +- .../executors/new/[type]/ssh-create-page.tsx | 4 +- apps/web/app/settings/executors/page.tsx | 9 +- .../executors/ssh/[executorId]/page.tsx | 4 +- .../workspace-repositories-client.tsx | 3 +- .../workspace-repository-set-editor.tsx | 4 +- .../workspace-repository-sets-section.tsx | 5 +- .../workspace/workspaces-page-client.tsx | 2 +- apps/web/app/tasks/columns.tsx | 8 +- apps/web/app/tasks/tasks-list-controls.tsx | 10 +- apps/web/app/tasks/tasks-list-view.tsx | 6 +- apps/web/app/tasks/tasks-pagination.tsx | 3 +- .../sections/settings/settings-search.tsx | 6 +- .../agent-runtime-unavailable-alert.tsx | 7 +- .../backend-reload-required-alert.tsx | 7 +- .../automations/automations-export-button.tsx | 4 +- .../automations/automations-list-page.tsx | 4 +- .../automations/schedule-selector.tsx | 14 +- .../azure-devops/azure-devops-board.tsx | 8 +- .../azure-devops-default-queries.tsx | 29 +- .../azure-devops-quick-actions.tsx | 22 +- .../azure-devops/azure-devops-settings.tsx | 2 +- .../azure-devops-watch-settings.tsx | 32 +- .../azure-devops-work-item-detail.tsx | 27 +- .../canvas/canvas-task-create-launcher.tsx | 3 +- apps/web/components/combobox.tsx | 11 +- .../config-chat/config-chat-panel.tsx | 2 +- .../create-local-repository-surface.tsx | 10 +- .../github/action-presets-section.tsx | 21 +- .../github/default-queries-section.tsx | 15 +- .../components/github/github-access-help.tsx | 3 +- .../github/github-app-connection-panel.tsx | 8 +- .../github/github-app-create-form.tsx | 9 +- .../github/github-app-import-fields.tsx | 14 +- .../github/github-app-import-form.tsx | 7 +- .../github/github-app-import-guide.tsx | 6 +- .../github/github-app-policy-dialog.tsx | 2 +- .../web/components/github/github-cli-form.tsx | 2 +- .../github/github-connection-dialog.tsx | 6 +- .../github-connection-settings-form.tsx | 2 +- .../web/components/github/github-pat-form.tsx | 4 +- .../github/github-permissions-dialog.tsx | 2 +- .../github/github-repo-scope-section.tsx | 3 +- apps/web/components/github/github-status.tsx | 9 +- .../github/my-github/issue-list.tsx | 2 +- .../github/my-github/list-toolbar.tsx | 2 +- .../github/my-github/save-preset-dialog.tsx | 2 +- .../web/components/github/pr-detail-panel.tsx | 7 +- .../web/components/github/pr-merge-button.tsx | 7 +- .../github/pr-reviews-section.test.tsx | 7 +- .../components/github/pr-reviews-section.tsx | 7 +- .../gitlab/action-presets-section.tsx | 10 +- .../web/components/gitlab/mr-detail-panel.tsx | 34 +- .../gitlab/mr-discussions-section.tsx | 11 +- .../components/gitlab/mr-reviewer-control.tsx | 8 +- .../gitlab/my-gitlab/list-toolbar.tsx | 2 +- .../components/gitlab/subscription-toggle.tsx | 3 +- apps/web/components/gitlab/watch-dialog.tsx | 8 +- apps/web/components/gitlab/watch-settings.tsx | 6 +- .../integrations/auth-error-message.tsx | 2 +- .../change-request-detail-copy-button.tsx | 4 +- .../change-request-detail-header.tsx | 13 +- .../change-request-detail.test.tsx | 9 +- .../integrations/change-request-detail.tsx | 2 +- .../integration-cursor-pagination.tsx | 10 +- .../integrations/integration-list-toolbar.tsx | 5 +- .../integration-save-query-dialog.tsx | 14 +- .../integration-start-task-menu.tsx | 3 +- .../saved-query-default-button.tsx | 3 +- .../integrations/validated-popover.tsx | 8 +- apps/web/components/jira/jira-action-bar.tsx | 2 +- .../web/components/jira/jira-oauth-fields.tsx | 8 +- .../components/jira/my-jira/list-toolbar.tsx | 32 +- .../components/jira/my-jira/ticket-row.tsx | 2 +- .../components/jira/task-presets-section.tsx | 28 +- .../web/components/linear/linear-settings.tsx | 2 +- .../review/review-dialog-pr-state.tsx | 2 +- .../sentry/sentry-instance-card.tsx | 4 +- .../components/sentry/sentry-issue-dialog.tsx | 8 +- .../settings/agent-profile-delete-dialog.tsx | 7 +- .../settings/agent-profile-page.tsx | 3 +- .../settings/agent-runtime-update-control.tsx | 2 +- .../agents/agent-profiles-section.tsx | 5 +- .../settings/canvas-host-components.tsx | 3 +- .../settings/canvas-lifecycle-dialogs.tsx | 17 +- .../components/settings/cli-flags-field.tsx | 2 +- .../settings/copy-move-dialog-body.tsx | 14 +- .../settings/dynamic-agent-candidate-list.tsx | 15 +- .../settings/dynamic-agent-policy-editor.tsx | 13 +- .../settings/dynamic-agents-card.tsx | 4 +- .../settings/executor-profiles-card.tsx | 10 +- .../settings/installed-agent-card.tsx | 7 +- .../settings/keyboard-shortcuts-card.tsx | 21 +- .../layouts/layout-editor-toolbar.tsx | 9 +- .../layout-profile-delete-confirmation.tsx | 5 +- .../settings/layouts/layout-profile-list.tsx | 4 +- .../settings/layouts/layout-settings.tsx | 12 +- .../model-config-resolution-status.tsx | 4 +- ...ifications-settings-external-providers.tsx | 5 +- .../plugins/marketplace-sources-dialog.tsx | 9 +- .../settings/plugins/plugin-detail.tsx | 16 +- .../settings/plugins/plugin-row.tsx | 26 +- .../settings/profile-edit/env-vars-card.tsx | 2 +- .../profile-edit/inline-secret-select.tsx | 2 +- .../profile-edit/portable-config-bundles.tsx | 3 +- .../profile-connection-settings-action.tsx | 4 +- .../profile-edit-page-chrome.test.tsx | 3 +- .../profile-edit/profile-edit-page-chrome.tsx | 15 +- .../settings/profile-enabled-help.tsx | 4 +- .../settings/profile-status-panels.tsx | 2 +- .../settings/prompt-row-actions.tsx | 4 +- .../settings/repository-branch-policies.tsx | 4 +- .../repository-branch-policy-fields.tsx | 8 +- .../settings/repository-secret-bindings.tsx | 13 +- .../settings/runtime-version-picker.tsx | 11 +- .../settings/secrets-delete-dialog.tsx | 3 +- .../settings/secrets-list-item-row.test.tsx | 5 +- .../settings/secrets-list-item-row.tsx | 8 +- .../components/settings/settings-control.ts | 9 +- .../settings/settings-floating-save.tsx | 84 +++- .../settings/settings-typography.test.ts | 7 +- .../settings/settings-typography.tsx | 2 +- .../settings/sleep-inhibition-settings.tsx | 5 +- .../settings/system/bundle-customizer.tsx | 8 +- .../settings/system/feature-toggle-card.tsx | 4 +- .../system/feature-toggles-settings.tsx | 3 +- .../components/settings/system/log-viewer.tsx | 2 +- .../system/message-queue-settings.tsx | 12 +- .../system/storage/storage-action-button.tsx | 9 +- .../system/storage/storage-adoption-field.tsx | 3 +- .../storage/storage-confirmation-dialogs.tsx | 16 +- .../system/storage/storage-policy-fields.tsx | 2 +- .../settings/units/unit-members-dialog.tsx | 14 +- .../settings/workflow-cycle-diagnostic.tsx | 4 +- .../workflow-pipeline-editor-panels.tsx | 12 +- .../workflow-pipeline-editor-step-actions.tsx | 13 +- .../workflow-pipeline-editor-wip-controls.tsx | 5 +- .../settings/workflow-pipeline-editor.tsx | 6 +- .../settings/workflow-section-actions.tsx | 7 +- .../workflow-session-config-carry-warning.tsx | 7 +- .../workflow-session-config-editor.tsx | 3 +- .../workflow-session-config-rule-card.tsx | 11 +- .../settings/workflow-sync-section.tsx | 3 +- .../settings/workspace-canvases-page.tsx | 31 +- .../workspaces/workspace-placement-card.tsx | 3 +- .../workspaces/workspace-settings-shell.tsx | 2 +- .../system-health/health-indicator.tsx | 3 +- apps/web/components/task-autopilot-toggle.tsx | 10 +- .../components/task-create-dialog-footer.tsx | 14 +- .../task-create-dialog-priority-select.tsx | 9 +- ...sk-create-dialog-remote-repo-chip.test.tsx | 6 +- .../task-create-dialog-remote-repo-chip.tsx | 3 +- apps/web/components/task-preview-panel.tsx | 2 +- apps/web/components/task/browser-panel.tsx | 1 + .../task/chat/dynamic-route-recovery.tsx | 6 +- .../chat/messages/action-message-actions.tsx | 165 ++++--- .../chat/messages/action-message-recovery.tsx | 7 +- .../chat/messages/action-message.test.tsx | 13 +- .../kandev/rich-output/chart-block.tsx | 3 +- .../kandev/rich-output/file-preview-block.tsx | 6 +- .../task/chat/session-stopped-banner.tsx | 12 +- .../task/commit-detail-panel.test.tsx | 6 +- .../components/task/commit-detail-panel.tsx | 3 +- .../task/file-browser-search-header.tsx | 1 + .../web/components/task/file-context-menu.tsx | 1 + .../web/components/task/inline-file-input.tsx | 1 + apps/web/components/task/remediation-link.tsx | 4 +- .../components/task/session-dialog-shared.tsx | 2 +- .../sidebar-filter/filter-multi-select.tsx | 3 +- .../task/sidebar-filter/group-picker.tsx | 2 +- .../sidebar-filter/sort-picker-primitive.tsx | 5 +- .../typed-filter-clause-editor.tsx | 26 +- .../task/sidebar-filter/view-manager.tsx | 1 + .../components/task/simple/task-documents.tsx | 2 +- apps/web/components/task/task-top-bar.tsx | 2 +- .../task/workflow-step-disclosure.tsx | 4 +- .../components/ui/data-table-pagination.tsx | 2 +- .../watcher-repository-fields.test.tsx | 4 +- apps/web/components/workflow-selector-row.tsx | 5 +- .../workspaces/workspace-picker-content.tsx | 4 +- apps/web/e2e/helpers/control-sizing.ts | 41 ++ .../e2e/tests/github/github-scope-bar.spec.ts | 17 +- .../integration-remove-confirmations.spec.ts | 3 + ...e-integration-remove-confirmations.spec.ts | 2 + .../e2e/tests/layout/control-sizing.spec.ts | 43 ++ .../layout/mobile-control-sizing.spec.ts | 36 ++ .../settings/agent-runtime-update.spec.ts | 7 +- .../e2e/tests/settings/control-sizing.spec.ts | 59 +++ .../settings/mobile-control-sizing.spec.ts | 49 ++ .../mobile-plugin-marketplace-sources.spec.ts | 2 +- .../settings/mobile-secrets-delete.spec.ts | 4 +- .../tests/settings/settings-discovery.spec.ts | 9 +- .../web/e2e/tests/task/control-sizing.spec.ts | 68 +++ apps/web/e2e/tests/task/create-task.spec.ts | 11 +- .../tests/task/mobile-control-sizing.spec.ts | 68 +++ .../mobile-workflow-cycle-guardrails.spec.ts | 21 +- apps/web/lib/ui/control-sizing.test.tsx | 41 ++ apps/web/lib/utils/selector-options.ts | 5 +- docs/plans/control-sizing/plan.md | 137 ++++++ docs/plans/control-sizing/sweep-inventory.md | 441 ++++++++++++++++++ .../control-sizing/task-01-shared-sizes.md | 124 +++++ .../control-sizing/task-02-task-controls.md | 119 +++++ .../task-03-settings-controls.md | 122 +++++ .../task-04-application-controls.md | 124 +++++ docs/specs/ui/README.md | 2 + docs/specs/ui/requirements/control-sizing.md | 80 ++++ docs/specs/ui/system-design/control-sizing.md | 125 +++++ 245 files changed, 2862 insertions(+), 824 deletions(-) create mode 100644 .agents/skills/mobile-parity/references/control-sizing.md create mode 100644 apps/packages/ui/src/control-sizing.tsx create mode 100644 apps/web/e2e/helpers/control-sizing.ts create mode 100644 apps/web/e2e/tests/layout/control-sizing.spec.ts create mode 100644 apps/web/e2e/tests/layout/mobile-control-sizing.spec.ts create mode 100644 apps/web/e2e/tests/settings/control-sizing.spec.ts create mode 100644 apps/web/e2e/tests/settings/mobile-control-sizing.spec.ts create mode 100644 apps/web/e2e/tests/task/control-sizing.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-control-sizing.spec.ts create mode 100644 apps/web/lib/ui/control-sizing.test.tsx create mode 100644 docs/plans/control-sizing/plan.md create mode 100644 docs/plans/control-sizing/sweep-inventory.md create mode 100644 docs/plans/control-sizing/task-01-shared-sizes.md create mode 100644 docs/plans/control-sizing/task-02-task-controls.md create mode 100644 docs/plans/control-sizing/task-03-settings-controls.md create mode 100644 docs/plans/control-sizing/task-04-application-controls.md create mode 100644 docs/specs/ui/requirements/control-sizing.md create mode 100644 docs/specs/ui/system-design/control-sizing.md diff --git a/.agents/skills/e2e/SKILL.md b/.agents/skills/e2e/SKILL.md index db172429d6..a6df3744f5 100644 --- a/.agents/skills/e2e/SKILL.md +++ b/.agents/skills/e2e/SKILL.md @@ -169,7 +169,9 @@ expect(actionsBox).not.toBeNull(); expect(metricsBox!.height).toBeCloseTo(actionsBox!.height, 1); ``` -Run the assertion in the relevant desktop and mobile projects when responsive layout can change the result. Do not rely on fixed pixels when the product contract is equality or alignment. +Run the assertion in the relevant desktop and mobile projects when responsive +layout can change the result. Do not rely on fixed pixels when the product +contract is equality or alignment. For control-size regressions, read the mobile-parity [sizing contract](../mobile-parity/references/control-sizing.md). For computed colors, parse alpha/opacity semantically or assert a deliberate class/data contract; do not compare serialized `getComputedStyle` strings because browsers may return `rgba()`, `oklab()`, or `color()`. **Animation-aware geometry:** Before reading dialog or panel geometry, wait only for currently running Web Animations with finite `effect.getComputedTiming().iterations`; await `animation.finished.catch(() => undefined)` because Radix overlays can cancel animations during close or replacement. Never blanket-await infinite animations or use a fixed sleep; then read bounding boxes and assert the relationship. diff --git a/.agents/skills/mobile-parity/SKILL.md b/.agents/skills/mobile-parity/SKILL.md index 068930514b..720f9e5674 100644 --- a/.agents/skills/mobile-parity/SKILL.md +++ b/.agents/skills/mobile-parity/SKILL.md @@ -13,6 +13,10 @@ Use this skill before planning or changing UI. Goal: desktop and mobile deliver Before proposing a mobile design, read [Kandev Mobile UI Language](references/kandev-mobile-ui-language.md) and inspect the closest shipped mobile surface. Responsive CSS alone does not establish mobile parity. +For buttons, single-line inputs, selectors, or control-size audits, also read +[Control sizing](references/control-sizing.md). It defines desktop sizes, +touch exceptions, and the sweep procedure. + ## When It Applies Apply when task changes user-facing UI: @@ -70,6 +74,8 @@ rendered phone surface with the assigned preview's structure and annotations. 3. Implement responsive UI. - Reuse domain hooks, state, view-model derivation, filtering/selection, and action handlers across viewports; keep responsive wrappers focused on presentation. Branch composition when the desktop interaction model depends on width or a fine pointer. Do not mount a heavyweight desktop workbench and merely hide or squeeze it on phones. - Use `useResponsiveBreakpoint` and existing `@kandev/ui` or mobile primitives. Reuse current Dropdown/ContextMenu primitives for contextual actions; use `Drawer` or an existing picker shell for structured phone navigation and choices. Use `useTouchDrawer` when a hover disclosure needs a coarse-pointer alternative. + - Use 28px for ordinary desktop buttons, single-line inputs, select triggers, and combobox triggers. Use 24px only for deliberate compact inline controls. Match the desktop Start Task dialog and shared default primitives. + - Keep phone and coarse-pointer action targets at least 44px. Scope touch dimensions to those conditions. A desktop-visible action must not inherit an unconditional `h-11` or `min-h-11`. Check both height and minimum height: changing `h-*` does not remove an oversized `min-h-*`. - A Radix Tooltip that happens to open after Playwright `.tap()` is not a coarse-pointer alternative. Use `useTouchDrawer` with a Drawer branch and assert the drawer surface in mobile E2E. - Keep coarse-pointer and mobile touch targets large enough for touch use, generally at least 44px in the active dimension. This is an active hit-area rule, not a universal desktop visual-size rule: fine-pointer desktop controls should retain the surrounding design-system density, and touch-sized classes such as `h-11` must not become the shared desktop button size. - When one component serves both pointer modes, keep its fine-pointer size as the base class and add the 44px size only behind a coarse-pointer or phone variant. An unqualified `h-11` or `min-h-11` on a desktop-visible shared action is a sizing bug. diff --git a/.agents/skills/mobile-parity/references/control-sizing.md b/.agents/skills/mobile-parity/references/control-sizing.md new file mode 100644 index 0000000000..6777003cf0 --- /dev/null +++ b/.agents/skills/mobile-parity/references/control-sizing.md @@ -0,0 +1,88 @@ +# Control sizing + +Use this guide when you add, change, or audit buttons, single-line inputs, +select triggers, combobox triggers, or their shared wrappers. + +## Size contract + +| Context | Control height | Usage | +| --- | --- | --- | +| Desktop, fine pointer | 28px | Ordinary actions and single-line fields | +| Compact desktop, fine pointer | 24px | Deliberate inline actions in dense chrome | +| Phone or coarse pointer | At least 44px | Action hit areas and single-line fields | + +The pixel values assume the standard 16px root font. Preserve root-font scaling. +The desktop Start Task dialog is the reference for ordinary action density. +The default `@kandev/ui` Button, Input, and SelectTrigger already use `h-7`. +An action's importance changes its variant, not its height. + +Use the same size for neighboring actions, inputs, and selectors within one row. +Use matching square dimensions for standalone icon controls. +Retain existing specialized controls, such as editor chrome and mobile pills, +only with a named purpose and an adjacent precedent. + +Selection cards, navigation rows, multiline fields, and menu options have content +geometry. Do not force their entire surface to the ordinary button height. +Their embedded ordinary buttons still follow the control contract. + +## Shared implementation + +Use existing shared primitives and sizing helpers before adding local classes. +Inspect the current implementation before referencing a new helper from a plan. +Keep the fine-pointer desktop size as the base value. +Apply larger hit areas through a phone or coarse-pointer condition. +Use the canonical 768px phone boundary for new shared sizing rules. +Preserve existing overlay boundaries unless the task changes their composition. + +Do not add a global CSS rule that assigns one height to every HTML button. +Do not change existing compact or large variant meanings to repair individual callers. +Use the standard variant at ordinary action call sites. +Keep single-line fields aligned with their attached reveal, clear, and picker buttons. + +Check `height`, `min-height`, `max-height`, padding, line height, and parent stretching together. +An `h-7` button with `min-h-11` still occupies at least 44px. +A helper with `md:min-h-7` does not shrink a caller's explicit `h-11`. +SelectTrigger can also carry a `data-[size=default]:h-*` rule. +Verify the CSS cascade in the browser after changing these combinations. + +## Sweep procedure + +1. Search shared primitives, application components, route files, and CSS for height overrides. +2. Inspect ordinary Buttons, raw buttons, Inputs, SelectTriggers, combobox wrappers, and inline height styles. +3. Follow named class constants, wrapper helpers, size props, and responsive branches to their callers. +4. Classify each candidate as standard, compact, touch-only, content-sized, or a documented exception. +5. Replace ordinary desktop overrides with the standard size. +6. Preserve touch minimums and existing interaction behavior. +7. Record each retained exception and its reason in the sweep inventory. +8. Repeat the search after migration to find missed wrappers and newly exposed controls. + +This search produces candidates, not confirmed defects: + +```bash +rg -n '(min-h-|max-h-|h-|size-)(8|9|10|11|12|\[)|size="(sm|lg)"|height\s*:' \ + apps/web/components apps/web/app apps/packages/ui/src \ + -g '*.tsx' -g '*.ts' -g '*.css' -g '!*.test.*' +``` + +Also inspect controls with padding-only sizing and responsive height resets. +Do not apply a blanket replacement to this search output. + +## Rendered verification + +Verify representative dialog, task, settings, and shared-picker surfaces. +For each changed family, include normal, disabled, busy, and long-label states. +Compare the actual controls, not only their containers or class strings. + +At the standard root font, ordinary desktop controls must measure 28px within 1px. +Compact desktop controls must measure 24px within 1px. +A desktop assertion of only `height >= 28` permits oversized controls to pass. +Touch actions must retain their 44px minimum active dimension. +Standalone touch icons need both height and width checks. + +Include desktop fine pointers, phone widths below 768px, and coarse-pointer tablets. +Verify keyboard activation, touch activation, and the absence of overlapping hit areas. +Preserve the existing coarse-pointer input anti-zoom rule. +Check translated labels without reducing their text size to fit a fixed height. + +The durable contract is in +[Control sizing requirements](../../../../docs/specs/ui/requirements/control-sizing.md). diff --git a/.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md b/.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md index f447114007..aa3bdecf75 100644 --- a/.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md +++ b/.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md @@ -35,7 +35,7 @@ Choose based on task frequency and content depth. A frequently revisited primary - Phone breakpoint is below 768px, matching the width at which the desktop sidebar appears. Use `useResponsiveBreakpoint`; it distinguishes phone, tablet, compact desktop, and full desktop using width plus pointer mode. Tablet is a coarse-pointer fallback between 768px and 1024px. The bottom-sheet treatment for Radix menus in `app/globals.css` is deliberately narrower at 640px — pick the boundary that matches what you are changing. - Reuse `@kandev/ui` primitives. `Drawer` is the standard bottom surface; `Sheet` remains useful for tablet/desktop side panels. - Use inset, rounded card surfaces with restrained borders/backgrounds and shadow, matching nearby components. Prefer existing component classes over copying a frozen class list. -- Give new primary touch actions, standalone icon buttons, and menu rows an actual hitbox of at least 44px in the active dimension (`min-h-11`, `h-11 w-11`). Existing compact chrome such as `MobilePillButton` is a deliberate density exception, not a sizing precedent for unrelated controls; do not claim surrounding spacing enlarges its hitbox. Provide pressed feedback such as the existing short `active:scale-*` treatment where nearby controls use it. +- Give phone and coarse-pointer primary actions, standalone icon buttons, and menu rows an actual hitbox of at least 44px. Apply these dimensions only within the touch condition. Ordinary desktop controls use 28px. See [Control sizing](control-sizing.md) for compact controls, shared components, and geometry checks. Existing compact chrome such as `MobilePillButton` remains a documented exception. Surrounding spacing does not enlarge a hitbox. Preserve nearby pressed feedback. - Keep labels and current context visible. Use truncation for long names, tabular number badges for counts, and accessible names that include enough hierarchy to disambiguate the action. - Use `100dvh`/`h-dvh`, not `100vh`, for viewport-filling phone layouts. Account for `env(safe-area-inset-*)` on fixed controls and drawer content. - In flex surfaces, the usual scroll body is `min-h-0 flex-1 overflow-y-auto overscroll-contain`. Keep headers/actions fixed and avoid nested vertical scrollers unless the interaction truly needs them. diff --git a/apps/packages/ui/src/button.tsx b/apps/packages/ui/src/button.tsx index b9438bbd62..b874c88500 100644 --- a/apps/packages/ui/src/button.tsx +++ b/apps/packages/ui/src/button.tsx @@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; import { cn } from "./lib/utils"; +import { controlSizingClassName } from "./control-sizing"; const buttonVariants = cva( "focus-visible:border-ring focus-visible:ring-ring/35 aria-invalid:border-destructive aria-invalid:ring-destructive/25 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex cursor-pointer items-center justify-center whitespace-nowrap transition-colors disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", @@ -21,12 +22,11 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { - default: - "h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + default: `${controlSizingClassName("standard")} gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5`, xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5", sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4", - icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5", + icon: `${controlSizingClassName("icon")} [&_svg:not([class*='size-'])]:size-3.5`, "icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5", "icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3", "icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4", diff --git a/apps/packages/ui/src/command.tsx b/apps/packages/ui/src/command.tsx index 130c3c5af0..3b44ae1f54 100644 --- a/apps/packages/ui/src/command.tsx +++ b/apps/packages/ui/src/command.tsx @@ -71,7 +71,7 @@ function CommandInput({ }: React.ComponentProps) { return (
- + ) { data-slot="input-group" role="group" className={cn( - "border-input bg-input/20 dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-7 rounded-md border transition-colors has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:ring-[2px] has-[[data-slot][aria-invalid=true]]:ring-[2px] has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto", + `border-input bg-input/20 dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 ${controlSizingClassName("standard")} rounded-md border transition-colors has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:ring-[2px] has-[[data-slot][aria-invalid=true]]:ring-[2px] has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto`, className, )} {...props} diff --git a/apps/packages/ui/src/input.tsx b/apps/packages/ui/src/input.tsx index a4292f684b..6148e92b5b 100644 --- a/apps/packages/ui/src/input.tsx +++ b/apps/packages/ui/src/input.tsx @@ -1,15 +1,24 @@ import * as React from "react"; +import { controlSizingClassName } from "./control-sizing"; import { cn } from "./lib/utils"; -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => ( +export type InputControlSize = "standard" | "compact" | "none"; + +type InputProps = React.ComponentProps<"input"> & { + /** Keep a fixed caller-provided height for embedded editor chrome. */ + controlSize?: InputControlSize; +}; + +const Input = React.forwardRef( + ({ className, type, controlSize = "standard", ...props }, ref) => ( @@ -400,7 +401,9 @@ function IdentityCard({
onSearchChange(e.target.value)} /> diff --git a/apps/web/app/office/projects/[id]/project-header.tsx b/apps/web/app/office/projects/[id]/project-header.tsx index 045d61ce02..be88f0378a 100644 --- a/apps/web/app/office/projects/[id]/project-header.tsx +++ b/apps/web/app/office/projects/[id]/project-header.tsx @@ -12,6 +12,7 @@ import { useAppStore } from "@/components/state-provider"; import type { Project, ProjectStatus } from "@/lib/state/slices/office/types"; import { PROJECT_STATUS_LABEL_KEYS } from "../../lib/label-keys"; import { useTranslation } from "react-i18next"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; // `labelKey`, not `label` — module scope freezes a `t()` at the boot locale. // The `value`s are the persisted project-status ids and stay untranslated. @@ -67,7 +68,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) { setName(e.target.value); setDirty(true); }} - className="text-lg font-semibold h-9 px-2.5" + className={controlSizingClassName("standard", "text-lg font-semibold px-2.5")} /> {dirty && ( - diff --git a/apps/web/app/office/projects/create-project-dialog.tsx b/apps/web/app/office/projects/create-project-dialog.tsx index badef924ab..a89fdef3b3 100644 --- a/apps/web/app/office/projects/create-project-dialog.tsx +++ b/apps/web/app/office/projects/create-project-dialog.tsx @@ -16,6 +16,7 @@ import type { AgentProfile } from "@/lib/state/slices/office/types"; import { ProjectRepositoryPicker } from "./project-repository-picker"; import { RepoChip } from "./repo-chip"; import { useTranslation } from "react-i18next"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; const COLOR_OPTIONS = [ "#ef4444", @@ -345,14 +346,14 @@ export function CreateProjectDialog({ open, onOpenChange, workspaceId }: CreateP diff --git a/apps/web/app/office/projects/project-repository-picker.tsx b/apps/web/app/office/projects/project-repository-picker.tsx index 0ec588b973..cd50b219e5 100644 --- a/apps/web/app/office/projects/project-repository-picker.tsx +++ b/apps/web/app/office/projects/project-repository-picker.tsx @@ -19,6 +19,7 @@ import { normalizeRepoValue, shouldShowCustomEntry } from "./repo-entry"; import { useDiscoveredRepositories } from "./use-discovered-repositories"; import { useTranslation } from "react-i18next"; import { RepositoryDiscoveryControls } from "@/components/repository-discovery-controls"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; type Props = { workspaceId: string | null; @@ -128,7 +129,7 @@ export function ProjectRepositoryPicker({ placeholder={t("office:searchOrPasteAUrlOr")} value={query} onValueChange={setQuery} - className="h-9" + className={controlSizingClassName("standard")} /> e.stopPropagation()} > @@ -163,7 +163,7 @@ function RoutineActions({ onRunNow, onDelete }: { onRunNow: () => void; onDelete diff --git a/apps/web/app/office/setup/step-agent.tsx b/apps/web/app/office/setup/step-agent.tsx index 94794a4a6a..280af84c87 100644 --- a/apps/web/app/office/setup/step-agent.tsx +++ b/apps/web/app/office/setup/step-agent.tsx @@ -19,6 +19,7 @@ import { } from "./agent-profile-setup-controls"; import { Trans, useTranslation } from "react-i18next"; import { TIER_NAME_KEYS } from "../lib/label-keys"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; type ProfileSelectOption = ReturnType["profileOptions"][number]; @@ -166,7 +167,10 @@ function ProfileSelectorSection({ onValueChange={(v) => onChange({ agentProfileId: v })} disabled={profileOptions.length === 0} placeholder={t("office:selectAnAgentProfile")} - triggerClassName="mt-1 border border-input rounded-md px-3 h-9" + triggerClassName={controlSizingClassName( + "standard", + "mt-1 border border-input rounded-md px-3", + )} /> )} {!showCreate && ( @@ -320,7 +324,10 @@ function ExecutorSelector({ onValueChange={onChange} placeholder={t("office:selectExecutor")} showSearch={false} - triggerClassName="mt-1 border border-input rounded-md px-3 h-9" + triggerClassName={controlSizingClassName( + "standard", + "mt-1 border border-input rounded-md px-3", + )} /> {selected ? (

{selected.description}

diff --git a/apps/web/app/office/setup/step-tier-profiles.tsx b/apps/web/app/office/setup/step-tier-profiles.tsx index 652f7632de..b4148a1eb8 100644 --- a/apps/web/app/office/setup/step-tier-profiles.tsx +++ b/apps/web/app/office/setup/step-tier-profiles.tsx @@ -15,6 +15,7 @@ import { useSelectableProfileOptions, } from "./agent-profile-setup-controls"; import { useTranslation } from "react-i18next"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; type StepTierProfilesProps = { tierProfileIds: Partial>; @@ -162,7 +163,10 @@ function TierProfileSelector({ onValueChange={onChange} disabled={options.length === 0} placeholder={t("office:selectProfile")} - triggerClassName="border border-input rounded-md px-3 h-9 w-full" + triggerClassName={controlSizingClassName( + "standard", + "border border-input rounded-md px-3 w-full", + )} />
); diff --git a/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx b/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx index d6012a0fef..3a5b168bfb 100644 --- a/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx +++ b/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx @@ -279,7 +279,7 @@ function ChatInput({ - set("status", value)} > - + {/* `value` is the Azure DevOps pull-request status sent on the @@ -506,7 +498,7 @@ function WatchEditor({ value={current.cleanupPolicy} onValueChange={(value: AzureDevOpsCleanupPolicy) => set("cleanupPolicy", value)} > - + {/* `value` is the persisted `AzureDevOpsCleanupPolicy`; only the @@ -530,7 +522,7 @@ function WatchEditor({
@@ -385,11 +389,10 @@ export function ActionPresetsSection({ workspaceId }: { workspaceId: string }) { action={
diff --git a/apps/web/components/github/github-access-help.tsx b/apps/web/components/github/github-access-help.tsx index 3510e5862d..f7ef44ab80 100644 --- a/apps/web/components/github/github-access-help.tsx +++ b/apps/web/components/github/github-access-help.tsx @@ -13,6 +13,7 @@ import { } from "@kandev/ui/drawer"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; export function GitHubAccessHelp({ label, @@ -40,7 +41,7 @@ export function GitHubAccessHelp({ type="button" variant="ghost" size="icon" - className="h-11 w-11 shrink-0 cursor-pointer text-muted-foreground sm:h-6 sm:w-6" + className={controlSizingClassName("icon", "shrink-0 cursor-pointer text-muted-foreground")} aria-haspopup={usesTouchDrawer ? "dialog" : undefined} aria-expanded={usesTouchDrawer ? open : undefined} aria-label={label} diff --git a/apps/web/components/github/github-app-connection-panel.tsx b/apps/web/components/github/github-app-connection-panel.tsx index 8e66f8b5ad..3384378446 100644 --- a/apps/web/components/github/github-app-connection-panel.tsx +++ b/apps/web/components/github/github-app-connection-panel.tsx @@ -91,17 +91,17 @@ export function GitHubAppConnectionPanel({ workspaceId }: { workspaceId: string - -
@@ -131,7 +131,7 @@ function useAppRegistrationSelection(workspaceId: string, registrations: Registr function BackButton({ onClick }: { onClick: () => void }) { const { t } = useTranslation(); return ( - ); diff --git a/apps/web/components/github/github-app-create-form.tsx b/apps/web/components/github/github-app-create-form.tsx index 733ceab8bb..081fe240dd 100644 --- a/apps/web/components/github/github-app-create-form.tsx +++ b/apps/web/components/github/github-app-create-form.tsx @@ -120,13 +120,13 @@ function ManifestHandoff({
-
@@ -163,14 +163,12 @@ function CreateAppFields(props: CreateFieldsProps) {
props.onDisplayName(event.target.value)} /> props.onOwnerLogin(event.target.value)} /> @@ -190,7 +188,6 @@ function CreateAppFields(props: CreateFieldsProps) { {t("github:githubMustReachThisHttpsOrigin")}

- @@ -193,14 +192,14 @@ function PreparedImportForm(props: PreparedImportProps) { update={props.update} />
-
{settingsUrl ? ( - ) : ( - diff --git a/apps/web/components/github/github-app-policy-dialog.tsx b/apps/web/components/github/github-app-policy-dialog.tsx index 02429453c6..673d2c92b9 100644 --- a/apps/web/components/github/github-app-policy-dialog.tsx +++ b/apps/web/components/github/github-app-policy-dialog.tsx @@ -42,7 +42,7 @@ export function GitHubAppPolicyDialog({ return ( - diff --git a/apps/web/components/github/github-cli-form.tsx b/apps/web/components/github/github-cli-form.tsx index b152c63996..6b64dfa236 100644 --- a/apps/web/components/github/github-cli-form.tsx +++ b/apps/web/components/github/github-cli-form.tsx @@ -113,7 +113,7 @@ export function GitHubCLIForm({ onValueChange={setSelected} disabled={disabled || loading || !accounts.length} > - + diff --git a/apps/web/components/github/github-connection-dialog.tsx b/apps/web/components/github/github-connection-dialog.tsx index 4373fbdc83..eebb74163f 100644 --- a/apps/web/components/github/github-connection-dialog.tsx +++ b/apps/web/components/github/github-connection-dialog.tsx @@ -25,6 +25,7 @@ import type { GitHubStatus } from "@/lib/types/github"; import type { GitHubAutomationMethod } from "./github-auth-method-list"; import { GitHubConnectionSettingsForm } from "./github-connection-settings-form"; import { useTranslation } from "react-i18next"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; function methodForStatus(status: GitHubStatus): GitHubAutomationMethod { if (status.automation?.source === "github_app_installation") return "app"; @@ -65,7 +66,10 @@ export function GitHubConnectionDialog({ [status], ); const trigger = ( - diff --git a/apps/web/components/github/github-connection-settings-form.tsx b/apps/web/components/github/github-connection-settings-form.tsx index 99af7a938f..3518eea684 100644 --- a/apps/web/components/github/github-connection-settings-form.tsx +++ b/apps/web/components/github/github-connection-settings-form.tsx @@ -284,7 +284,7 @@ export function GitHubConnectionSettingsForm(props: SettingsFormProps) { type="button" disabled={!canSave} onClick={save} - className="h-11 w-full cursor-pointer sm:w-auto" + className="w-full cursor-pointer sm:w-auto" data-dialog-default-action > {saving && } diff --git a/apps/web/components/github/github-pat-form.tsx b/apps/web/components/github/github-pat-form.tsx index 7a0156b4e3..1275893a4e 100644 --- a/apps/web/components/github/github-pat-form.tsx +++ b/apps/web/components/github/github-pat-form.tsx @@ -42,13 +42,13 @@ export function GitHubPATForm({ placeholder="ghp_xxxxxxxxxxxx" autoComplete="off" disabled={disabled} - className={settingsCredentialClassName("h-11 pr-11")} + className={settingsCredentialClassName("pr-11")} />
diff --git a/apps/web/components/gitlab/mr-discussions-section.tsx b/apps/web/components/gitlab/mr-discussions-section.tsx index 79e2572a31..b6ebd9e75d 100644 --- a/apps/web/components/gitlab/mr-discussions-section.tsx +++ b/apps/web/components/gitlab/mr-discussions-section.tsx @@ -8,6 +8,7 @@ import { Textarea } from "@kandev/ui/textarea"; import type { GitLabMRDiscussion } from "@/lib/types/gitlab"; import { CollapsibleSection, formatTimeAgo, PRMarkdownBody } from "@/components/github/pr-shared"; import { useTranslation } from "react-i18next"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; function discussionLocation(discussion: GitLabMRDiscussion): string { if (!discussion.path) return ""; @@ -78,9 +79,9 @@ function Discussion({ )} )}
- diff --git a/apps/web/components/gitlab/watch-settings.tsx b/apps/web/components/gitlab/watch-settings.tsx index 2f15efcb04..45e5b38fd9 100644 --- a/apps/web/components/gitlab/watch-settings.tsx +++ b/apps/web/components/gitlab/watch-settings.tsx @@ -39,11 +39,7 @@ function ActionError({ message }: { message: string }) { function NewWatchButton({ onClick }: { onClick: () => void }) { const { t } = useTranslation(); return ( - diff --git a/apps/web/components/integrations/auth-error-message.tsx b/apps/web/components/integrations/auth-error-message.tsx index a0e97bdfef..f7b5490a51 100644 --- a/apps/web/components/integrations/auth-error-message.tsx +++ b/apps/web/components/integrations/auth-error-message.tsx @@ -56,7 +56,7 @@ export function IntegrationAuthErrorMessage({ : cleanIntegrationErrorMessage(error)} {isAuth && ( - )} diff --git a/apps/web/components/integrations/change-request-detail-copy-button.tsx b/apps/web/components/integrations/change-request-detail-copy-button.tsx index 6d682a11ac..4cbbf7aea3 100644 --- a/apps/web/components/integrations/change-request-detail-copy-button.tsx +++ b/apps/web/components/integrations/change-request-detail-copy-button.tsx @@ -46,9 +46,9 @@ export function ChangeRequestDetailCopyButton({ ) : null} diff --git a/apps/web/components/integrations/integration-cursor-pagination.tsx b/apps/web/components/integrations/integration-cursor-pagination.tsx index 419bdb6d74..404a9d5ef8 100644 --- a/apps/web/components/integrations/integration-cursor-pagination.tsx +++ b/apps/web/components/integrations/integration-cursor-pagination.tsx @@ -53,10 +53,7 @@ export function IntegrationCursorPagination({ if (!previousDisabled) onPrevious(); }} aria-disabled={previousDisabled} - className={cn( - "min-h-11 cursor-pointer sm:min-h-9", - previousDisabled && "pointer-events-none opacity-50", - )} + className={cn("cursor-pointer", previousDisabled && "pointer-events-none opacity-50")} /> @@ -67,10 +64,7 @@ export function IntegrationCursorPagination({ if (!nextDisabled) onNext(); }} aria-disabled={nextDisabled} - className={cn( - "min-h-11 cursor-pointer sm:min-h-9", - nextDisabled && "pointer-events-none opacity-50", - )} + className={cn("cursor-pointer", nextDisabled && "pointer-events-none opacity-50")} /> diff --git a/apps/web/components/integrations/integration-list-toolbar.tsx b/apps/web/components/integrations/integration-list-toolbar.tsx index 49b9dfccc5..96ed1c73d6 100644 --- a/apps/web/components/integrations/integration-list-toolbar.tsx +++ b/apps/web/components/integrations/integration-list-toolbar.tsx @@ -6,6 +6,7 @@ import { IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { cn, formatRelativeTime } from "@/lib/utils"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; export type IntegrationListToolbarProps = { title: string; @@ -48,7 +49,7 @@ function RefreshControls({ @@ -98,7 +96,7 @@ export function OAuthFields({ type="button" onClick={oauth.handleConnect} disabled={oauth.connecting || loading || !form.siteUrl} - className="h-11 w-full cursor-pointer sm:w-auto" + className="w-full cursor-pointer sm:w-auto" data-testid="jira-oauth-connect" > {oauth.connecting ? t("jira:connecting") : t("jira:connectWithAtlassian")} diff --git a/apps/web/components/jira/my-jira/list-toolbar.tsx b/apps/web/components/jira/my-jira/list-toolbar.tsx index a38c3739f7..7b95c683c2 100644 --- a/apps/web/components/jira/my-jira/list-toolbar.tsx +++ b/apps/web/components/jira/my-jira/list-toolbar.tsx @@ -94,19 +94,18 @@ export function ListToolbar({ @@ -160,7 +159,7 @@ function SearchInput({ value, onChange }: { value: string; onChange: (v: string) value={value} onChange={(e) => onChange(e.target.value)} placeholder={t("jira:searchTicketKeyOrText")} - className="h-8 text-xs pl-8" + className="text-xs pl-8" /> ); @@ -208,7 +207,7 @@ function ViewsDropdown({ }} > - @@ -403,8 +402,7 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) { - diff --git a/apps/web/components/jira/my-jira/ticket-row.tsx b/apps/web/components/jira/my-jira/ticket-row.tsx index ccf92d4e67..b682e98d76 100644 --- a/apps/web/components/jira/my-jira/ticket-row.tsx +++ b/apps/web/components/jira/my-jira/ticket-row.tsx @@ -54,7 +54,7 @@ function StartTaskMenu({ return ( - diff --git a/apps/web/components/jira/task-presets-section.tsx b/apps/web/components/jira/task-presets-section.tsx index a274bd05da..fb4b0ed837 100644 --- a/apps/web/components/jira/task-presets-section.tsx +++ b/apps/web/components/jira/task-presets-section.tsx @@ -20,6 +20,11 @@ import { SettingsPromptEditor } from "@/components/settings/settings-prompt-edit import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; import { Trans, useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; +import { + settingsActionClassName, + settingsControlClassName, +} from "@/components/settings/settings-control"; // A function rather than a const because `description` is copy — it is shown in // the editor's completion list — and a module-scope `t()` would freeze it at the @@ -76,7 +81,10 @@ function IconSelect({ value, onChange }: { value: string; onChange: (v: string) const { t } = useTranslation(); return ( {t("jira:hint")} diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index 2401fcf980..2b17dc7095 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -229,7 +229,7 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete type="button" variant="destructive" onClick={() => setConfirmingDelete(true)} - className="ml-auto min-h-11 cursor-pointer" + className="ml-auto cursor-pointer" data-testid="linear-delete-button" > {t("linear:removeConfiguration")} diff --git a/apps/web/components/review/review-dialog-pr-state.tsx b/apps/web/components/review/review-dialog-pr-state.tsx index 78bb4c7913..7c5f262d83 100644 --- a/apps/web/components/review/review-dialog-pr-state.tsx +++ b/apps/web/components/review/review-dialog-pr-state.tsx @@ -149,7 +149,7 @@ export function ReviewPRDiffBoundary({
{error} {onRetry && ( - diff --git a/apps/web/components/sentry/sentry-instance-card.tsx b/apps/web/components/sentry/sentry-instance-card.tsx index dc18c382d7..ebbbaa6d52 100644 --- a/apps/web/components/sentry/sentry-instance-card.tsx +++ b/apps/web/components/sentry/sentry-instance-card.tsx @@ -61,7 +61,6 @@ export function SentryInstanceCard({
diff --git a/apps/web/components/settings/workflow-section-actions.tsx b/apps/web/components/settings/workflow-section-actions.tsx index 4eb2df5705..4c4ed7cef6 100644 --- a/apps/web/components/settings/workflow-section-actions.tsx +++ b/apps/web/components/settings/workflow-section-actions.tsx @@ -4,6 +4,7 @@ import { IconDownload, IconPlus, IconUpload } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { WorkflowSyncButton } from "@/components/settings/workflow-sync-section"; +import { settingsActionClassName } from "@/components/settings/settings-control"; type WorkflowSectionActionsProps = { onExport: () => void; @@ -31,7 +32,7 @@ export function WorkflowSectionActions({ size="sm" variant="outline" onClick={onExport} - className="cursor-pointer" + className={settingsActionClassName("cursor-pointer")} > {t("workflows:exportAll")} @@ -41,7 +42,7 @@ export function WorkflowSectionActions({ size="sm" variant="outline" onClick={onImport} - className="cursor-pointer" + className={settingsActionClassName("cursor-pointer")} > {t("workflows:import")} @@ -50,7 +51,7 @@ export function WorkflowSectionActions({ type="button" size="sm" onClick={onAdd} - className="cursor-pointer" + className={settingsActionClassName("cursor-pointer")} data-testid="add-workflow-button" > diff --git a/apps/web/components/settings/workflow-session-config-carry-warning.tsx b/apps/web/components/settings/workflow-session-config-carry-warning.tsx index e86ed4640d..d0b5b6e471 100644 --- a/apps/web/components/settings/workflow-session-config-carry-warning.tsx +++ b/apps/web/components/settings/workflow-session-config-carry-warning.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import type { ConfigureSessionOperation } from "@/lib/types/workflow-actions"; import type { SessionConfigCarryWarning } from "@/lib/workflows/session-config-carry-analysis"; +import { settingsActionClassName } from "./settings-control"; export function SessionConfigCarryWarningPanel({ warnings, @@ -46,7 +47,7 @@ export function SessionConfigCarryWarningPanel({ type="button" size="sm" variant="outline" - className="min-h-10 cursor-pointer" + className={settingsActionClassName("cursor-pointer")} disabled={readOnly || disabled} onClick={() => onChoose(warning, "keep")} > @@ -56,7 +57,7 @@ export function SessionConfigCarryWarningPanel({ type="button" size="sm" variant="outline" - className="min-h-10 cursor-pointer" + className={settingsActionClassName("cursor-pointer")} disabled={readOnly || disabled} onClick={() => onChoose(warning, "restore_original")} > @@ -66,7 +67,7 @@ export function SessionConfigCarryWarningPanel({ type="button" size="sm" variant="outline" - className="min-h-10 cursor-pointer" + className={settingsActionClassName("cursor-pointer")} disabled={readOnly || disabled} onClick={() => onChoose(warning, "set")} > diff --git a/apps/web/components/settings/workflow-session-config-editor.tsx b/apps/web/components/settings/workflow-session-config-editor.tsx index 456f04d742..56d01cc2b6 100644 --- a/apps/web/components/settings/workflow-session-config-editor.tsx +++ b/apps/web/components/settings/workflow-session-config-editor.tsx @@ -27,6 +27,7 @@ import { withConfigureSessionRules, type AgentChoice, } from "./workflow-session-config-shared"; +import { settingsActionClassName } from "./settings-control"; type SessionConfigEditorProps = { step: WorkflowStep; @@ -361,7 +362,7 @@ function SessionConfigOptionsHeader({ type="button" size="sm" variant="ghost" - className="min-h-10 cursor-pointer" + className={settingsActionClassName("cursor-pointer")} onClick={onAddRule} disabled={disabled || !canAddRule} data-testid={`${step.id}-add-session-config-rule`} diff --git a/apps/web/components/settings/workflow-session-config-rule-card.tsx b/apps/web/components/settings/workflow-session-config-rule-card.tsx index 04babdaaa9..b8539cdd1b 100644 --- a/apps/web/components/settings/workflow-session-config-rule-card.tsx +++ b/apps/web/components/settings/workflow-session-config-rule-card.tsx @@ -22,6 +22,7 @@ import { defaultModelForAgent, operationRule, } from "./workflow-session-config-shared"; +import { settingsActionClassName, settingsControlClassName } from "./settings-control"; function modelSelectionForRule( rule: ConfigureSessionRule, @@ -254,7 +255,7 @@ function SessionConfigRuleHeader({ disabled={readOnly} > @@ -275,7 +276,7 @@ function SessionConfigRuleHeader({ disabled={readOnly} > @@ -290,7 +291,9 @@ function SessionConfigRuleHeader({ type="button" variant="ghost" size="sm" - className="min-h-10 cursor-pointer self-end text-destructive hover:text-destructive sm:self-auto" + className={settingsActionClassName( + "cursor-pointer self-end text-destructive hover:text-destructive sm:self-auto", + )} onClick={onRemove} disabled={readOnly} aria-label={t("workflows:removeAgentCondition", { index: index + 1 })} @@ -358,7 +361,7 @@ function SessionConfigRuleSettings({ disabled={readOnly} placeholder={t("workflows:chooseModelAndSessionSettings")} ariaLabel={t("workflows:settingsForAgent", { agent: rule.agent_name })} - triggerClassName="min-h-11 w-full sm:min-h-10" + triggerClassName={settingsControlClassName("w-full")} /> ) : (

diff --git a/apps/web/components/settings/workflow-sync-section.tsx b/apps/web/components/settings/workflow-sync-section.tsx index 7436ddc71e..e5cb9317ad 100644 --- a/apps/web/components/settings/workflow-sync-section.tsx +++ b/apps/web/components/settings/workflow-sync-section.tsx @@ -7,6 +7,7 @@ import { Separator } from "@kandev/ui/separator"; import { WorkflowSyncDialog } from "@/components/settings/workflow-sync-dialog"; import { WorkflowSyncStatusCard } from "@/components/settings/workflow-sync-status-banner"; import { useWorkflowSync } from "@/hooks/domains/settings/use-workflow-sync"; +import { settingsActionClassName } from "@/components/settings/settings-control"; // WorkflowSyncButton is the workflow sync entry point (GitHub or GitLab), // rendered alongside the other workflow actions (Export / Import / Add). The @@ -20,7 +21,7 @@ export function WorkflowSyncButton({ onClick }: { onClick: () => void }) { variant="outline" size="sm" onClick={onClick} - className="cursor-pointer" + className={settingsActionClassName("cursor-pointer")} data-testid="workflow-sync-open" > diff --git a/apps/web/components/settings/workspace-canvases-page.tsx b/apps/web/components/settings/workspace-canvases-page.tsx index 09a922fd18..91e849eec3 100644 --- a/apps/web/components/settings/workspace-canvases-page.tsx +++ b/apps/web/components/settings/workspace-canvases-page.tsx @@ -38,6 +38,9 @@ import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle"; import { CanvasTaskCreateLauncher } from "@/components/canvas/canvas-task-create-launcher"; import { SettingsErrorText, SettingsPageHeader } from "./settings-typography"; import { CanvasReleaseDialog } from "./canvas-lifecycle-dialogs"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; + +const canvasActionClassName = controlSizingClassName("standard", "cursor-pointer"); function canvasStatusLabel(status: string, t: (key: string) => string): string { const labels: Record = { @@ -190,7 +193,7 @@ export function WorkspaceCanvasesPage({ workspaceId }: { workspaceId: string })

- diff --git a/apps/web/components/task-create-dialog-priority-select.tsx b/apps/web/components/task-create-dialog-priority-select.tsx index 8698a2126a..029e14551e 100644 --- a/apps/web/components/task-create-dialog-priority-select.tsx +++ b/apps/web/components/task-create-dialog-priority-select.tsx @@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next"; import { TASK_PRIORITY_LABEL_KEYS, TASK_PRIORITY_TOKENS } from "@/lib/tasks/task-priority"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import type { TaskPriority } from "@/lib/types/http"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; function PriorityInfo({ label, description }: { label: string; description: string }) { const usesTouchDrawer = useTouchDrawer(); @@ -23,7 +24,7 @@ function PriorityInfo({ label, description }: { label: string; description: stri const trigger = ( )} - diff --git a/apps/web/components/task/browser-panel.tsx b/apps/web/components/task/browser-panel.tsx index 64d5b32574..14ae01e71b 100644 --- a/apps/web/components/task/browser-panel.tsx +++ b/apps/web/components/task/browser-panel.tsx @@ -156,6 +156,7 @@ export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelP url.setUrlDraft(e.target.value)} onKeyDown={(e) => { diff --git a/apps/web/components/task/chat/dynamic-route-recovery.tsx b/apps/web/components/task/chat/dynamic-route-recovery.tsx index 3564d925a8..0131fb868d 100644 --- a/apps/web/components/task/chat/dynamic-route-recovery.tsx +++ b/apps/web/components/task/chat/dynamic-route-recovery.tsx @@ -151,7 +151,7 @@ function DynamicRouteRecoveryActions({ + ); +} + function StandardActionButton({ action, messageTaskId, @@ -103,24 +169,8 @@ function StandardActionButton({ if (state === "busy") return; setState("busy"); try { - switch (action.type) { - case "archive_task": { - if (taskId) await archiveAndSwitch(taskId); - break; - } - case "ws_request": { - const client = getWebSocketClient(); - const params = action.params as - | { method: string; payload: Record } - | undefined; - // i18n-exempt: technical error for unavailable WebSocket recovery client. - if (!client || !params) throw new Error("WebSocket recovery request is unavailable"); - await client.request(params.method, params.payload); - break; - } - } + await executeStandardAction(action, taskId, archiveAndSwitch, onCompleted); setState("done"); - if (action.type === "ws_request") onCompleted?.(); } catch { setState("error"); setTimeout(() => setState("idle"), 3000); @@ -133,27 +183,18 @@ function StandardActionButton({ // buttons, so this stale one would just confuse the user. if (state === "done" && action.type === "ws_request") return null; - const Icon = action.icon ? ACTION_ICON_MAP[action.icon] : null; const disabled = state === "busy" || state === "done"; const isDestructive = action.variant === "destructive"; const button = ( - + labelOverride={labelOverride} + destructive={isDestructive} + /> ); if (action.tooltip) { @@ -171,6 +212,33 @@ type DeleteActionButtonProps = Omit & { action: MessageAction & { type: "delete_task" }; }; +type DeleteTaskById = ReturnType["deleteTaskById"]; +type RemoveTaskFromBoard = ReturnType["removeTaskFromBoard"]; +type TaskStore = ReturnType; + +async function deleteTaskAndRemoveFromBoard({ + taskId, + cascade, + discardWorktreeChanges, + store, + deleteTaskById, + removeTaskFromBoard, +}: { + taskId: string; + cascade: boolean; + discardWorktreeChanges: boolean; + store: TaskStore; + deleteTaskById: DeleteTaskById; + removeTaskFromBoard: RemoveTaskFromBoard; +}): Promise { + const { activeTaskId, activeSessionId } = store.getState().tasks; + await deleteTaskById(taskId, { cascade, discardWorktreeChanges }); + await removeTaskFromBoard(taskId, { + wasActiveTaskId: activeTaskId, + wasActiveSessionId: activeSessionId, + }); +} + function DeleteActionButton({ action, messageTaskId, @@ -199,11 +267,13 @@ function DeleteActionButton({ if (!taskId || state === "busy") return; setState("busy"); try { - const { activeTaskId, activeSessionId } = store.getState().tasks; - await deleteTaskById(taskId, { cascade, discardWorktreeChanges }); - await removeTaskFromBoard(taskId, { - wasActiveTaskId: activeTaskId, - wasActiveSessionId: activeSessionId, + await deleteTaskAndRemoveFromBoard({ + taskId, + cascade, + discardWorktreeChanges, + store, + deleteTaskById, + removeTaskFromBoard, }); setState("done"); } catch { @@ -218,25 +288,16 @@ function DeleteActionButton({ if (state !== "busy") setDeleteDialogOpen(true); }, [state]); - const Icon = action.icon ? ACTION_ICON_MAP[action.icon] : null; const disabled = state === "busy" || state === "done"; const button = ( - + labelOverride={labelOverride} + destructive + /> ); const dialog = ( { expect(notice.className).not.toContain("text-red"); expect(notice.querySelector("svg")).toBeNull(); const button = screen.getByTestId(STALL_CANCEL_TEST_ID); - expect(button.className).toContain("min-h-11"); + expect(button.className).toContain("h-6"); + expect(button.className).toContain("max-md:min-h-11"); expect(button.className).not.toContain("w-full"); }); @@ -453,8 +454,8 @@ describe("ActionMessage — missing PR branch", () => { expect(screen.getByText("codex/enhance-prompt-result-delivery")).toBeTruthy(); const technicalDetails = screen.getByText(TECHNICAL_DETAILS).closest("details"); expect(technicalDetails?.open).toBe(false); - expect(screen.getByTestId("missing-branch-archive-button").className).toContain("min-h-11"); - expect(screen.getByTestId("missing-branch-delete-button").className).toContain("min-h-11"); + expect(screen.getByTestId("missing-branch-archive-button").className).toContain("h-7"); + expect(screen.getByTestId("missing-branch-delete-button").className).toContain("h-7"); fireEvent.click(screen.getByText(TECHNICAL_DETAILS)); expect(technicalDetails?.open).toBe(true); @@ -521,7 +522,8 @@ describe("ActionMessage — provider quota recovery", () => { expect(screen.getByText(/kimi-k3/i)).toBeTruthy(); const details = screen.getByText(TECHNICAL_DETAILS).closest("details"); expect(details?.open).toBe(false); - expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("min-h-11"); + expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("h-7"); + expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("max-md:h-11"); fireEvent.click(screen.getByText(TECHNICAL_DETAILS)); expect(details?.open).toBe(true); @@ -640,7 +642,8 @@ describe("ActionMessage — remediation link", () => { expect(link.href).toBe(REMEDIATION_URL); expect(link.target).toBe("_blank"); expect(link.rel).toBe("noopener noreferrer"); - expect(link.className).toContain("min-h-11"); + expect(link.className).toContain("h-7"); + expect(link.className).toContain("max-md:h-11"); // The sanitized message and collapsed details stay URL-free. expect(screen.queryByText(/opencode\.ai\/workspace/i)).toBeNull(); expect(screen.getByTestId("provider-quota-recovery").textContent).toContain(QUOTA_OUTPUT); diff --git a/apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx b/apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx index d45973365d..19306be10f 100644 --- a/apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx +++ b/apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx @@ -14,6 +14,7 @@ import { createXAxisTickFormatter, formatYAxisTick } from "./chart-format"; import { useRichOutputChartAnimations } from "./chart-motion"; import { useChartPlotVisibility } from "./chart-visibility"; import type { RichOutputChartBlock } from "./types"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; const HOST_SERIES_COLORS = [ "var(--chart-1)", @@ -85,7 +86,7 @@ function SeriesLegend({ key={key} type="button" aria-pressed={isVisible} - className={`flex min-h-11 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground transition-[opacity,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.98] ${isVisible ? "opacity-100" : "opacity-50"}`} + className={`${controlSizingClassName("standard")} flex cursor-pointer items-center gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground transition-[opacity,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.98] ${isVisible ? "opacity-100" : "opacity-50"}`} data-testid={`rich-output-chart-legend-${key}`} onClick={() => onToggle(key)} > diff --git a/apps/web/components/task/chat/messages/kandev/rich-output/file-preview-block.tsx b/apps/web/components/task/chat/messages/kandev/rich-output/file-preview-block.tsx index 31d341276a..8620d09960 100644 --- a/apps/web/components/task/chat/messages/kandev/rich-output/file-preview-block.tsx +++ b/apps/web/components/task/chat/messages/kandev/rich-output/file-preview-block.tsx @@ -111,8 +111,7 @@ export function FilePreviewBlock({ block, sessionId, onOpenFile }: FilePreviewBl diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 9976c5316e..b4931efcdd 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -426,7 +426,7 @@ function TopBarRight({ )} {officeTaskHref && ( - diff --git a/apps/web/components/task/workflow-step-disclosure.tsx b/apps/web/components/task/workflow-step-disclosure.tsx index efa9019cd9..3144763e6d 100644 --- a/apps/web/components/task/workflow-step-disclosure.tsx +++ b/apps/web/components/task/workflow-step-disclosure.tsx @@ -517,9 +517,9 @@ function StepDisclosureBody({