From 85e6212e06cff2b434401bcfc3d48c5b11d175c7 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 01:35:46 -0500 Subject: [PATCH 01/20] feat(ui): add provenance/review tokens and an AA-safe brand red Adds ai / verified / pending and priority tokens (light + dark) so AI-originated, human-validated and awaiting-review content stop sharing the brand red. The red fill moves to #dc2626 so white button labels pass AA (4.83:1); in dark, red used as text takes a lighter step. Muted text, the default focus outline and the active sidebar item now meet AA, and the nav indicator animates with transform. --- src/app/layout/shell/shell.ts | 10 ++--- src/styles.css | 81 ++++++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/app/layout/shell/shell.ts b/src/app/layout/shell/shell.ts index b332407..7c21135 100644 --- a/src/app/layout/shell/shell.ts +++ b/src/app/layout/shell/shell.ts @@ -156,7 +156,7 @@ interface Crumb { } @else { @@ -269,15 +269,15 @@ interface Crumb { position: absolute; left: 0; top: 50%; - height: 0; + height: 1.15rem; width: 3px; border-radius: 9999px; background: var(--primary); - transform: translateY(-50%); - transition: height 200ms cubic-bezier(0.16, 1, 0.3, 1); + transform: translateY(-50%) scaleY(0); + transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); } .nav-link-active::before { - height: 1.15rem; + transform: translateY(-50%) scaleY(1); } @media (prefers-reduced-motion: reduce) { .nav-link::before { diff --git a/src/styles.css b/src/styles.css index b9cadf7..9bc392b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5,6 +5,22 @@ @import '@spartan-ng/brain/hlm-tailwind-preset.css'; +@theme inline { + --color-ai: var(--ai); + --color-ai-soft: var(--ai-soft); + --color-ai-border: var(--ai-border); + --color-verified: var(--verified); + --color-verified-soft: var(--verified-soft); + --color-verified-border: var(--verified-border); + --color-pending: var(--pending); + --color-pending-soft: var(--pending-soft); + --color-pending-border: var(--pending-border); + --color-priority-critical: var(--priority-critical); + --color-priority-critical-soft: var(--priority-critical-soft); + --color-priority-high: var(--priority-high); + --color-priority-high-soft: var(--priority-high-soft); +} + /* CDK Overlay structural styles — required so connected popovers (org/project * switchers, user menu) position correctly and escape sidebar clipping. */ @import '@angular/cdk/overlay-prebuilt.css'; @@ -28,26 +44,43 @@ --card-foreground: #050d1a; --popover: #ffffff; --popover-foreground: #050d1a; - --primary: #ef4444; + --primary: #dc2626; --primary-foreground: #ffffff; --secondary: #f1f5f9; --secondary-foreground: #0f172a; --muted: #f1f5f9; - --muted-foreground: #64748b; + --muted-foreground: #5b6b82; --accent: #fef2f2; --accent-foreground: #b91c1c; - --destructive: #ef4444; + --destructive: #dc2626; --border: #dbe1ea; --input: #dbe1ea; - --ring: #ef4444; + --ring: #dc2626; --sidebar: #f2f4f7; --sidebar-foreground: #0f172a; - --sidebar-primary: #ef4444; + --sidebar-primary: #dc2626; --sidebar-primary-foreground: #ffffff; --sidebar-accent: #fef2f2; --sidebar-accent-foreground: #b91c1c; --sidebar-border: #dbe1ea; - --sidebar-ring: #ef4444; + --sidebar-ring: #dc2626; + + /* Provenance and review states (see .ai/.verified/.pending utilities below). AI-originated + * content reads violet, human-validated content emerald, awaiting-review amber, so "the AI + * suggested this" and "an analyst approved this" never share a colour with the brand red. */ + --ai: #6d28d9; + --ai-soft: #f3effe; + --ai-border: #c4b5fd; + --verified: #047857; + --verified-soft: #ecfdf5; + --verified-border: #a7f3d0; + --pending: #b45309; + --pending-soft: #fffbeb; + --pending-border: #fcd34d; + --priority-critical: #b91c1c; + --priority-critical-soft: #fef2f2; + --priority-high: #c2410c; + --priority-high-soft: #fff7ed; } :root.dark .theme-reqsai { @@ -59,7 +92,7 @@ --card-foreground: #ffffff; --popover: #0f2040; --popover-foreground: #ffffff; - --primary: #ef4444; + --primary: #dc2626; --primary-foreground: #ffffff; --secondary: #0f2040; --secondary-foreground: #e2e8f0; @@ -67,23 +100,43 @@ --muted-foreground: #94a3b8; --accent: #1a2b4a; --accent-foreground: #fecaca; - --destructive: #ef4444; + --destructive: #dc2626; --border: rgba(255, 255, 255, 0.12); --input: rgba(255, 255, 255, 0.16); --ring: #ef4444; --sidebar: #070f1f; --sidebar-foreground: #cbd5e1; - --sidebar-primary: #ef4444; + --sidebar-primary: #dc2626; --sidebar-primary-foreground: #ffffff; --sidebar-accent: #0f2040; --sidebar-accent-foreground: #fecaca; --sidebar-border: rgba(255, 255, 255, 0.1); --sidebar-ring: #ef4444; + + /* Red text on navy needs a lighter step than the red that white button labels sit on. */ + --primary-text: #f87171; + + --ai: #c4b5fd; + --ai-soft: rgba(139, 92, 246, 0.14); + --ai-border: rgba(167, 139, 250, 0.38); + --verified: #6ee7b7; + --verified-soft: rgba(16, 185, 129, 0.12); + --verified-border: rgba(52, 211, 153, 0.32); + --pending: #fcd34d; + --pending-soft: rgba(245, 158, 11, 0.12); + --pending-border: rgba(251, 191, 36, 0.32); + --priority-critical: #fca5a5; + --priority-critical-soft: rgba(239, 68, 68, 0.15); + --priority-high: #fdba74; + --priority-high-soft: rgba(249, 115, 22, 0.14); } @layer base { * { - @apply border-border outline-ring/50; + @apply border-border outline-ring outline-offset-2; + } + ::selection { + background-color: color-mix(in srgb, var(--primary) 22%, transparent); } body { @apply bg-background text-foreground; @@ -91,6 +144,14 @@ } } +/* Dark theme: red used as TEXT (links, active tabs) takes the lighter step so it stays + * legible on navy, while red FILLS keep --primary so white button labels pass AA. */ +@layer utilities { + .dark .text-primary { + color: var(--primary-text); + } +} + /* Motion: keep everything tasteful and reduced-motion-safe. The single guard at the * bottom disables route view transitions and every keyframe we add app-wide. */ @layer utilities { From c03af79e51fe8a73bdbe1b0439c872cbaf6852a4 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 01:36:04 -0500 Subject: [PATCH 02/20] fix(ui): let textareas grow with their content and style span labels hlmInput gave textareas the inputs' fixed h-10, so rows were ignored and story statements and Gherkin steps were clipped. Textareas now size to their content (field-sizing) from a two-line minimum. hlmLabel also styles span and legend labels, which rendered unstyled at 16px next to 14px labels. --- src/app/shared/ui/input/hlm-input.ts | 21 ++++++++++++++++----- src/app/shared/ui/label/hlm-label.ts | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/app/shared/ui/input/hlm-input.ts b/src/app/shared/ui/input/hlm-input.ts index c6555be..257f342 100644 --- a/src/app/shared/ui/input/hlm-input.ts +++ b/src/app/shared/ui/input/hlm-input.ts @@ -1,18 +1,29 @@ -import { Directive, computed, input } from '@angular/core'; +import { Directive, ElementRef, computed, inject, input } from '@angular/core'; import { cn } from '../../utils/cn'; +const BASE = + 'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ' + + 'ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none ' + + 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' + + 'disabled:cursor-not-allowed disabled:opacity-50'; + +/** + * Text field styling. Inputs get the fixed control height; textareas instead grow with + * their content (`field-sizing: content`, Chromium/Safari) from a two-line minimum, so + * long story statements and Gherkin steps are never clipped behind a fixed `h-10`. + */ @Directive({ selector: 'input[hlmInput], textarea[hlmInput]', host: { '[class]': '_computedClass()' }, }) export class HlmInput { readonly userClass = input('', { alias: 'class' }); + private readonly isTextarea = + inject>(ElementRef).nativeElement.tagName === 'TEXTAREA'; protected readonly _computedClass = computed(() => cn( - 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ' + - 'ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none ' + - 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' + - 'disabled:cursor-not-allowed disabled:opacity-50', + BASE, + this.isTextarea ? 'field-sizing-content min-h-16 resize-y leading-relaxed' : 'h-10', this.userClass(), ), ); diff --git a/src/app/shared/ui/label/hlm-label.ts b/src/app/shared/ui/label/hlm-label.ts index 98c3185..5861448 100644 --- a/src/app/shared/ui/label/hlm-label.ts +++ b/src/app/shared/ui/label/hlm-label.ts @@ -2,7 +2,7 @@ import { Directive, computed, input } from '@angular/core'; import { cn } from '../../utils/cn'; @Directive({ - selector: 'label[hlmLabel]', + selector: 'label[hlmLabel], span[hlmLabel], legend[hlmLabel]', host: { '[class]': '_computedClass()' }, }) export class HlmLabel { From dbbc123c80f3229aa2a1d84ebfb3ddf0a96a9fc0 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 01:36:05 -0500 Subject: [PATCH 03/20] feat(i18n): add the copy for the MVP UX polish (es, en) AI status lines, review-tray and provenance labels, Gherkin hints, story status explanations, the labelled audio-source picker and its two-step virtual-meeting guidance, and the optional/required markers for the project forms. Speakers read "Participante n" in Spanish so they no longer collide with the story role. --- public/i18n/en.json | 99 ++++++++++++++++++++++++++++++++++++------- public/i18n/es.json | 101 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 169 insertions(+), 31 deletions(-) diff --git a/public/i18n/en.json b/public/i18n/en.json index 491ae3e..7ef4901 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: awaiting review, not validated by the team yet.", + "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,15 @@ "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)." }, "glossaryPage": { "title": "Glossary", @@ -1044,7 +1079,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 +1097,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 +1113,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 +1127,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 +1140,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 +1157,16 @@ }, "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" }, "suggestion": { "type": { @@ -1151,7 +1198,7 @@ "saveAndAccept": "Save & accept", "dismiss": "Dismiss", "edit": "Edit", - "cancelEdit": "Cancel", + "cancelEdit": "Cancel editing", "done": "Done", "titleField": "Title", "scenario": "Scenario", @@ -1165,7 +1212,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 +1283,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..8e7316d 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: pendiente de revisión, aún no la validó el equipo.", + "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,15 @@ "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)." }, "glossaryPage": { "title": "Glosario", @@ -1044,8 +1079,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 +1097,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 +1113,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 +1127,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 +1140,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 +1157,16 @@ }, "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" }, "suggestion": { "type": { @@ -1151,7 +1198,7 @@ "saveAndAccept": "Guardar y aceptar", "dismiss": "Descartar", "edit": "Editar", - "cancelEdit": "Cancelar", + "cancelEdit": "Cancelar edición", "done": "Listo", "titleField": "Título", "scenario": "Escenario", @@ -1165,7 +1212,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 +1283,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": { From 5350bff5fec186f657da3cf6b4360da2524e24fe Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 01:36:06 -0500 Subject: [PATCH 04/20] feat(discovery): add Gherkin steps and priority, status and origin badges Shared presentational pieces: Given/When/Then steps behind a keyword gutter, a priority chip with a direction glyph (critical red, high orange, medium and low neutral), a review-status chip (draft = amber dashed, approved = emerald check) and an origin chip (AI from a session vs manual). Backlog stories in the chat store now keep their review status for the side panel. --- .../components/gherkin-steps/gherkin-steps.ts | 59 +++++++ .../components/story-badges/story-badges.ts | 162 ++++++++++++++++++ .../discovery/data/discovery-chat.store.ts | 1 + .../discovery/data/discovery.models.ts | 10 +- 4 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 src/app/features/discovery/components/gherkin-steps/gherkin-steps.ts create mode 100644 src/app/features/discovery/components/story-badges/story-badges.ts 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/story-badges/story-badges.ts b/src/app/features/discovery/components/story-badges/story-badges.ts new file mode 100644 index 0000000..4730fae --- /dev/null +++ b/src/app/features/discovery/components/story-badges/story-badges.ts @@ -0,0 +1,162 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { provideIcons } from '@ng-icons/core'; +import { + lucideChevronDown, + lucideChevronUp, + lucideChevronsUp, + lucideCircleCheck, + lucideCircleDashed, + lucideCircleX, + lucideEqual, + lucideGitMerge, + lucidePencilLine, + lucideSparkles, + lucideUpload, +} from '@ng-icons/lucide'; +import { HlmIcon } from '../../../../shared/ui'; + +const CHIP = + 'inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 font-medium'; + +interface ChipStyle { + icon: string; + tone: string; +} + +const PRIORITY: Record = { + CRITICAL: { + icon: 'lucideChevronsUp', + tone: 'bg-priority-critical-soft text-priority-critical', + }, + HIGH: { icon: 'lucideChevronUp', tone: 'bg-priority-high-soft text-priority-high' }, + MEDIUM: { icon: 'lucideEqual', tone: 'bg-secondary text-secondary-foreground' }, + LOW: { icon: 'lucideChevronDown', tone: 'bg-secondary text-muted-foreground' }, +}; + +const STATUS: Record = { + DRAFT: { + icon: 'lucideCircleDashed', + tone: 'bg-pending-soft text-pending ring-1 ring-inset ring-pending-border', + }, + APPROVED: { + icon: 'lucideCircleCheck', + tone: 'bg-verified-soft text-verified ring-1 ring-inset ring-verified-border', + }, + REJECTED: { icon: 'lucideCircleX', tone: 'bg-secondary text-muted-foreground' }, + MERGED: { icon: 'lucideGitMerge', tone: 'bg-secondary text-muted-foreground' }, + EXPORTED: { icon: 'lucideUpload', tone: 'bg-secondary text-secondary-foreground' }, +}; + +const ICONS = provideIcons({ + lucideChevronDown, + lucideChevronUp, + lucideChevronsUp, + lucideCircleCheck, + lucideCircleDashed, + lucideCircleX, + lucideEqual, + lucideGitMerge, + lucidePencilLine, + lucideSparkles, + lucideUpload, +}); + +/** + * Priority chip with a direction glyph (⇈ ↑ = ↓) so the level never rests on colour + * alone: critical red, high orange, medium/low neutral — red stays reserved for the + * truly urgent end of the scale instead of tinting half the backlog. + */ +@Component({ + selector: 'app-priority-badge', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmIcon, TranslocoPipe], + viewProviders: [ICONS], + template: ` + + + `, +}) +export class PriorityBadge { + readonly priority = input.required(); + readonly size = input<'sm' | 'md'>('md'); + + protected readonly key = computed(() => (this.priority() ?? 'MEDIUM').toUpperCase()); + protected readonly style = computed(() => PRIORITY[this.key()] ?? PRIORITY['MEDIUM']); + protected readonly classes = computed( + () => `${CHIP} ${this.size() === 'sm' ? 'text-[11px]' : 'text-xs'} ${this.style().tone}`, + ); +} + +/** + * Review-status chip. Draft (awaiting review) is amber with a dashed ring; approved + * (validated by the team) is emerald with a check — the two states the analyst must + * tell apart at a glance. The tooltip spells out what the status means. + */ +@Component({ + selector: 'app-story-status-badge', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmIcon, TranslocoPipe], + viewProviders: [ICONS], + template: ` + + + `, +}) +export class StoryStatusBadge { + readonly status = input.required(); + + protected readonly key = computed(() => { + const status = (this.status() ?? 'DRAFT').toUpperCase(); + return STATUS[status] ? status : 'DRAFT'; + }); + protected readonly style = computed(() => STATUS[this.key()]); + protected readonly classes = computed(() => `${CHIP} text-xs ${this.style().tone}`); +} + +/** + * Where a story came from: generated by the AI in a capture session (violet, sparkles) + * or written by hand. Stories carry their `sessionId` only when the AI produced them. + */ +@Component({ + selector: 'app-origin-badge', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmIcon, TranslocoPipe], + viewProviders: [ICONS], + template: ` + @if (fromSession()) { + + + } @else { + + + } + `, +}) +export class OriginBadge { + readonly sessionId = input(null); + protected readonly fromSession = computed(() => !!this.sessionId()); +} diff --git a/src/app/features/discovery/data/discovery-chat.store.ts b/src/app/features/discovery/data/discovery-chat.store.ts index ae3d117..867765b 100644 --- a/src/app/features/discovery/data/discovery-chat.store.ts +++ b/src/app/features/discovery/data/discovery-chat.store.ts @@ -104,6 +104,7 @@ function toDisplayStory(story: UserStoryResponse): DisplayStory { storyPoints: story.storyPoints, createdAt: story.createdAt ?? null, acceptanceCriteria: suggestionCriteria(story.acceptanceCriteria), + status: story.status ?? null, }; } diff --git a/src/app/features/discovery/data/discovery.models.ts b/src/app/features/discovery/data/discovery.models.ts index 3ba0b71..34b372e 100644 --- a/src/app/features/discovery/data/discovery.models.ts +++ b/src/app/features/discovery/data/discovery.models.ts @@ -1,13 +1,7 @@ /** Mirrors the discovery REST + realtime contracts (backend discovery context). */ export type SessionStatus = - | 'DRAFT' - | 'RECORDING' - | 'PAUSED' - | 'STOPPED' - | 'PROCESSING' - | 'COMPLETED' - | 'FAILED'; + 'DRAFT' | 'RECORDING' | 'PAUSED' | 'STOPPED' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; export interface DiscoverySessionResponse { id: string; @@ -202,6 +196,8 @@ export interface DisplayStory { * events, older REST payloads). */ acceptanceCriteria: AcceptanceCriterion[]; + /** Review status (DRAFT/APPROVED/…) when the source carried it (REST backlog). */ + status?: string | null; } export interface PageResponse { From 588454cef61bca538ac0e7a7c9f221f760f76994 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 01:36:07 -0500 Subject: [PATCH 05/20] feat(discovery): dock the AI review tray to the feed and mark suggestions as AI The decision queue no longer floats over the viewport: it docks to the feed column, so the session bar (pause/stop), the header actions and the side panel stay reachable. A tray header names it, carries the counter with prev/next and minimize, and announces changes. Suggestion cards carry violet AI provenance, read the story as its sentence and criteria as Gherkin steps, highlight every field an update changes, edit in labelled auto-growing fields, and keep Discard apart from Edit/Cancel. --- .../decision-queue/decision-queue.spec.ts | 33 +- .../decision-queue/decision-queue.ts | 225 ++++---- .../suggestion-card/suggestion-card.ts | 511 +++++++++--------- 3 files changed, 398 insertions(+), 371 deletions(-) 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..bc31ee1 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,34 @@ 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('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 +269,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..d5e7a22 100644 --- a/src/app/features/discovery/components/decision-queue/decision-queue.ts +++ b/src/app/features/discovery/components/decision-queue/decision-queue.ts @@ -44,11 +44,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 +60,108 @@ 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) { +
}
diff --git a/src/app/features/discovery/components/suggestion-card/suggestion-card.ts b/src/app/features/discovery/components/suggestion-card/suggestion-card.ts index 4c527f8..e8c052d 100644 --- a/src/app/features/discovery/components/suggestion-card/suggestion-card.ts +++ b/src/app/features/discovery/components/suggestion-card/suggestion-card.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, + inject, input, linkedSignal, output, @@ -9,9 +10,15 @@ import { } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { TranslocoPipe } from '@jsverse/transloco'; +import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { provideIcons } from '@ng-icons/core'; -import { lucideCircleHelp, lucidePlus, lucideSparkles, lucideTrash2 } from '@ng-icons/lucide'; +import { + lucideArrowUpRight, + lucideCircleHelp, + lucidePlus, + lucideSparkles, + lucideTrash2, +} from '@ng-icons/lucide'; import { AcceptSuggestionRequest, DisplayStory, @@ -24,49 +31,85 @@ import { emptyEditableCriterion, suggestionCriteria, } from '../../data/discovery.models'; +import { Select, SelectOption } from '../../../../shared/components/select/select'; +import { translateFn } from '../../../../core/i18n/translate-fn'; import { HlmButton, HlmIcon, HlmInput, HlmSpinner } from '../../../../shared/ui'; +import { GherkinSteps } from '../gherkin-steps/gherkin-steps'; +import { PriorityBadge } from '../story-badges/story-badges'; const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; +/** Story fields an UPDATE_STORY suggestion can change (highlighted on the proposed side). */ +type StoryField = 'title' | 'role' | 'action' | 'benefit'; + /** * One AI suggestion rendered per type (draft story, story update diff, edge - * case, clarifying question) with an inline edit-before-accept flow. Clicking - * "Edit" opens an editable form inside the card; accepting sends only the - * changed fields to the backend. Read-only when `canDecide` is false. - * `openTarget` asks the page to reveal the target story in the side panel. + * case, clarifying question) with an inline edit-before-accept flow. Everything + * the AI proposed carries the violet "AI" provenance (type chip, card border), + * so it never reads like content an analyst already validated. The story reads + * as its canonical sentence ("Como …, quiero …, para …") and criteria as + * Given/When/Then steps. Clicking "Edit" opens labelled, auto-growing fields; + * accepting sends only the changed fields to the backend. Read-only when + * `canDecide` is false. `openTarget` asks the page to reveal the target story. */ @Component({ selector: 'app-suggestion-card', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgTemplateOutlet, FormsModule, HlmButton, HlmInput, HlmIcon, HlmSpinner, TranslocoPipe], - viewProviders: [provideIcons({ lucideCircleHelp, lucidePlus, lucideSparkles, lucideTrash2 })], + imports: [ + NgTemplateOutlet, + FormsModule, + Select, + GherkinSteps, + PriorityBadge, + HlmButton, + HlmInput, + HlmIcon, + HlmSpinner, + TranslocoPipe, + ], + viewProviders: [ + provideIcons({ + lucideArrowUpRight, + lucideCircleHelp, + lucidePlus, + lucideSparkles, + lucideTrash2, + }), + ], + host: { class: 'flex min-h-0 flex-col' }, template: ` -
- -
+ +
- @if (suggestion().type === 'CLARIFYING_QUESTION') { - - } @else { - - } + @if (suggestion().type !== 'CLARIFYING_QUESTION') { - - {{ 'discovery.suggestion.priority.' + displayPriority() | transloco }} - + @if (displayStoryPoints() !== null) { {{ 'discovery.panel.points' | transloco: { n: displayStoryPoints() } }} @@ -75,76 +118,72 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; } @if (suggestion().relatedTopic; as topic) { {{ topic }} } -
+
@switch (suggestion().type) { @case ('CLARIFYING_QUESTION') { -

{{ suggestion().question }}

+

+ {{ suggestion().question }} +

+

+ {{ 'discovery.suggestion.questionHint' | transloco }} +

} @case ('UPDATE_STORY') { + (editable) version, every changed field highlighted. -->

- {{ 'discovery.suggestion.updates' | transloco }} {{ targetStory()?.title ?? ('discovery.suggestion.storyNotFound' | transloco) }}

-
-

+

+

{{ 'discovery.suggestion.current' | transloco }}

@if (targetStory(); as cur) { -

{{ cur.title }}

-
-
-
- {{ 'discovery.suggestion.role' | transloco }} -
-
{{ cur.role }}
-
-
-
- {{ 'discovery.suggestion.action' | transloco }} -
-
{{ cur.action }}
-
-
-
- {{ 'discovery.suggestion.benefit' | transloco }} -
-
{{ cur.benefit }}
-
-
+

{{ cur.title }}

+

+ {{ 'discovery.story.as' | transloco + }}{{ cur.role }}{{ 'discovery.story.want' | transloco + }}{{ cur.action }}{{ 'discovery.story.soThat' | transloco + }}{{ cur.benefit }}. +

} @else {

{{ 'discovery.suggestion.storyNotFound' | transloco }}

}
-
-

+

+

{{ 'discovery.suggestion.proposed' | transloco }}

+ @if (!editing() && hasChanges()) { +

+ + {{ 'discovery.suggestion.changedHint' | transloco }} +

+ } @@ -154,7 +193,7 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; @if (targetStory(); as cur) {

- {{ 'discovery.suggestion.forStory' | transloco }} {{ cur.title }}

@@ -163,8 +202,8 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; {{ 'discovery.suggestion.storyNotFound' | transloco }}

} -
-

+

+

{{ 'discovery.suggestion.scenarioToAdd' | transloco }}

@@ -179,32 +218,14 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; @if (!editing() && suggestion().type !== 'EDGE_CASE' && criteria().length > 0) { -
-

- {{ 'discovery.suggestion.criteria' | transloco }} +

+

+ {{ 'discovery.suggestion.criteriaCount' | transloco: { count: criteria().length } }}

    @for (criterion of criteria(); track $index) { -
  • - @if (criterion.scenario) { -

    {{ criterion.scenario }}

    - } -

    - {{ - 'discovery.suggestion.criteriaGiven' | transloco - }} - {{ criterion.given }} · - {{ - 'discovery.suggestion.criteriaWhen' | transloco - }} - {{ criterion.when }} · - {{ - 'discovery.suggestion.criteriaThen' | transloco - }} - {{ criterion.then }} -

    +
  • +
  • }
@@ -214,17 +235,18 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; @if (suggestion().targetStoryId && suggestion().type !== 'NEW_STORY') { }
@if (canDecide()) { -
+
-
+ } -
+ @@ -279,83 +303,72 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; }} -
- - - -
-
-
+
} @else if (firstCriterion(); as c) { -
- @if (c.scenario) { -

{{ c.scenario }}

- } -

- {{ - 'discovery.suggestion.criteriaGiven' | transloco - }} - {{ c.given }} · - {{ - 'discovery.suggestion.criteriaWhen' | transloco - }} - {{ c.when }} · - {{ - 'discovery.suggestion.criteriaThen' | transloco - }} - {{ c.then }} -

-
+ } @else {

{{ 'discovery.suggestion.noCriterion' | transloco }} @@ -437,19 +425,19 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; @if (editing()) { -

-

+

+

{{ 'discovery.suggestion.criteria' | transloco }}

@for (criterion of model().criteria; track $index) { -
-
- +
+
+ {{ 'discovery.suggestion.criterion' | transloco }} {{ $index + 1 }} - } +
+ @for (t of tabs; track t) { + + } +
@@ -212,33 +224,13 @@ const QUICK_VIEW_SIZE = 100; >.

@if (story.acceptanceCriteria.length > 0) { -

+

{{ 'discovery.suggestion.criteria' | transloco }}

    @for (c of story.acceptanceCriteria; track $index) { -
  • - @if (c.scenario) { -

    {{ c.scenario }}

    - } -

    - {{ - 'discovery.suggestion.criteriaGiven' | transloco - }} - {{ c.given }} · - {{ - 'discovery.suggestion.criteriaWhen' | transloco - }} - {{ c.when }} · - {{ - 'discovery.suggestion.criteriaThen' | transloco - }} - {{ c.then }} -

    +
  • +
  • }
@@ -515,16 +507,6 @@ export class SidePanel { this.priorityFilter.update((current) => (current === priority ? null : priority)); } - protected priorityClass(priority: string): string { - const classes: Record = { - CRITICAL: 'bg-destructive/20 text-destructive', - HIGH: 'bg-destructive/15 text-destructive', - MEDIUM: 'bg-amber-500/15 text-amber-600', - LOW: 'bg-secondary text-muted-foreground', - }; - return classes[priority?.toUpperCase()] ?? 'bg-secondary text-muted-foreground'; - } - private loadGlossary(): void { const orgId = this.auth.organizationId(); if (!orgId) { diff --git a/src/app/features/discovery/data/ai-activity.spec.ts b/src/app/features/discovery/data/ai-activity.spec.ts new file mode 100644 index 0000000..ff59355 --- /dev/null +++ b/src/app/features/discovery/data/ai-activity.spec.ts @@ -0,0 +1,63 @@ +import { aiActivityFor } from './ai-activity'; +import { SessionStatus } from './discovery.models'; + +const NOW = Date.parse('2026-10-07T15:00:00Z'); +const session = (status: SessionStatus, processingError: string | null = null) => ({ + status, + processingError, +}); + +describe('aiActivityFor', () => { + it('reports the AI as listening while recording, without a last suggestion when none is pending', () => { + expect(aiActivityFor(session('RECORDING'), [], NOW)).toEqual({ + state: 'listening', + detail: null, + last: null, + }); + }); + + it('dates "last suggestion" from the newest pending suggestion of the session', () => { + const activity = aiActivityFor( + session('RECORDING'), + [{ createdAt: '2026-10-07T14:50:00Z' }, { createdAt: '2026-10-07T14:57:00Z' }], + NOW, + ); + + expect(activity?.last).toEqual({ + kind: 'relative', + key: 'discovery.time.minutes', + params: { n: 3 }, + }); + }); + + it('ignores unparseable timestamps when picking the newest suggestion', () => { + const activity = aiActivityFor(session('RECORDING'), [{ createdAt: 'not-a-date' }], NOW); + + expect(activity?.last).toBeNull(); + }); + + it('is paused while the recording is paused', () => { + expect( + aiActivityFor(session('PAUSED'), [{ createdAt: '2026-10-07T14:57:00Z' }], NOW)?.state, + ).toBe('paused'); + }); + + it('is processing between Stop and the final stories (STOPPED and PROCESSING)', () => { + expect(aiActivityFor(session('STOPPED'), [], NOW)?.state).toBe('processing'); + expect(aiActivityFor(session('PROCESSING'), [], NOW)?.state).toBe('processing'); + }); + + it('surfaces the backend reason when processing failed', () => { + expect(aiActivityFor(session('FAILED', ' Transcript too short '), [], NOW)).toEqual({ + state: 'failed', + detail: 'Transcript too short', + last: null, + }); + expect(aiActivityFor(session('FAILED', ' '), [], NOW)?.detail).toBeNull(); + }); + + it('shows nothing for settled or not-yet-started sessions', () => { + expect(aiActivityFor(session('COMPLETED'), [], NOW)).toBeNull(); + expect(aiActivityFor(session('DRAFT'), [], NOW)).toBeNull(); + }); +}); diff --git a/src/app/features/discovery/data/ai-activity.ts b/src/app/features/discovery/data/ai-activity.ts new file mode 100644 index 0000000..f3b4d64 --- /dev/null +++ b/src/app/features/discovery/data/ai-activity.ts @@ -0,0 +1,56 @@ +import { DiscoverySessionResponse, SuggestionResponse } from './discovery.models'; +import { RelativeTime, relativeTime } from './relative-time'; + +/** What the AI is doing for a session, as shown at the live edge of its feed block. */ +type AiActivityState = 'listening' | 'paused' | 'processing' | 'failed'; + +export interface AiActivity { + state: AiActivityState; + /** Extra line that replaces the default hint (the processing failure reason). */ + detail: string | null; + /** When the newest still-pending suggestion of this session was proposed (listening only). */ + last: Extract | null; +} + +/** + * Maps a session's lifecycle to the AI status line of its feed block, or null once + * the session has settled (COMPLETED) or never started (DRAFT): + * + * - RECORDING → listening (real-time suggestions are being produced), plus how long + * ago the newest pending suggestion arrived, when there is one; + * - PAUSED → paused (no audio, so no analysis until resumed); + * - STOPPED / PROCESSING → processing (the final stories are being generated); + * - FAILED → failed, carrying the backend's reason when it sent one. + */ +export function aiActivityFor( + session: Pick, + pending: readonly Pick[], + now: number, +): AiActivity | null { + switch (session.status) { + case 'RECORDING': + return { state: 'listening', detail: null, last: newestPending(pending, now) }; + case 'PAUSED': + return { state: 'paused', detail: null, last: null }; + case 'STOPPED': + case 'PROCESSING': + return { state: 'processing', detail: null, last: null }; + case 'FAILED': + return { state: 'failed', detail: session.processingError?.trim() || null, last: null }; + default: + return null; + } +} + +function newestPending( + pending: readonly Pick[], + now: number, +): AiActivity['last'] { + const newest = pending + .map((s) => Date.parse(s.createdAt)) + .filter((t) => !Number.isNaN(t)) + .reduce((max, t) => (max === null || t > max ? t : max), null); + if (newest === null) return null; + const label = relativeTime(new Date(newest).toISOString(), now); + return label.kind === 'relative' ? label : null; +} diff --git a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts index bb38c3a..0979a4f 100644 --- a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts +++ b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts @@ -21,8 +21,10 @@ import { provideIcons } from '@ng-icons/core'; import { lucideArrowDown, lucideArrowUpRight, - lucideCheck, - lucideCircleHelp, + lucideCircleAlert, + lucideCircleCheck, + lucideCircleDashed, + lucideCircleX, lucideClock, lucideHeadphones, lucideHistory, @@ -30,9 +32,11 @@ import { lucideLanguages, lucideMic, lucidePanelRight, + lucidePause, + lucideRotateCw, + lucideScreenShare, lucideSparkles, lucideTriangleAlert, - lucideX, } from '@ng-icons/lucide'; import { AuthStore } from '../../../../core/auth/auth.store'; import { WorkspaceStore } from '../../../workspace/data/workspace.store'; @@ -42,13 +46,14 @@ import { AudioRecorderService } from '../../../../core/audio/audio-recorder.serv import { AudioSource, supportsMeetingAudio } from '../../../../core/audio/audio-source'; import { DiscoveryChatStore, RenderBlock } from '../../data/discovery-chat.store'; import { SessionRecordingService } from '../../data/session-recording.service'; -import { SpeakerDisplay } from '../../data/feed'; +import { DecisionEntry, SpeakerDisplay } from '../../data/feed'; import { RelativeTime, relativeTime } from '../../data/relative-time'; import { AcceptSuggestionRequest, SessionTranscriptSegmentMessage, SuggestionResponse, } from '../../data/discovery.models'; +import { AiActivity, aiActivityFor } from '../../data/ai-activity'; import { SessionBar } from '../../components/session-bar/session-bar'; import { AudioSourcePicker } from '../../components/audio-source-picker/audio-source-picker'; import { ActiveParticipants } from '../../components/active-participants/active-participants'; @@ -62,13 +67,15 @@ import { audioSourceStorageKey, resolveAudioSource } from '../../data/audio-sour import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; /** - * The default discovery view: a GPT/Claude-style chat. The center feed is a - * chronological, read-only stream of transcript bubbles and resolved decision - * cards, chunked by session; scrolling to the top lazily loads older sessions. - * The composer at the bottom pairs a disabled ("coming soon") text input with - * the audio-source picker (in person vs. virtual meeting) and the record - * button. Pending AI suggestions surface in the floating decision - * queue; the side panel exposes the project's stories/info/glossary/constraints. + * The default discovery view. The center feed is a chronological, read-only + * stream chunked by session: neutral transcript bubbles, compact human decision + * rows (accepted = validated by the analyst), AI-generated stories and, at the + * live edge, a status line saying what the AI is doing (listening, paused, + * processing, failed). Scrolling to the top lazily loads older sessions. Pending + * AI suggestions wait in a review tray docked over the top of the feed column — + * never over the session bar or the side panel. The composer pairs the + * audio-source picker (in person vs. virtual meeting) with the record button; + * the side panel exposes the project's stories/info/glossary/constraints. */ @Component({ selector: 'app-discovery-chat', @@ -92,8 +99,10 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; provideIcons({ lucideArrowDown, lucideArrowUpRight, - lucideCheck, - lucideCircleHelp, + lucideCircleAlert, + lucideCircleCheck, + lucideCircleDashed, + lucideCircleX, lucideClock, lucideHeadphones, lucideHistory, @@ -101,9 +110,11 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; lucideLanguages, lucideMic, lucidePanelRight, + lucidePause, + lucideRotateCw, + lucideScreenShare, lucideSparkles, lucideTriangleAlert, - lucideX, }), ], host: { class: 'flex min-h-0 flex-1 flex-col' }, @@ -204,9 +215,9 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; > {{ 'discovery.live.banner' | transloco: { language: liveLabel } }}
@@ -217,7 +228,7 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; } - -
- @switch (store.state()) { - @case ('loading') { -
- -
- } - @case ('error') { -

- {{ 'discovery.loadError' | transloco }} -

- } - @default { - @if (store.loadingOlder()) { + +
+
+ @switch (store.state()) { + @case ('loading') { +
+ +
+ } + @case ('error') {
- - {{ 'discovery.loadingOlder' | transloco }} -
- } @else if (!store.hasOlder() && store.blocks().length > 0 && projectCreatedAt()) { -
- - - {{ - 'discovery.startMarker' - | transloco: { date: projectCreatedAt() | date: 'mediumDate' } - }} - - + +
} - - @if (store.blocks().length === 0) { -
- - - -
-

{{ 'discovery.emptyTitle' | transloco }}

-

- {{ 'discovery.emptyBody' | transloco }} -

+ + {{ 'discovery.loadingOlder' | transloco }}
-
- } - - @for (block of store.blocks(); track block.session.id) { -
- -
+ } @else if (!store.hasOlder() && store.blocks().length > 0 && projectCreatedAt()) { +
- @let sessionAt = block.session.startedAt ?? block.session.createdAt; + + {{ + 'discovery.startMarker' + | transloco: { date: projectCreatedAt() | date: 'mediumDate' } + }} + + +
+ } + + @if (store.blocks().length === 0) { +
- {{ 'discovery.sessionSeparator' | transloco }} - @let sessionTime = timeLabel(sessionAt); - @if (sessionTime.kind === 'relative') { - {{ sessionTime.key | transloco: sessionTime.params }} - } @else { - {{ sessionAt | date: 'MMM d · HH:mm' }} - } - @if ( - block.session.storiesGeneratedCount !== null && - block.session.storiesGeneratedCount !== undefined - ) { - · - {{ - 'discovery.sessionStories' - | transloco: { count: block.session.storiesGeneratedCount } - }} - } + - +
+

{{ 'discovery.emptyTitle' | transloco }}

+

+ {{ 'discovery.emptyBody' | transloco }} +

+
+ } - @if (!block.loaded) { -
- } + @for (block of store.blocks(); track block.session.id) { +
+ +
+ @let sessionAt = block.session.startedAt ?? block.session.createdAt; + + {{ 'discovery.sessionSeparator' | transloco }} + @let sessionTime = timeLabel(sessionAt); + @if (sessionTime.kind === 'relative') { + {{ sessionTime.key | transloco: sessionTime.params }} + } @else { + {{ sessionAt | date: 'MMM d · HH:mm' }} + } + @if ( + block.session.storiesGeneratedCount !== null && + block.session.storiesGeneratedCount !== undefined + ) { + · + {{ + 'discovery.sessionStories' + | transloco: { count: block.session.storiesGeneratedCount } + }} + } + +
- @for (item of block.items; track item.id) { - @switch (item.kind) { - @case ('paragraph') { -
-

{{ item.text }}

-
- } - @case ('segment') { - @let speaker = speakerFor(block, item.segment); -
- @if (speaker) { + @if (!block.loaded) { +
+ } + + @for (item of block.items; track item.id) { + @switch (item.kind) { + @case ('paragraph') { +
+

{{ item.text }}

+
+ } + @case ('segment') { + + @let speaker = speakerFor(block, item.segment); +
- {{ 'discovery.speaker' | transloco: { n: speaker.index } }} + @if (speaker) { + {{ + 'discovery.speaker' | transloco: { n: speaker.index } + }} + + } + - } +
+

{{ item.segment.text }}

+
+
+ } + @case ('decision') { + + @let accepted = item.decision.outcome === 'ACCEPTED';
-

{{ item.segment.text }}

- -

- {{ item.segment.occurredAt | date: 'HH:mm' }} -

+
-
- } - @case ('decision') { -
- -
-
- - @if (item.decision.outcome === 'ACCEPTED') { - - {{ 'discovery.decision.accepted' | transloco }} - } @else { - - {{ 'discovery.decision.dismissed' | transloco }} - } + } + @case ('story') { + +
+
+ + - @if (item.decision.type === 'CLARIFYING_QUESTION') { - - } - {{ 'discovery.suggestion.type.' + item.decision.type | transloco }} +
- @if (item.decision.storyId) { +

{{ item.story.title }}

+

+ {{ 'discovery.story.as' | transloco }} + {{ item.story.role }}{{ 'discovery.story.want' | transloco }} + {{ item.story.action }}{{ 'discovery.story.soThat' | transloco }} + {{ item.story.benefit }}. +

+
+ @if (item.story.createdAt; as storyAt) { + @let storyTime = timeLabel(storyAt); + + } - } +
+ } + } + } - - @if (item.decision.label) { -

+ @if (activityFor(block); as activity) { +

+ @switch (activity.state) { + @case ('listening') { + } - - - @let decisionTime = timeLabel(item.decision.occurredAt); - - @if (decisionTime.kind === 'relative') { - {{ decisionTime.key | transloco: decisionTime.params }} - } @else { - {{ item.decision.occurredAt | date: 'd MMM' }} - } - -
- } - @case ('story') { -
- - - {{ 'discovery.generatedStory' | transloco }} - -

{{ item.story.title }}

-

- {{ 'discovery.story.as' | transloco }} - {{ item.story.role }}{{ 'discovery.story.want' | transloco }} - {{ item.story.action }}{{ 'discovery.story.soThat' | transloco }} - {{ item.story.benefit }}. + @case ('paused') { + + } + @case ('processing') { + + } + @case ('failed') { + + } + } +

+

+ {{ 'discovery.ai.' + activity.state | transloco }}

-
- @if (item.story.createdAt; as storyAt) { - - @let storyTime = timeLabel(storyAt); -

- @if (storyTime.kind === 'relative') { - {{ storyTime.key | transloco: storyTime.params }} - } @else { - {{ storyAt | date: 'd MMM' }} - } -

- } - -
+ @if (activity.detail; as detail) { +

+ {{ detail }} +

+ } @else if (activity.state === 'listening' && activity.last) { +

+ {{ + 'discovery.ai.lastSuggestion' + | transloco + : { + time: (activity.last.key | transloco: activity.last.params), + } + }} +

+ } @else { +

+ {{ 'discovery.ai.' + activity.state + 'Hint' | transloco }} +

+ }
- } +
} - } -
+
+ } } } - } - @if (!atBottom()) { - - } + @if (!atBottom()) { + + } +
+ +
- +
-
+ @if (recorder.error(); as errKey) { -

{{ errKey | transloco }}

+ } @else if (recorder.notice(); as noticeKey) { -

- - {{ noticeKey | transloco }} -

- } @else if (sourceHint(); as hintKey) { -

- - {{ hintKey | transloco }} -

+ + {{ noticeKey | transloco }} + @if (canRecord() && noticeKey === 'discovery.rec.noMeetingAudio') { + + } +
+ } @else if (sourceHint(); as hint) { + @if (hint === 'unsupported') { +

+ + {{ 'discovery.source.unsupported' | transloco }} +

+ } @else { + +
    +
  • + + {{ 'discovery.source.hintShare' | transloco }} +
  • +
  • + + {{ 'discovery.source.hintHeadphones' | transloco }} +
  • +
+ } }
@@ -592,13 +740,45 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; }
- - + +

+ @if (store.queue().length > 0) { + {{ 'discovery.queue.announce' | transloco: { count: store.queue().length } }} + } +

+ + @@ -757,10 +937,10 @@ export class DiscoveryChat implements OnInit { * The composer's guidance line before recording: why the virtual option is * unavailable, or how to share the meeting audio (and to wear headphones). */ - protected readonly sourceHint = computed(() => { + protected readonly sourceHint = computed<'unsupported' | 'meeting' | null>(() => { if (!this.canRecord() || this.recording.isActive()) return null; - if (this.meetingUnavailableHint()) return 'discovery.source.unsupported'; - return this.audioSource() === 'meeting' ? 'discovery.source.hint' : null; + if (this.meetingUnavailableHint()) return 'unsupported'; + return this.audioSource() === 'meeting' ? 'meeting' : null; }); /** Uppercased primary subtag of the editable language, for the select's mobile trigger. */ @@ -894,6 +1074,16 @@ export class DiscoveryChat implements OnInit { protected accept(suggestion: SuggestionResponse, body: AcceptSuggestionRequest): void { this.store.decide(suggestion, 'ACCEPTED', body).subscribe({ + // Confirm where the accepted content went: a question is only marked as + // addressed, everything else lands in the backlog as a draft. + next: () => + this.toast.success( + this.transloco.translate( + suggestion.type === 'CLARIFYING_QUESTION' + ? 'discovery.decision.resolvedToast' + : 'discovery.decision.acceptedToast', + ), + ), error: (err: HttpErrorResponse) => this.handleDecideError(err, suggestion.id), }); } @@ -951,8 +1141,56 @@ export class DiscoveryChat implements OnInit { protected decisionClass(outcome: 'ACCEPTED' | 'DISMISSED'): string { return outcome === 'ACCEPTED' - ? 'border-emerald-500/30 bg-emerald-500/10' - : 'border-border bg-secondary/60 text-muted-foreground'; + ? 'bg-verified-soft ring-1 ring-inset ring-verified-border' + : 'bg-muted/70 text-muted-foreground'; + } + + /** "Accepted/resolved by the analyst" makes the human validation explicit; questions are resolved. */ + protected decisionLabel(decision: DecisionEntry): string { + if (decision.outcome === 'DISMISSED') return 'discovery.decision.dismissed'; + return decision.type === 'CLARIFYING_QUESTION' + ? 'discovery.decision.resolvedBy' + : 'discovery.decision.acceptedBy'; + } + + /** + * What the AI is doing for a session block (live/paused/processing/failed), or + * null for a settled session. "Last suggestion" is the newest pending one of + * this session still in the review queue. + */ + protected activityFor(block: RenderBlock): AiActivity | null { + const pending = this.store.queue().filter((s) => s.sessionId === block.session.id); + return aiActivityFor(block.session, pending, this.now()); + } + + protected activityClass(state: AiActivity['state']): string { + switch (state) { + case 'listening': + case 'processing': + return 'bg-ai-soft text-ai'; + case 'failed': + return 'border border-destructive/30 bg-destructive/5 text-destructive'; + default: + return 'bg-muted/70 text-muted-foreground'; + } + } + + /** Re-runs the initial feed load after an error. */ + protected retryLoad(): void { + this.store.init(this.projectId()); + } + + /** + * "Share again" from the no-meeting-audio notice: mid-session it re-opens the + * share picker for the running recording; before one, it simply retries the + * record flow (both straight from the click, which the picker needs). + */ + protected retryMeetingShare(): void { + if (this.recording.isActive()) { + void this.recorder.shareMeetingAudio(); + return; + } + void this.record(); } /** @@ -968,9 +1206,14 @@ export class DiscoveryChat implements OnInit { return label ? block.speakers.get(label) : undefined; } - /** Bubble styling per side: the right (2nd) speaker gets the primary tint to read as "the other side". */ + /** + * Bubble styling per side, kept neutral so the transcript recedes behind AI and + * human decisions: the left speaker is filled, the right (2nd) one outlined. + */ protected segmentBubbleClass(speaker: SpeakerDisplay | undefined): string { - return speaker?.side === 'right' ? 'bg-primary/10' : 'bg-secondary'; + return speaker?.side === 'right' + ? 'rounded-tr-md border border-border bg-card' + : 'rounded-tl-md bg-secondary'; } /** Picks a language and persists it as this user's per-project override. */ From a1586b64ead1a967e03b03b1d3900cf6a9cf1459 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 01:36:23 -0500 Subject: [PATCH 07/20] feat(discovery): label the audio source picker and keep the bar's state announced The picker becomes a labelled segmented control (In person / Virtual meeting) so it is never confused with the record button, which now shows a record dot and a "Record" 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. The session bar keeps its status label for screen readers on mobile and shows the meeting-audio badge on every width. --- .../audio-source-picker.spec.ts | 10 +++++ .../audio-source-picker.ts | 39 +++++++++++++------ .../session-bar/session-bar.spec.ts | 16 ++++++-- .../components/session-bar/session-bar.ts | 22 +++++++---- 4 files changed, 65 insertions(+), 22 deletions(-) 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..b3a338f 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 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/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 { - + } - -