diff --git a/CHANGELOG.md b/CHANGELOG.md index a031a33..a28580e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -217,6 +217,48 @@ _Feature module implementation (iam, billing, workspace, discovery) in progress. ### Changed +- **UX — MVP usability polish** (`feature/mvp-ux-polish`): a refinement pass on the MVP surfaces driven by + a heuristic evaluation with 6 users (Nielsen, impeccable critique: live session 20 → 26/40, stories + 20 → 24/40), keeping the brand, behaviour and copy. + - *Live session*: the AI suggestion queue docks to the feed column as a framed **review tray** ("AI + suggestions to review", counter with prev/next, minimize) instead of floating over the session bar, + header actions and side panel; from `sm` it stops short of the feed bottom so the latest lines stay + visible. ←/→ browse it, Esc minimizes it, focus returns to it after a decision, and focusing a feed + control it covers minimizes it (WCAG 2.4.11). A status line at the live edge says what the AI is doing — listening + (with the last suggestion's age), paused, **processing the final stories after Stop**, or failed with + the backend reason. The transcript recedes into neutral bubbles with visible speaker/time + ("Participante n" in Spanish); human decisions become compact "accepted/resolved by the analyst" + rows; accepting confirms that the story landed in the backlog as a draft. + - *AI vs human provenance*: new `ai` / `verified` / `pending` tokens. Everything the AI proposed reads + violet (suggestion cards, generated stories, "IA" origin chip); human-validated content reads emerald; + drafts awaiting review read amber. Priority gets its own glyph scale (critical red, high orange, + medium/low neutral), so red is no longer brand, AI, danger and priority at once. + - *Readable stories*: `hlmInput` textareas grow with their content (they were clipped at a fixed + height); stories read as their sentence and criteria as **Gherkin** steps with a keyword gutter in the + suggestion card, side panel, story detail and create page. The story detail leads with the title, + review status (with what it means) and origin, links an AI story to its capture session, and flags + unsaved story and criterion edits. + - *Backlog order*: sortable headers with a direction arrow and `aria-sort`, a visible "sorted by" + caption, priority re-ordered by meaning on the page (the API sorts the enum alphabetically), origin + and status chips (draft as an amber dashed outline), a page-scrolling chip-row layout below `sm`, + unambiguous dates, an empty state with next steps and retry on load errors. Story forms flag empty + required fields inline. + - *Audio source*: the picker is a labelled segmented control (In person / Virtual meeting) next to a + record button with a record dot and label; the virtual-meeting guidance is a calm two-step note + (share the meeting tab with its audio; wear headphones) and the missing-audio notice offers "Share + again" in place. + - *Project forms*: only the name is required and both forms say so; the optional technical profile is + one group (business context + tech stack, example placeholders, Enter-to-add hint). Settings go from + eight one-field cards to General and Technical profile, and the logo card is titled for the project. + - *Accessibility*: the brand red fill is `#dc2626` so white labels pass AA (4.83:1), red text in dark + uses a lighter step, muted text and the default focus outline meet AA, filter inputs show focus, + story titles are keyboard links, panel tabs/sort chips expose their state, the mobile session-bar + status stays announced, and new suggestions are announced politely. All measured chips are ≥ 4.5:1 + in both themes. + - Report screenshots (light, 1440×900, Spanish demo data) in `docs/screenshots/mvp/`. New + `discovery.ai.*`, `discovery.queue.*`, `discovery.suggestion.*`, `stories.statusHint.*`, + `stories.origin.*`, `storyForm.*`, `projectCreate.*`, `projectSettings.*` and `common.optional/required` + keys (EN + ES); `discovery.source.hint` is replaced by `hintShare` / `hintHeadphones`. - **Core — centralized backend error handling** (`feature/frontend-error-handling`): a shared `messageForError` helper resolves backend errors by their machine-readable `code` against a single top-level `errors.` i18n block (network / per-status / generic fallback chain). Extended from the diff --git a/docs/screenshots/mvp/01-proyectos.png b/docs/screenshots/mvp/01-proyectos.png new file mode 100644 index 0000000..ad019ec Binary files /dev/null and b/docs/screenshots/mvp/01-proyectos.png differ diff --git a/docs/screenshots/mvp/02-nuevo-proyecto.png b/docs/screenshots/mvp/02-nuevo-proyecto.png new file mode 100644 index 0000000..b448507 Binary files /dev/null and b/docs/screenshots/mvp/02-nuevo-proyecto.png differ diff --git a/docs/screenshots/mvp/03-ajustes-proyecto.png b/docs/screenshots/mvp/03-ajustes-proyecto.png new file mode 100644 index 0000000..6eeec8d Binary files /dev/null and b/docs/screenshots/mvp/03-ajustes-proyecto.png differ diff --git a/docs/screenshots/mvp/04-captura-sesion-en-vivo.png b/docs/screenshots/mvp/04-captura-sesion-en-vivo.png new file mode 100644 index 0000000..046dc46 Binary files /dev/null and b/docs/screenshots/mvp/04-captura-sesion-en-vivo.png differ diff --git a/docs/screenshots/mvp/05-captura-editar-sugerencia.png b/docs/screenshots/mvp/05-captura-editar-sugerencia.png new file mode 100644 index 0000000..06b0fc6 Binary files /dev/null and b/docs/screenshots/mvp/05-captura-editar-sugerencia.png differ diff --git a/docs/screenshots/mvp/06-captura-actualizar-historia.png b/docs/screenshots/mvp/06-captura-actualizar-historia.png new file mode 100644 index 0000000..151fb28 Binary files /dev/null and b/docs/screenshots/mvp/06-captura-actualizar-historia.png differ diff --git a/docs/screenshots/mvp/07-captura-pregunta-ia.png b/docs/screenshots/mvp/07-captura-pregunta-ia.png new file mode 100644 index 0000000..aba5051 Binary files /dev/null and b/docs/screenshots/mvp/07-captura-pregunta-ia.png differ diff --git a/docs/screenshots/mvp/08-captura-procesando.png b/docs/screenshots/mvp/08-captura-procesando.png new file mode 100644 index 0000000..eb3fc02 Binary files /dev/null and b/docs/screenshots/mvp/08-captura-procesando.png differ diff --git a/docs/screenshots/mvp/09-selector-audio-reunion-virtual.png b/docs/screenshots/mvp/09-selector-audio-reunion-virtual.png new file mode 100644 index 0000000..e6cf2e8 Binary files /dev/null and b/docs/screenshots/mvp/09-selector-audio-reunion-virtual.png differ diff --git a/docs/screenshots/mvp/10-selector-audio-presencial.png b/docs/screenshots/mvp/10-selector-audio-presencial.png new file mode 100644 index 0000000..70dc18c Binary files /dev/null and b/docs/screenshots/mvp/10-selector-audio-presencial.png differ diff --git a/docs/screenshots/mvp/11-backlog-historias.png b/docs/screenshots/mvp/11-backlog-historias.png new file mode 100644 index 0000000..d029f97 Binary files /dev/null and b/docs/screenshots/mvp/11-backlog-historias.png differ diff --git a/docs/screenshots/mvp/12-backlog-orden-prioridad.png b/docs/screenshots/mvp/12-backlog-orden-prioridad.png new file mode 100644 index 0000000..9ca529f Binary files /dev/null and b/docs/screenshots/mvp/12-backlog-orden-prioridad.png differ diff --git a/docs/screenshots/mvp/13-detalle-historia.png b/docs/screenshots/mvp/13-detalle-historia.png new file mode 100644 index 0000000..6caecdd Binary files /dev/null and b/docs/screenshots/mvp/13-detalle-historia.png differ diff --git a/docs/screenshots/mvp/14-detalle-historia-gherkin.png b/docs/screenshots/mvp/14-detalle-historia-gherkin.png new file mode 100644 index 0000000..382b647 Binary files /dev/null and b/docs/screenshots/mvp/14-detalle-historia-gherkin.png differ diff --git a/public/i18n/en.json b/public/i18n/en.json index 491ae3e..d28dcf1 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -7,7 +7,9 @@ "create": "Create", "loading": "Loading", "back": "Back", - "remove": "Remove" + "remove": "Remove", + "optional": "Optional", + "required": "Required" }, "toast": { "dismiss": "Dismiss", @@ -374,8 +376,14 @@ "projectCreate": { "title": "New project", "subtitle": "Give it a name. You can add the tech profile later — it helps the AI but is optional.", - "advanced": "Advanced options", - "advancedHint": "Optional context the AI uses to tailor its suggestions." + "advanced": "Tech profile", + "advancedHint": "Optional context the AI uses to tailor its suggestions.", + "namePlaceholder": "e.g. Patient portal", + "descriptionPlaceholder": "What problem does it solve, and for whom?", + "contextGroup": "Business context", + "stackGroup": "Tech stack", + "chipHint": "Type a value and press Enter to add it.", + "example": "e.g. {{value}}" }, "orgPicker": { "title": "Choose an organization", @@ -670,7 +678,10 @@ "saved": "Changes saved.", "save": "Save changes", "errorValidation": "Check the data and try again.", - "errorGeneric": "Could not save the changes." + "errorGeneric": "Could not save the changes.", + "logo": "Project logo", + "general": "General", + "techProfile": "Tech profile" }, "projectRoles": { "title": "Roles", @@ -882,7 +893,23 @@ "REJECTED": "Rejected", "MERGED": "Merged", "EXPORTED": "Exported" - } + }, + "statusHint": { + "DRAFT": "Draft: not approved yet; you can still edit it before approving.", + "APPROVED": "Approved: validated by the team.", + "REJECTED": "Rejected: discarded by the team.", + "MERGED": "Merged: folded into another story.", + "EXPORTED": "Exported: pushed to an external tracker (e.g. Jira)." + }, + "origin": { + "ai": "AI", + "manual": "Manual", + "aiHint": "Generated by the AI in a capture session", + "manualHint": "Created manually" + }, + "colOrigin": "Origin", + "sortedBy": "Sorted by: {{sort}}", + "emptyRecord": "Record a session" }, "storyForm": { "back": "Back to stories", @@ -918,7 +945,16 @@ "criteriaSaved": "Criterion saved.", "criteriaDeleted": "Criterion removed.", "criteriaIncomplete": "Fill in Given, When and Then for each criterion.", - "criteriaPartial": "Story created, but {{count}} criterion(s) could not be saved. Add them here." + "criteriaPartial": "Story created, but {{count}} criterion(s) could not be saved. Add them here.", + "storySection": "User story", + "leadRole": "As a…", + "leadAction": "I want…", + "leadBenefit": "so that…", + "viewSession": "View source session", + "unsaved": "Unsaved changes", + "criteriaNew": "New · not saved", + "criteriaHint": "Write each criterion as a Gherkin scenario: Given (context), When (action), Then (expected outcome).", + "required": "Fill in this field." }, "glossaryPage": { "title": "Glossary", @@ -1044,7 +1080,7 @@ "sessionSeparator": "Session", "sessionStories": "{{count}} stories", "sessionDefaultTitle": "Discovery session — {{date}}", - "generatedStory": "Generated story", + "generatedStory": "Generated by AI", "speaker": "Speaker {{n}}", "goToStory": "Go to story", "time": { @@ -1062,7 +1098,11 @@ }, "decision": { "accepted": "Accepted", - "dismissed": "Dismissed" + "dismissed": "Dismissed", + "acceptedBy": "Accepted by the analyst", + "resolvedBy": "Resolved by the analyst", + "acceptedToast": "Suggestion accepted: the story is in the backlog as a draft.", + "resolvedToast": "Question marked as resolved." }, "live": { "banner": "Live session in progress — {{language}}" @@ -1074,7 +1114,8 @@ "composer": { "placeholder": "Message discovery…", "comingSoon": "Text to pipeline is coming soon.", - "record": "Start recording" + "record": "Start recording", + "recordShort": "Record" }, "language": { "label": "Meeting language", @@ -1087,7 +1128,10 @@ "mic": "In person (microphone)", "meeting": "Virtual meeting (microphone + meeting audio)", "unsupported": "Virtual meeting: available in desktop Chrome or Edge.", - "hint": "When sharing, pick the meeting tab and tick \"Share tab audio\". Use headphones to avoid a duplicated transcript." + "micShort": "In person", + "meetingShort": "Virtual meeting", + "hintShare": "When you press Record, pick the meeting tab and turn on “Share tab audio”.", + "hintHeadphones": "Wear headphones so your microphone doesn't pick up the meeting again and duplicate the transcript." }, "bar": { "recording": "Recording", @@ -1097,7 +1141,8 @@ "stop": "Stop", "meetingAudio": "Recording microphone + meeting audio", "meetingAudioLost": "Meeting audio is not being captured. Share the meeting tab again.", - "reshare": "Share again" + "reshare": "Share again", + "meetingAudioShort": "Meeting audio" }, "rec": { "micDenied": "Microphone permission denied. Enable it in your browser settings.", @@ -1113,13 +1158,17 @@ }, "queue": { "pendingBadge": "{{count}} suggestions pending", - "pendingBadgeLabel": "pending suggestions", + "pendingBadgeLabel": "suggestions to review", "counter": "{{n}} of {{m}}", "prev": "Previous suggestion", "next": "Next suggestion", "collapse": "Collapse", "minimize": "Minimize", - "dragHint": "Drag to browse suggestions" + "dragHint": "Drag to browse suggestions", + "title": "AI suggestions to review", + "announce": "{{count}} AI suggestions to review", + "expand": "Show the suggestions to review", + "titleShort": "AI · to review" }, "suggestion": { "type": { @@ -1151,7 +1200,7 @@ "saveAndAccept": "Save & accept", "dismiss": "Dismiss", "edit": "Edit", - "cancelEdit": "Cancel", + "cancelEdit": "Cancel editing", "done": "Done", "titleField": "Title", "scenario": "Scenario", @@ -1165,7 +1214,16 @@ "removeCriterion": "Remove criterion", "given": "Given", "when": "when", - "then": "then" + "then": "then", + "aiLabel": "AI suggestion", + "aiHint": "Proposed by the AI from the conversation. Awaiting your review.", + "questionHint": "Resolving it only marks it as addressed; the backlog doesn't change.", + "changedHint": "Highlighted: what the AI proposes to change", + "criteriaCount": "Acceptance criteria ({{count}})", + "roleField": "Role · As a…", + "actionField": "Action · I want…", + "benefitField": "Benefit · so that…", + "scenarioPlaceholder": "Optional" }, "panel": { "stories": "Stories", @@ -1227,6 +1285,19 @@ "body": "Leave this page anyway? The session keeps recording in the background and stays active.", "stay": "Stay", "leave": "Leave anyway" + }, + "retry": "Retry", + "pendingReview": "Awaiting review", + "ai": { + "listening": "The AI is listening to the conversation", + "listeningHint": "It proposes stories, edge cases and questions as the meeting goes on; they appear above for your review.", + "lastSuggestion": "Last suggestion {{time}} · review it in the tray above.", + "paused": "Recording paused", + "pausedHint": "The AI resumes its analysis when you resume recording.", + "processing": "Processing the session", + "processingHint": "The AI is generating the final stories. This can take a few minutes.", + "failed": "The session could not be processed", + "failedHint": "Check the session history or try again later." } }, "integrations": { diff --git a/public/i18n/es.json b/public/i18n/es.json index b856dcb..f28d1ac 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -7,7 +7,9 @@ "create": "Crear", "loading": "Cargando", "back": "Volver", - "remove": "Quitar" + "remove": "Quitar", + "optional": "Opcional", + "required": "Obligatorio" }, "toast": { "dismiss": "Descartar", @@ -374,8 +376,14 @@ "projectCreate": { "title": "Nuevo proyecto", "subtitle": "Dale un nombre. Puedes agregar el perfil técnico luego — ayuda a la IA pero es opcional.", - "advanced": "Opciones avanzadas", - "advancedHint": "Contexto opcional que la IA usa para afinar sus sugerencias." + "advanced": "Perfil técnico", + "advancedHint": "Contexto opcional que la IA usa para afinar sus sugerencias.", + "namePlaceholder": "p. ej. Portal de citas", + "descriptionPlaceholder": "¿Qué problema resuelve y para quién?", + "contextGroup": "Contexto del negocio", + "stackGroup": "Stack técnico", + "chipHint": "Escribe un valor y pulsa Enter para añadirlo.", + "example": "p. ej. {{value}}" }, "orgPicker": { "title": "Elige una organización", @@ -670,7 +678,10 @@ "saved": "Cambios guardados.", "save": "Guardar cambios", "errorValidation": "Revisa los datos e intenta de nuevo.", - "errorGeneric": "No se pudieron guardar los cambios." + "errorGeneric": "No se pudieron guardar los cambios.", + "logo": "Logo del proyecto", + "general": "General", + "techProfile": "Perfil técnico" }, "projectRoles": { "title": "Roles", @@ -882,7 +893,23 @@ "REJECTED": "Rechazada", "MERGED": "Fusionada", "EXPORTED": "Exportada" - } + }, + "statusHint": { + "DRAFT": "Borrador: todavía no está aprobada; puedes editarla antes de aprobarla.", + "APPROVED": "Aprobada: validada por el equipo.", + "REJECTED": "Rechazada: el equipo la descartó.", + "MERGED": "Fusionada: se integró en otra historia.", + "EXPORTED": "Exportada: enviada a un gestor externo (p. ej., Jira)." + }, + "origin": { + "ai": "IA", + "manual": "Manual", + "aiHint": "Generada por la IA en una sesión de captura", + "manualHint": "Creada manualmente" + }, + "colOrigin": "Origen", + "sortedBy": "Ordenadas por: {{sort}}", + "emptyRecord": "Grabar una sesión" }, "storyForm": { "back": "Volver a historias", @@ -918,7 +945,16 @@ "criteriaSaved": "Criterio guardado.", "criteriaDeleted": "Criterio eliminado.", "criteriaIncomplete": "Completa Dado, Cuando y Entonces en cada criterio.", - "criteriaPartial": "Historia creada, pero {{count}} criterio(s) no se pudieron guardar. Añádelos aquí." + "criteriaPartial": "Historia creada, pero {{count}} criterio(s) no se pudieron guardar. Añádelos aquí.", + "storySection": "Historia de usuario", + "leadRole": "Como…", + "leadAction": "quiero…", + "leadBenefit": "para…", + "viewSession": "Ver la sesión de origen", + "unsaved": "Cambios sin guardar", + "criteriaNew": "Nuevo · sin guardar", + "criteriaHint": "Escribe cada criterio como escenario Gherkin: Dado (contexto), Cuando (acción), Entonces (resultado esperado).", + "required": "Completa este campo." }, "glossaryPage": { "title": "Glosario", @@ -1044,8 +1080,8 @@ "sessionSeparator": "Sesión", "sessionStories": "{{count}} historias", "sessionDefaultTitle": "Sesión de discovery — {{date}}", - "generatedStory": "Historia generada", - "speaker": "Usuario {{n}}", + "generatedStory": "Generada por la IA", + "speaker": "Participante {{n}}", "goToStory": "Ir a la historia", "time": { "justNow": "hace un momento", @@ -1062,7 +1098,11 @@ }, "decision": { "accepted": "Aceptada", - "dismissed": "Descartada" + "dismissed": "Descartada", + "acceptedBy": "Aceptada por el analista", + "resolvedBy": "Resuelta por el analista", + "acceptedToast": "Sugerencia aceptada: la historia quedó en el backlog como borrador.", + "resolvedToast": "Pregunta marcada como resuelta." }, "live": { "banner": "Sesión en vivo en curso — {{language}}" @@ -1074,7 +1114,8 @@ "composer": { "placeholder": "Escribe a discovery…", "comingSoon": "La entrada de texto llegará pronto.", - "record": "Iniciar grabación" + "record": "Iniciar grabación", + "recordShort": "Grabar" }, "language": { "label": "Idioma de la reunión", @@ -1087,7 +1128,10 @@ "mic": "Presencial (micrófono)", "meeting": "Reunión virtual (micrófono + audio de la reunión)", "unsupported": "Reunión virtual: disponible en Chrome o Edge de escritorio.", - "hint": "Al compartir, elige la pestaña de la reunión y marca «Compartir audio de la pestaña». Usa audífonos para no duplicar la transcripción." + "micShort": "Presencial", + "meetingShort": "Reunión virtual", + "hintShare": "Al pulsar Grabar, elige la pestaña de la reunión y activa «Compartir audio de la pestaña».", + "hintHeadphones": "Usa audífonos: así tu micrófono no vuelve a captar la reunión y la transcripción no se duplica." }, "bar": { "recording": "Grabando", @@ -1097,7 +1141,8 @@ "stop": "Detener", "meetingAudio": "Grabando micrófono + audio de la reunión", "meetingAudioLost": "No se está capturando el audio de la reunión. Vuelve a compartir la pestaña de la reunión.", - "reshare": "Compartir de nuevo" + "reshare": "Compartir de nuevo", + "meetingAudioShort": "Audio de la reunión" }, "rec": { "micDenied": "Permiso de micrófono denegado. Habilítalo en los ajustes de tu navegador.", @@ -1113,13 +1158,17 @@ }, "queue": { "pendingBadge": "{{count}} sugerencias pendientes", - "pendingBadgeLabel": "sugerencias pendientes", + "pendingBadgeLabel": "sugerencias por revisar", "counter": "{{n}} de {{m}}", "prev": "Sugerencia anterior", "next": "Sugerencia siguiente", "collapse": "Contraer", "minimize": "Minimizar", - "dragHint": "Arrastra para navegar las sugerencias" + "dragHint": "Arrastra para navegar las sugerencias", + "title": "Sugerencias de la IA por revisar", + "announce": "{{count}} sugerencias de la IA por revisar", + "expand": "Mostrar las sugerencias por revisar", + "titleShort": "IA · por revisar" }, "suggestion": { "type": { @@ -1151,7 +1200,7 @@ "saveAndAccept": "Guardar y aceptar", "dismiss": "Descartar", "edit": "Editar", - "cancelEdit": "Cancelar", + "cancelEdit": "Cancelar edición", "done": "Listo", "titleField": "Título", "scenario": "Escenario", @@ -1165,7 +1214,16 @@ "removeCriterion": "Eliminar criterio", "given": "Dado", "when": "cuando", - "then": "entonces" + "then": "entonces", + "aiLabel": "Sugerencia de la IA", + "aiHint": "Propuesta por la IA a partir de la conversación. Pendiente de tu revisión.", + "questionHint": "Resolverla solo la marca como atendida; no cambia el backlog.", + "changedHint": "Resaltado: lo que la IA propone cambiar", + "criteriaCount": "Criterios de aceptación ({{count}})", + "roleField": "Rol · Como…", + "actionField": "Acción · quiero…", + "benefitField": "Beneficio · para…", + "scenarioPlaceholder": "Opcional" }, "panel": { "stories": "Historias", @@ -1227,6 +1285,19 @@ "body": "¿Salir de esta página de todos modos? La sesión sigue grabando en segundo plano y permanece activa.", "stay": "Quedarse", "leave": "Salir de todos modos" + }, + "retry": "Reintentar", + "pendingReview": "Pendiente de revisión", + "ai": { + "listening": "La IA está escuchando la conversación", + "listeningHint": "Propone historias, casos límite y preguntas a medida que avanza la reunión; aparecerán arriba para tu revisión.", + "lastSuggestion": "Última sugerencia {{time}} · revísala en la bandeja de arriba.", + "paused": "Grabación en pausa", + "pausedHint": "La IA retomará el análisis cuando reanudes la grabación.", + "processing": "Procesando la sesión", + "processingHint": "La IA está generando las historias finales. Puede tardar unos minutos.", + "failed": "No se pudo procesar la sesión", + "failedHint": "Revisa el historial de sesiones o vuelve a intentarlo más tarde." } }, "integrations": { diff --git a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts index 29e34e8..771802a 100644 --- a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts @@ -29,6 +29,16 @@ describe('AudioSourcePicker', () => { expect(option(el, 'mic').getAttribute('aria-pressed')).toBe('false'); }); + it('labels each source with visible text, not an icon alone', () => { + const { el } = render(); + + expect(option(el, 'mic').textContent).toContain('discovery.source.micShort'); + expect(option(el, 'meeting').textContent).toContain('discovery.source.meetingShort'); + // The full description stays the accessible name and tooltip. + expect(option(el, 'meeting').getAttribute('aria-label')).toContain('discovery.source.meeting'); + expect(option(el, 'meeting').getAttribute('aria-label')).not.toContain('meetingShort'); + }); + it('switches to the virtual meeting source', () => { const { fixture, el } = render(); const changes: AudioSource[] = []; diff --git a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts index d8f8039..4db5b3a 100644 --- a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts @@ -8,13 +8,17 @@ import { HlmIcon } from '../../../../shared/ui'; interface SourceOption { value: AudioSource; icon: string; + /** Full description: the accessible name and tooltip. */ label: string; + /** Short visible label (always a prefix of the full one, so the name matches what is seen). */ + short: string; } /** - * Compact segmented control next to the record button: where the next - * recording takes its audio from — the microphone (in person) or the - * microphone plus the meeting's audio (virtual call). Two-way bound via + * Labelled segmented control next to the record button: where the next + * recording takes its audio from — "In person" (microphone) or "Virtual + * meeting" (microphone plus the meeting's audio). Visible text, not icons + * alone, so it is never confused with the record button. Two-way bound via * `[(value)]`. Where the browser cannot capture meeting audio the virtual * option stays visible but inert, with the reason as its tooltip; tapping it * emits `unavailablePicked` so touch users get the reason too. @@ -24,11 +28,12 @@ interface SourceOption { changeDetection: ChangeDetectionStrategy.OnPush, imports: [HlmIcon, TranslocoPipe], viewProviders: [provideIcons({ lucideMic, lucideMonitorSpeaker })], + host: { class: 'block' }, template: `
@for (opt of options; track opt.value) { @@ -40,11 +45,12 @@ interface SourceOption { [attr.aria-disabled]="disabled() || unavailable" [attr.aria-label]="opt.label | transloco" [title]="(unavailable ? 'discovery.source.unsupported' : opt.label) | transloco" - class="grid h-full w-9 place-items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + class="flex h-full min-w-0 flex-1 items-center justify-center gap-1.5 whitespace-nowrap sm:flex-none rounded-full px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" [class]="optionClass(opt.value, unavailable)" [attr.data-testid]="'audio-source-' + opt.value" > - +
@@ -59,8 +65,18 @@ export class AudioSourcePicker { readonly unavailablePicked = output(); protected readonly options: SourceOption[] = [ - { value: 'mic', icon: 'lucideMic', label: 'discovery.source.mic' }, - { value: 'meeting', icon: 'lucideMonitorSpeaker', label: 'discovery.source.meeting' }, + { + value: 'mic', + icon: 'lucideMic', + label: 'discovery.source.mic', + short: 'discovery.source.micShort', + }, + { + value: 'meeting', + icon: 'lucideMonitorSpeaker', + label: 'discovery.source.meeting', + short: 'discovery.source.meetingShort', + }, ]; protected choose(source: AudioSource): void { @@ -73,13 +89,14 @@ export class AudioSourcePicker { } protected optionClass(source: AudioSource, unavailable: boolean): string { + const selected = 'bg-accent text-accent-foreground ring-1 ring-inset ring-primary/25'; if (this.disabled() || unavailable) { return this.value() === source - ? 'cursor-not-allowed bg-secondary text-foreground opacity-60' + ? `cursor-not-allowed opacity-60 ${selected}` : 'cursor-not-allowed text-muted-foreground opacity-50'; } return this.value() === source - ? 'cursor-pointer bg-secondary text-foreground' - : 'cursor-pointer text-muted-foreground hover:text-foreground'; + ? `cursor-pointer ${selected}` + : 'cursor-pointer text-muted-foreground hover:bg-secondary/70 hover:text-foreground'; } } diff --git a/src/app/features/discovery/components/decision-queue/decision-queue.spec.ts b/src/app/features/discovery/components/decision-queue/decision-queue.spec.ts index 01d7ce8..1c5d5ba 100644 --- a/src/app/features/discovery/components/decision-queue/decision-queue.spec.ts +++ b/src/app/features/discovery/components/decision-queue/decision-queue.spec.ts @@ -168,15 +168,68 @@ describe('DecisionQueue', () => { expect(el.querySelector('[data-testid="queue-badge-count"]')?.textContent?.trim()).toBe('3'); }); - it('shows the "n de m" counter in the corner tab of the active card', () => { + it('shows the "n de m" counter in the tray header, outside the card body', () => { store.setQueue([suggestion({ id: 'a' }), suggestion({ id: 'b' })]); const el = render(); - const tab = el.querySelector('[data-testid="queue-counter"]'); - expect(tab).not.toBeNull(); + const counter = el.querySelector('[data-testid="queue-counter"]'); + expect(counter).not.toBeNull(); // The Transloco test module has no messages, so it echoes the key; the point - // is the counter lives in a dedicated tab element, not overlapping the body. - expect(tab?.classList.contains('queue-tab')).toBe(true); + // is the counter sits in the tray header (between prev/next), never over the card. + expect(el.querySelector('[data-testid="queue-card"]')?.contains(counter)).toBe(false); + expect(counter?.getAttribute('aria-live')).toBe('polite'); + }); + + it('browses with the arrow keys and minimizes on Escape', () => { + store.setQueue([suggestion({ id: 'a' }), suggestion({ id: 'b' })]); + const fixture = TestBed.createComponent(DecisionQueue); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + const tray = el.querySelector('section.queue-card')!; + + tray.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(store.queueIndex()).toBe(1); + tray.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + expect(store.queueIndex()).toBe(0); + + tray.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fixture.detectChanges(); + expect(el.querySelector('[data-testid="queue-badge"]')).not.toBeNull(); + }); + + it('leaves arrow keys alone while a field is being edited', () => { + store.setQueue([suggestion({ id: 'a' }), suggestion({ id: 'b' })]); + const fixture = TestBed.createComponent(DecisionQueue); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + const tray = el.querySelector('section.queue-card')!; + const field = document.createElement('textarea'); + tray.appendChild(field); + + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fixture.detectChanges(); + + expect(store.queueIndex()).toBe(0); + expect(el.querySelector('[data-testid="queue-badge"]')).toBeNull(); + }); + + it('docks inside the feed column instead of floating over the page', () => { + store.setQueue([suggestion()]); + const el = render(); + + const tray = el.querySelector('[data-testid="decision-queue"]'); + expect(tray?.classList.contains('absolute')).toBe(true); + expect(tray?.classList.contains('fixed')).toBe(false); + }); + + it('labels every pending card as an AI suggestion', () => { + store.setQueue([suggestion({ type: 'EDGE_CASE' })]); + const el = render(); + + const card = el.querySelector('[data-testid="suggestion-card"]'); + expect(card?.getAttribute('aria-label')).toContain('discovery.suggestion.aiLabel'); + expect(el.querySelector('[data-testid="suggestion-type"]')).not.toBeNull(); }); it('renders no decorative stack behind a single pending card', () => { @@ -250,7 +303,9 @@ describe('DecisionQueue', () => { swipe(handle, -300); fixture.detectChanges(); expect( - el.querySelector('[data-testid="queue-card"]')!.classList.contains('card-exiting'), + el + .querySelector('[data-testid="queue-card"]')! + .classList.contains('card-exiting'), ).toBe(true); // A second swipe mid-fling must be ignored — the index still hasn't advanced. diff --git a/src/app/features/discovery/components/decision-queue/decision-queue.ts b/src/app/features/discovery/components/decision-queue/decision-queue.ts index e23c592..275d8d2 100644 --- a/src/app/features/discovery/components/decision-queue/decision-queue.ts +++ b/src/app/features/discovery/components/decision-queue/decision-queue.ts @@ -1,12 +1,16 @@ import { ChangeDetectionStrategy, Component, + ElementRef, computed, effect, inject, input, + model, output, signal, + untracked, + viewChild, } from '@angular/core'; import { TranslocoPipe } from '@jsverse/transloco'; import { provideIcons } from '@ng-icons/core'; @@ -44,11 +48,12 @@ const EXIT_DURATION_MS = 200; const DRAG_HORIZONTAL_LOCK_PX = 8; /** - * The decision queue: pending AI suggestions as floating, non-modal cards - * anchored top-center over the feed. One card at a time with prev/next arrows - * (or a horizontal drag) and an "n of m" counter. The user can minimize it to a - * compact badge that expands on click; new suggestions only bump the count and - * never change that state. While a decision is in flight the card stays visible + * The decision queue: pending AI suggestions as a non-modal review tray docked + * to the top of the feed column (never over the session controls or the side + * panel). A tray header names it ("AI suggestions to review") and carries the + * "n of m" counter with prev/next; a horizontal drag on the card's top handle + * also browses. The user can minimize it to a compact badge that expands on + * click; new suggestions only bump the count and never change that state. While a decision is in flight the card stays visible * with a spinner and disabled actions, and leaves only once the store confirms * the resolution. */ @@ -59,84 +64,115 @@ const DRAG_HORIZONTAL_LOCK_PX = 8; viewProviders: [ provideIcons({ lucideChevronLeft, lucideChevronRight, lucideMinus, lucideSparkles, lucideX }), ], + host: { class: 'contents' }, template: ` @if (store.queue().length > 0) { +
@if (collapsed()) { - -
- -
+ } + {{ 'discovery.queue.pendingBadgeLabel' | transloco }} + } @else { - -
- -
- + + +
+ +
+
- - @if (store.queue().length > 1) { - - - } - -
+
@for (layer of stackLayers(); track layer) { +
}
@@ -309,7 +319,10 @@ export class DecisionQueue { readonly decideDismiss = output(); readonly openTarget = output(); - protected readonly collapsed = signal(false); + /** Minimized to the badge. Two-way bound so the page can fold it (e.g. focus moved under it). */ + readonly collapsed = model(false); + + private readonly tray = viewChild>('tray'); protected readonly safeIndex = computed(() => { const length = this.store.queue().length; @@ -520,6 +533,36 @@ export class DecisionQueue { effect(() => { if (this.store.queue().length === 0) this.collapsed.set(false); }); + // A decided card leaves the DOM with the focused button in it, dropping focus to the + // page body. Bring it back to the tray so keyboard review can carry on. + let previousLength = 0; + effect(() => { + const length = this.store.queue().length; + const shrank = length < previousLength; + previousLength = length; + if (!shrank || length === 0 || untracked(() => this.collapsed())) return; + setTimeout(() => { + const active = document.activeElement; + if (!active || active === document.body) this.tray()?.nativeElement.focus(); + }); + }); + } + + /** ←/→ browse the deck and Esc minimizes — unless the key belongs to a field being edited. */ + protected onTrayKeydown(event: KeyboardEvent): void { + const target = event.target as HTMLElement | null; + if (target?.closest('input, textarea, select, [contenteditable="true"], [role="listbox"]')) + return; + if (event.key === 'Escape') { + event.preventDefault(); + this.collapsed.set(true); + } else if (event.key === 'ArrowLeft' && this.safeIndex() > 0) { + event.preventDefault(); + this.prev(); + } else if (event.key === 'ArrowRight' && this.safeIndex() < this.store.queue().length - 1) { + event.preventDefault(); + this.next(); + } } /** Applies a committed drag outcome as a carousel navigation. */ diff --git a/src/app/features/discovery/components/gherkin-steps/gherkin-steps.ts b/src/app/features/discovery/components/gherkin-steps/gherkin-steps.ts new file mode 100644 index 0000000..b006234 --- /dev/null +++ b/src/app/features/discovery/components/gherkin-steps/gherkin-steps.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { AcceptanceCriterion } from '../../data/discovery.models'; + +/** + * Read-only Given/When/Then rendering of one acceptance criterion: the optional + * scenario as a heading, then one step per line behind a fixed keyword gutter, so + * a criterion reads like a Gherkin scenario instead of a run-on sentence. Shared + * by the suggestion card and the side panel's story cards. + */ +@Component({ + selector: 'app-gherkin-steps', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslocoPipe], + template: ` + @let c = criterion(); + @if (c.scenario) { +

+ {{ c.scenario }} +

+ } +
+ @for (step of steps; track step.key) { +
+ {{ step.label | transloco }} +
+
+ {{ c[step.key] }} +
+ } +
+ `, +}) +export class GherkinSteps { + readonly criterion = input.required(); + /** `sm` for dense lists (side panel), `md` for the suggestion card. */ + readonly size = input<'sm' | 'md'>('md'); + + protected readonly steps = [ + { key: 'given', label: 'discovery.suggestion.criteriaGiven' }, + { key: 'when', label: 'discovery.suggestion.criteriaWhen' }, + { key: 'then', label: 'discovery.suggestion.criteriaThen' }, + ] as const; +} diff --git a/src/app/features/discovery/components/session-bar/session-bar.spec.ts b/src/app/features/discovery/components/session-bar/session-bar.spec.ts index a1c202f..e4e76a8 100644 --- a/src/app/features/discovery/components/session-bar/session-bar.spec.ts +++ b/src/app/features/discovery/components/session-bar/session-bar.spec.ts @@ -69,20 +69,28 @@ describe('SessionBar', () => { expect(stop.getAttribute('title')).toBeTruthy(); }); - it('hides the pause/stop button labels and the status label below the sm breakpoint', () => { + it('hides the pause/stop button labels below the sm breakpoint', () => { const { el } = render(); const pause = el.querySelector('[data-testid="session-bar-pause"]') as HTMLButtonElement; const stop = el.querySelector('[data-testid="session-bar-stop"]') as HTMLButtonElement; - const status = el.querySelector('[data-testid="session-bar-status"]') as HTMLElement; // "hidden sm:inline" is the app's established mobile-icon-only pattern // (see the discovery header's history/panel-toggle buttons). expect(pause.querySelector('span')?.className).toContain('hidden'); expect(pause.querySelector('span')?.className).toContain('sm:inline'); expect(stop.querySelector('span')?.className).toContain('hidden'); - expect(status.className).toContain('hidden'); - expect(status.className).toContain('sm:inline'); + }); + + it('keeps the status label announced on mobile (visually hidden, not removed)', () => { + const { el } = render(); + + const status = el.querySelector('[data-testid="session-bar-status"]') as HTMLElement; + + expect(status.className).toContain('sr-only'); + expect(status.className).toContain('sm:not-sr-only'); + expect(status.className).not.toContain('hidden'); + expect(status.getAttribute('aria-live')).toBe('polite'); }); it('always renders the icon and the elapsed timer regardless of viewport', () => { diff --git a/src/app/features/discovery/components/session-bar/session-bar.ts b/src/app/features/discovery/components/session-bar/session-bar.ts index 6e1d28f..7896ada 100644 --- a/src/app/features/discovery/components/session-bar/session-bar.ts +++ b/src/app/features/discovery/components/session-bar/session-bar.ts @@ -67,13 +67,17 @@ export function formatElapsed(ms: number): string { > } @else { - + } - -