Skip to content

feat(ui): disabled button tooltip audit#34

Merged
hqhq1025 merged 5 commits intomainfrom
wt/ux-disabled-tooltip
Apr 18, 2026
Merged

feat(ui): disabled button tooltip audit#34
hqhq1025 merged 5 commits intomainfrom
wt/ux-disabled-tooltip

Conversation

@hqhq1025
Copy link
Copy Markdown
Collaborator

Summary

  • Implements §9 P1 of docs/research/18-ux-master-plan.md: every disabled button now explains why on hover
  • 9 distinct disabled scenarios covered across 7 component files (see commit message for full list)
  • Added disabledReason.* i18n namespace with 12 keys in both en and zh-CN locales
  • Tooltip component updated to accept label?: string and skip rendering when undefined, so enabled buttons stay clean

Compatibility checklist

  • Compatibility ✅ — no interface changes, Tooltip label widens to string | undefined (backward compatible)
  • Upgradeability ✅ — no disk schema touched
  • No bloat ✅ — zero new dependencies; reuses existing Tooltip primitive from packages/ui
  • Elegance ✅ — tooltip only wraps disabled state; enabled buttons render unchanged via early-return in Tooltip

Test plan

  • All 85 existing unit tests pass (pnpm -r test)
  • Typecheck clean (pnpm -r typecheck)
  • Lint clean (pnpm lint)
  • Manual: hover the Send arrow when textarea is empty — tooltip "Type a prompt to start" appears
  • Manual: hover the Send arrow while generating — tooltip "Generation in progress" appears
  • Manual: hover the Export button before any generation — tooltip "Generate a design first" appears
  • Manual: hover the "Validate" button in Add Provider modal with empty key — tooltip appears
  • Manual: hover "Save provider" button before validation — tooltip "Validate the key first" appears
  • Manual: in onboarding, hover "Continue" before key validates — tooltip appears
  • Manual: in onboarding, hover Ollama option if detected — tooltip "coming in v0.2" appears
  • Manual: switch to zh-CN locale and verify all tooltip strings render in Chinese

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Disabled-reason tooltip copy bypasses i18n, causing locale regression (zh-CN users will still see English in these newly added tooltips). Evidence: apps/desktop/src/renderer/src/components/Settings.tsx:375, apps/desktop/src/renderer/src/onboarding/ChooseModel.tsx:98, apps/desktop/src/renderer/src/onboarding/PasteKey.tsx:227, apps/desktop/src/renderer/src/onboarding/Welcome.tsx:37
    Suggested fix:

    const t = useT();
    <Tooltip
      label={disabled ? t('disabledReason.enterApiKeyToValidate') : undefined}
      side="top"
    >
      ...
    </Tooltip>
  • [Minor] The new Tooltip early-return branch has no automated coverage, so regressions (rendering wrappers when label is undefined / changed hover behavior) may slip through. Evidence: packages/ui/src/components/Tooltip.tsx:15
    Suggested fix:

    it('renders children without tooltip chrome when label is undefined', () => {
      render(<Tooltip label={undefined}><button>Send</button></Tooltip>);
      expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument();
      expect(screen.queryByRole('tooltip')).toBeNull();
    });

Summary

  • Review mode: initial
  • Found 2 issues in the latest head diff, including one localization regression tied to newly added disabled-state tooltip text.
  • docs/VISION.md and docs/PRINCIPLES.md: Not found in repo/docs.

Testing

  • Not run (automation)

open-codesign Bot

<Tooltip
label={
form.apiKey.trim().length === 0 || form.validating
? 'Enter an API key to validate'
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] This new tooltip reason is hardcoded English. The PR also adds disabledReason.* locale keys, so this bypass causes zh-CN regression.

Suggested fix:

const t = useT();
label={form.apiKey.trim().length === 0 || form.validating
  ? t('disabledReason.enterApiKeyToValidate')
  : undefined}

};

export function Tooltip({ label, side = 'bottom', children }: TooltipProps) {
if (!label) return <>{children}</>;
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] New behavior branch (if (!label) return children) should have a unit test to prevent regressions in wrapper/hover semantics.

Suggested fix:

it('renders children without tooltip when label is undefined', () => {
  render(<Tooltip label={undefined}><button>Send</button></Tooltip>);
  expect(screen.queryByRole('tooltip')).toBeNull();
});

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] New Tooltip fallback branch has no unit coverage, so regressions in wrapper/hover behavior can slip in unnoticed across all newly wrapped disabled-button states. Evidence packages/ui/src/components/Tooltip.tsx:15
    Suggested fix:
    import { render, screen } from '@testing-library/react';
    import { Tooltip } from './Tooltip';
    
    it('renders children without tooltip when label is undefined', () => {
      render(
        <Tooltip label={undefined}>
          <button type="button">Send</button>
        </Tooltip>,
      );
    
      expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument();
      expect(screen.queryByRole('tooltip')).toBeNull();
    });

Summary

  • Review mode: follow-up after new commits
  • Found 1 issue in the latest head diff.
  • docs/VISION.md and docs/PRINCIPLES.md: Not found in repo/docs.

Testing

  • Not run (automation)

open-codesign Bot

};

export function Tooltip({ label, side = 'bottom', children }: TooltipProps) {
if (!label) return <>{children}</>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] This new branch changes rendering behavior (children only when label is absent) but has no unit test coverage.

Suggested fix:

it('renders children without tooltip when label is undefined', () => {
  render(
    <Tooltip label={undefined}>
      <button type="button">Send</button>
    </Tooltip>,
  );
  expect(screen.queryByRole('tooltip')).toBeNull();
});

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Disabled tooltip reasons are not keyboard-accessible, so the new disabled-state guidance is effectively mouse-only. The tooltip is shown only via group-hover / group-focus-within, but its wrapper is a plain span that cannot receive focus. Evidence packages/ui/src/components/Tooltip.tsx:17, packages/ui/src/components/Tooltip.tsx:21.
    Suggested fix:
    import { useId, type ReactNode } from 'react';
    
    export function Tooltip({ label, side = 'bottom', children }: TooltipProps) {
      const tooltipId = useId();
      if (!label) return <>{children}</>;
    
      return (
        <span
          className="relative inline-flex group"
          tabIndex={0}
          aria-describedby={tooltipId}
        >
          {children}
          <span id={tooltipId} role="tooltip" className={`... group-hover:opacity-100 group-focus-within:opacity-100 ...`}>
            {label}
          </span>
        </span>
      );
    }

Summary

  • Review mode: follow-up after new commits
  • Found 1 issue in the latest head diff.
  • docs/VISION.md: Not found in repo/docs.
  • docs/PRINCIPLES.md: Not found in repo/docs.

Testing

  • Not run (automation)

open-codesign Bot

Comment thread packages/ui/src/components/Tooltip.tsx Outdated
export function Tooltip({ label, side = 'bottom', children }: TooltipProps) {
if (!label) return <>{children}</>;
return (
<span className="relative inline-flex group">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] This wrapper is not focusable, so disabled-state tooltip reasons are mouse-only despite group-focus-within styles. Suggested fix:

import { useId, type ReactNode } from "react";

export function Tooltip({ label, side = "bottom", children }: TooltipProps) {
  const tooltipId = useId();
  if (!label) return <>{children}</>;

  return (
    <span className="relative inline-flex group" tabIndex={0} aria-describedby={tooltipId}>
      {children}
      <span id={tooltipId} role="tooltip" className={`... group-hover:opacity-100 group-focus-within:opacity-100 ...`}>
        {label}
      </span>
    </span>
  );
}

@hqhq1025 hqhq1025 force-pushed the wt/ux-disabled-tooltip branch from d7ce29c to f296134 Compare April 18, 2026 18:14
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Tooltip now makes every wrapped control add an extra keyboard tab stop because the wrapper is always focusable (tabIndex={0}), even when the child control is enabled and already keyboard-focusable. This degrades keyboard navigation order across existing always-enabled tooltip usage and is directly caused by the new global wrapper behavior. Evidence packages/ui/src/components/Tooltip.tsx:24.
    Suggested fix:
    import { isValidElement, useId, type ReactNode } from 'react';
    
    export function Tooltip({ label, side = 'bottom', children }: TooltipProps) {
      const tooltipId = useId();
      if (!label) return <>{children}</>;
    
      const childDisabled =
        isValidElement<{ disabled?: boolean }>(children) && Boolean(children.props.disabled);
    
      return (
        <span
          className="relative inline-flex group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] rounded-[var(--radius-sm)]"
          tabIndex={childDisabled ? 0 : undefined}
          aria-describedby={childDisabled ? tooltipId : undefined}
        >
          {children}
          <span id={tooltipId} role="tooltip" className={`... group-hover:opacity-100 group-focus-within:opacity-100 ...`}>
            {label}
          </span>
        </span>
      );
    }

Summary

  • Review mode: follow-up after new commits
  • Found 1 issue in the latest head diff.
  • docs/VISION.md: Not found in repo/docs.
  • docs/PRINCIPLES.md: Not found in repo/docs.

Testing

  • Not run (automation)

open-codesign Bot

Comment thread packages/ui/src/components/Tooltip.tsx Outdated
<span className="relative inline-flex group">
<span
className="relative inline-flex group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] rounded-[var(--radius-sm)]"
tabIndex={0}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] tabIndex={0} is now unconditional, so enabled controls wrapped in Tooltip become two tab stops (wrapper + control). This regresses keyboard navigation order. Suggested fix:

const childDisabled =
  isValidElement<{ disabled?: boolean }>(children) && Boolean(children.props.disabled);

<span
  tabIndex={childDisabled ? 0 : undefined}
  aria-describedby={childDisabled ? tooltipId : undefined}
>

Implements §9 P1 of the UX master plan. Every disabled interactive
button now shows a tooltip on hover explaining why it cannot be clicked.

- Sidebar send button: "Type a prompt to start" / "Generation in progress"
- InlineCommentComposer apply button: "Type a comment to apply" / "Generation in progress"
- PreviewToolbar export button: "Generate a design first"
- Settings validate button: "Enter an API key to validate"
- Settings save-provider button: "Validate the key first"
- Onboarding PasteKey test-connection button: "Enter provider, key and base URL to test"
- Onboarding PasteKey continue button: "Validate your API key to continue"
- Onboarding ChooseModel back/finish buttons: "Saving in progress" / "Both model fields are required"
- Onboarding Welcome Ollama button: "Ollama integration is coming in v0.2"

Tooltip strings added to `disabledReason.*` namespace in both en and
zh-CN locales. Tooltip component updated to accept `label?: string` and
skip rendering when label is undefined, so enabled buttons stay clean.

Signed-off-by: hqhq1025 <[email protected]>
All four files that added hardcoded English strings in the previous
tooltip round now use t('disabledReason.*') instead. No new i18n keys
were required — all keys already existed in both en.json and zh-CN.json.

Signed-off-by: hqhq1025 <[email protected]>
Previously the Tooltip wrapper was unconditionally `tabIndex={0}`, which
added a redundant extra tab stop in front of every enabled control it
wrapped. Now we inspect `children.props.disabled` and only set
`tabIndex={0}` when the wrapped element is actually disabled — enabled
elements stay the sole focus target.

`aria-describedby` remains on the wrapper unconditionally so screen
readers announce the reason whether focus lands on the wrapper or on
the inner control.

Tests:
- "tabindex=0 only when disabled" replaces the previous unconditional check
- "omits tabindex when enabled" guards against regression
- "tab order" verifies the inner button is the first focus target when
  enabled and the wrapper takes over only when disabled

Signed-off-by: hqhq1025 <[email protected]>
@hqhq1025 hqhq1025 force-pushed the wt/ux-disabled-tooltip branch from f296134 to 7b0674b Compare April 18, 2026 19:14
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No high-confidence issues found in added/modified lines.

Summary

  • Review mode: follow-up after new commits
  • No blocking/major/minor issues identified in the latest head diff.
  • docs/VISION.md: Not found in repo/docs.
  • docs/PRINCIPLES.md: Not found in repo/docs.
  • Residual risk: broad UI behavior across all tooltip callsites cannot be fully validated here because runtime tests were not executable in this environment.

Testing

  • Not run (automation): pnpm is unavailable in this runner (pnpm: command not found).

open-codesign Bot

@hqhq1025 hqhq1025 merged commit 79c49ad into main Apr 18, 2026
5 of 6 checks passed
@hqhq1025 hqhq1025 deleted the wt/ux-disabled-tooltip branch April 18, 2026 19:23
hqhq1025 added a commit that referenced this pull request Apr 19, 2026
* feat(ui): add tooltips explaining why every disabled button is disabled

Implements §9 P1 of the UX master plan. Every disabled interactive
button now shows a tooltip on hover explaining why it cannot be clicked.

- Sidebar send button: "Type a prompt to start" / "Generation in progress"
- InlineCommentComposer apply button: "Type a comment to apply" / "Generation in progress"
- PreviewToolbar export button: "Generate a design first"
- Settings validate button: "Enter an API key to validate"
- Settings save-provider button: "Validate the key first"
- Onboarding PasteKey test-connection button: "Enter provider, key and base URL to test"
- Onboarding PasteKey continue button: "Validate your API key to continue"
- Onboarding ChooseModel back/finish buttons: "Saving in progress" / "Both model fields are required"
- Onboarding Welcome Ollama button: "Ollama integration is coming in v0.2"

Tooltip strings added to `disabledReason.*` namespace in both en and
zh-CN locales. Tooltip component updated to accept `label?: string` and
skip rendering when label is undefined, so enabled buttons stay clean.

Signed-off-by: hqhq1025 <[email protected]>

* fix(ui): wire disabled tooltip labels through i18n

All four files that added hardcoded English strings in the previous
tooltip round now use t('disabledReason.*') instead. No new i18n keys
were required — all keys already existed in both en.json and zh-CN.json.

Signed-off-by: hqhq1025 <[email protected]>

* test(ui): cover Tooltip fallback / disabled / hover states

Signed-off-by: hqhq1025 <[email protected]>

* fix(ui): make disabled tooltip keyboard accessible (focusable wrapper + aria-describedby)

Signed-off-by: hqhq1025 <[email protected]>

* fix(ui): make Tooltip wrapper focusable only when child disabled

Previously the Tooltip wrapper was unconditionally `tabIndex={0}`, which
added a redundant extra tab stop in front of every enabled control it
wrapped. Now we inspect `children.props.disabled` and only set
`tabIndex={0}` when the wrapped element is actually disabled — enabled
elements stay the sole focus target.

`aria-describedby` remains on the wrapper unconditionally so screen
readers announce the reason whether focus lands on the wrapper or on
the inner control.

Tests:
- "tabindex=0 only when disabled" replaces the previous unconditional check
- "omits tabindex when enabled" guards against regression
- "tab order" verifies the inner button is the first focus target when
  enabled and the wrapper takes over only when disabled

Signed-off-by: hqhq1025 <[email protected]>

---------

Signed-off-by: hqhq1025 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant