Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/i18n-inventory.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# i18n inventory (egdev6/quorb#66)

Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `ef32308b66a322ccdc40e9b6f088568bf510f7f2` and `src-tauri/src/` = `ce740f275529068830cd72c10266ea6a79315ac7`.
Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `68310535276fdf258b307ee468cd64af1e64a8d0` and `src-tauri/src/` = `ce740f275529068830cd72c10266ea6a79315ac7`.
Any commit whose `git rev-parse <commit>:src` and `<commit>:src-tauri/src` match has these exact texts.
Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`.

Expand Down
57 changes: 57 additions & 0 deletions odd/tasks/214-orb-status-badge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# ODD — #214 B mínima: indicador de falta de credencial en el orbe

Issue: https://github.com/egdev6/quorb/issues/214 (labels `status:approved`, `type:bug`)
Assignee verificado: SrStamm (Mirko Alexander Stamm). Decisión del mantenedor (#214, 2026-11-01): **no integrar C** (`transient_for`, rama `fix/214-wayland-detail-transient` de #230); ir con la **versión mínima de B**, con el ajuste aprobado: badge ámbar para `needsLogin` **y** `unconfigured`; `error` queda solo con motivo en texto accesible.

## Diseño aprobado por el mantenedor

- Badge visual **ámbar** (`data-kind='action'`, ya definido en `src/styles.css:185`) cuando `status === 'needsLogin'` o `status === 'unconfigured'`. Solo los estados que tienen acción asociada (`model.rs:120-124`: `needsLogin → 'login'`, `unconfigured → 'settings'`).
- Se suma al atenuado, no lo reemplaza: el orbe sigue con `is-muted` (clases en `gaugeState`, `ProviderGauge.tsx`).
- `aria-label` y `title` incorporan el motivo para los tres estados atenuados (`needsLogin`, `unconfigured`, `error`) reusando `details.status.*` de `src/i18n/es.ts:477-481` y `src/i18n/en.ts:452-456`. Orden del título: `[nombre, motivo, validez, reinicios]`.
- `title` deja de condicionarse solo a `account || validity || resets`: con motivo presente siempre se incluye.
- Badge decorativo (`aria-hidden`), esquina superior derecha, escala con `calc(9px * var(--k))`. Validar a mano que no choque con el chip de cuenta (`.orb__acct`) ni con `ValidityBadge` en 24/48/56 px.
- Plataformas: **todas** (el estado viene del modelo, no del compositor). Sin código Wayland-específico, sin Rust, sin ventanas nuevas, sin tocar `DetailPanel` ni `widget_placement`.

## Alcance por archivo

| Archivo | Cambio |
| --- | --- |
| `src/components/ProviderGauge.tsx` | Badge `<span class="orb__badge" data-kind='action' aria-hidden>` con `needsLogin`/`unconfigured`; `accessibleTitle` incorpora el motivo (`t(locale, 'details.status.<status>')` para estados atenuados); `title` se muestra siempre que haya motivo |
| `src/styles.css` | Sin cambios (`.orb__badge` ya existe; solo si la validación manual pide un ajuste mínimo) |
| `src/i18n/es.ts`, `src/i18n/en.ts` | Sin cambios (claves reutilizadas) |
| `src/components/ProviderGauge.test.tsx` | Tests RED/GREEN del punto de tests |
| `docs/i18n-inventory.md` | Sin cambios |

No-goals: C (`transient_for`), detalle B completo (panel inline en la tira), cambios Rust/i18n.

## Tareas

1. **[x] Tests RED** en `ProviderGauge.test.tsx`
- Rojo observado (vitest): 3 fallidos — falta badge en `needsLogin`/`unconfigured` y motivo en los tres estados atenuados; caso `ok` pasa como guardia de regresión del comportamiento actual.
- Bloque: `describe('ProviderGauge credential status badge')` (4 tests, estilista `renderToStaticMarkup` igual que el resto del archivo).
- La tarea original decía: (estilo `renderToStaticMarkup` existente):
- `needsLogin`: badge ámbar presente + motivo «Sin sesión» en `aria-label`.
- `unconfigured`: badge ámbar presente + motivo.
- `error`: motivo en `aria-label`/`title` pero **sin** badge.
- `ok`: ni badge ni motivo; título igual que hoy.
2. **[x] Implementación** en `src/components/ProviderGauge.tsx`: bloque `STATUS_REASON` (mapa cerrado `needsLogin`/`unconfigured`/`error` → `details.status.*`), `reason` entra al título accesible entre nombre y validez, `title` condicionado también por motivo, badge `<span class="orb__badge" data-kind="action" aria-hidden="true" />` solo para `needsLogin`/`unconfigured`. Verde: 23/23 en `ProviderGauge.test.tsx`.
3. **[x] Checks del repo**:
- `npm test`: 68 archivos vitest / **904 passed, 0 failed** + scripts node --test / **298 pass, 0 fail**.
- `npm run typecheck`: OK.
- `cargo test --locked` (desde `src-tauri/`): **402 passed, 0 failed**; `cargo fmt --check`: OK.
- `node scripts/i18n-inventory.mjs --check`: exit 0 (sin cambios de texto).
4. **[x] Validación manual** con `npm run app:dev` (máquina del reporte, Wayland nativo):
- `needsLogin`: tooltip con motivo, badge ámbar visible.
- `unconfigured` (proveedor con API key sin clave): «funciona bien» (badge + tooltip).
- `error` (red cortada a proveedor con credencial): tooltip «Provider · Error» y **sin badge**, según diseño.
- Nota observada: al abrir el panel por hover el tooltip desaparece y el panel sigue apareciendo centrado en Wayland nativo — limitación conocida de `main`, fuera de alcance de este cambio (decisión #230: sin C).
- Halo rojo al hover identificado como dependiente del color de aro elegido en Ajustes (`--ring-1`), no un estado.
- Tamaños: el punto escala y se ve bien; `GDK_BACKEND=x11` intacto.
5. **[x] Commit de unidad** en rama propia desde `main`: `fix/214-orb-status-badge` → commit `94e7334` (`fix(widget): el orbe avisa la falta de credencial con badge y motivo (#214)`; 3 archivos, +111/-3: implementación, tests y este documento).
6. **[ ] Evidencia y PR**: presentar evidencia al mantenedor en #214 y abrir la PR autorizada (ya con luz verde por el mantenedor: «Con ese ajuste… Adelante con la PR»), asignada a SrStamm, con la validación manual documentada; revalidar assignees/estado antes de abrir.

## Notas

- Evidencia del diagnóstico previo en #214: la ventana `detail` sí mapea en Wayland nativo; Mutter la centra y queda tapada (ubicación decide el compositor). Queda como contexto; este ODD no toca el detalle.
- La sonda de backend disponible (`display_kind()`, de #180) no se usa acá: `needsLogin`/`unconfigured` vienen del modelo.
- Mantenedor confirmó la cobertura de `unconfigured`: los proveedores con API key (`Status::Unconfigured`) son el caso concreto de los pasos de la issue.
34 changes: 34 additions & 0 deletions src/components/ProviderGauge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,40 @@ function colors(meters: Meter[], small = false, size = 60, id = 'claude') {

afterEach(() => vi.unstubAllGlobals());

describe('ProviderGauge credential status badge', () => {
function statusReport(status: Exclude<UsageReport['status'], 'warning'>): UsageReport {
return {
id: 'claude', name: 'Claude', kind: 'subscription', status, plan: null,
meters: [], spend: null, message: null, action: null, fetchedAt: '',
};
}
function gauge(status: Exclude<UsageReport['status'], 'warning'>) {
const html = renderToStaticMarkup(<ProviderGauge report={statusReport(status)} small={false} size={60} active={false} onHover={() => {}} />);
const ariaLabel = html.match(/aria-label="([^"]*)"/)?.[1] ?? '';
return { html, ariaLabel };
}

it.each(['needsLogin', 'unconfigured'] as const)('shows the amber action badge and reason for %s', (status) => {
const { html, ariaLabel } = gauge(status);
expect(html).toContain('orb__badge');
expect(html).toContain('data-kind="action"');
expect(ariaLabel).toMatch(/Claude · Sin (sesión|configurar)/);
});

it('exposes the reason for error without any badge', () => {
const { html, ariaLabel } = gauge('error');
expect(html).not.toContain('orb__badge');
expect(ariaLabel).toBe('Claude · Error');
});

it('keeps ok unchanged: no badge and no reason in the accessible title', () => {
const { html, ariaLabel } = gauge('ok');
expect(html).not.toContain('orb__badge');
expect(ariaLabel).toBe('Claude');
expect(html).not.toContain('title=');
});
});

describe('ProviderGauge popup focus', () => {
function gauge(userAgent: string, focusVisible: boolean) {
vi.stubGlobal('navigator', { userAgent });
Expand Down
23 changes: 20 additions & 3 deletions src/components/ProviderGauge.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ButtonHTMLAttributes, CSSProperties } from 'react';
import type { Locale, UsageReport } from '../lib/types';
import type { Locale, Status, UsageReport } from '../lib/types';
import { t } from '../i18n';
import { peakMeter } from '../lib/format';
import { meterColor } from '../lib/meter-colors';
import { ProviderLogo } from './ProviderLogo';
Expand All @@ -17,6 +18,14 @@ export function gaugeState(report: UsageReport, threshold = 90): 'over' | 'warni
return null;
}

/** Motivo accesible de los tres estados atenuados (#214): falta credencial, sin configurar o error.
* Solo `needsLogin` y `unconfigured` tienen acción asociada (login/settings), y esa pareja muestra badge. */
const STATUS_REASON: Partial<Record<Status, 'details.status.needsLogin' | 'details.status.unconfigured' | 'details.status.error'>> = {
needsLogin: 'details.status.needsLogin',
unconfigured: 'details.status.unconfigured',
error: 'details.status.error',
};

/**
* Círculo de la tira: Gauge del DS (hasta tres anillos concéntricos + logo), sin texto.
* Los detalles se ven en el panel que se abre al pasar el ratón.
Expand Down Expand Up @@ -61,7 +70,10 @@ export function ProviderGauge({
}) {
const validity = validityInfo(report, locale);
const resets = showResets ? resetCreditsInfo(report, locale) : null;
const accessibleTitle = [report.name, validity?.title, resets?.title].filter(Boolean).join(' · ');
// Motivo del estado atenuado (#214): entra al título accesible entre el nombre y la validez.
const reasonKey = STATUS_REASON[report.status];
const reason = reasonKey ? t(locale, reasonKey) : null;
const accessibleTitle = [report.name, reason, validity?.title, resets?.title].filter(Boolean).join(' · ');
const peak = peakMeter(report);
const all = report.meters.filter((m) => m.percent != null);
// Pequeños: un único anillo con el límite más cercano. Con círculos muy pequeños, máximo dos.
Expand All @@ -87,7 +99,7 @@ export function ProviderGauge({
data-status={report.status}
data-account={account}
aria-label={accessibleTitle}
title={account || validity || resets ? accessibleTitle : undefined}
title={account || reason || validity || resets ? accessibleTitle : undefined}
aria-expanded={active}
style={{ '--size': `${size}px`, '--w': `${w}px`, '--gap': `${gap}px` } as CSSProperties}
onMouseEnter={(e) => onHover(e.currentTarget)}
Expand All @@ -109,6 +121,11 @@ export function ProviderGauge({
<ProviderLogo id={report.id} name={report.name} />
</span>
<ValidityBadge report={report} compact showResets={showResets} locale={locale} />
{/* Badge ámbar (#214): solo los estados con acción; decorativo, el motivo legible va en el título. */}
{/* Sin badge para `error`: la señal queda solo en el texto accesible. */}
{report.status === 'needsLogin' || report.status === 'unconfigured' ? (
<span className="orb__badge" data-kind="action" aria-hidden="true" />
) : null}
{account && (
<span className="orb__acct" aria-hidden="true">
{account}
Expand Down
Loading