diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index e55d843..fa31a37 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -5,59 +5,24 @@ on:
branches: [ main ]
workflow_dispatch:
-env:
- AWS_REGION: ${{ vars.AWS_REGION }}
- S3_BUCKET: ${{ vars.S3_BUCKET }}
- CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
+permissions:
+ contents: read
jobs:
deploy:
+ name: Trigger the MVP deploy
+ if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
- permissions:
- contents: read
- id-token: write
+ timeout-minutes: 5
steps:
- - uses: actions/checkout@v7
-
- - name: Set up Bun
- uses: oven-sh/setup-bun@v2
- with:
- bun-version: 1.3.14
-
- - name: Install dependencies
- run: bun install --frozen-lockfile
-
- - name: Build for production
- run: bun run build
-
- - name: Configure AWS credentials (OIDC)
- uses: aws-actions/configure-aws-credentials@v6
- with:
- role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
- aws-region: ${{ env.AWS_REGION }}
-
- - name: Sync static assets to S3 (long cache — hashed filenames)
- run: |
- aws s3 sync dist/reqsai-web/browser/ s3://${{ env.S3_BUCKET }}/ \
- --delete \
- --exclude "index.html" \
- --exclude "i18n/*" \
- --cache-control "public,max-age=31536000,immutable"
-
- - name: Upload index.html and i18n to S3 (no cache — must always be fresh)
- run: |
- # index.html and the i18n JSON have STABLE, non-fingerprinted names, so they must
- # never be served "immutable" — otherwise a browser caches them for a year and never
- # picks up new translations after a deploy. Serve them revalidatable instead.
- aws s3 cp dist/reqsai-web/browser/index.html \
- s3://${{ env.S3_BUCKET }}/index.html \
- --cache-control "no-cache,no-store,must-revalidate"
- aws s3 cp dist/reqsai-web/browser/i18n/ \
- s3://${{ env.S3_BUCKET }}/i18n/ --recursive \
- --cache-control "no-cache,must-revalidate"
-
- - name: Invalidate CloudFront cache
+ - name: Dispatch deploy-mvp in reqsai-infra
+ env:
+ GH_TOKEN: ${{ secrets.INFRA_DEPLOY_TOKEN }}
+ SHA: ${{ github.sha }}
run: |
- aws cloudfront create-invalidation \
- --distribution-id ${{ env.CLOUDFRONT_DISTRIBUTION_ID }} \
- --paths "/*"
+ if [ -z "$GH_TOKEN" ]; then
+ echo "::notice title=Deploy not triggered::INFRA_DEPLOY_TOKEN is not set. Add a fine-grained PAT with Actions read and write on Kntro-Soft/reqsai-infra to deploy main automatically."
+ exit 0
+ fi
+ gh workflow run deploy-mvp.yml --repo Kntro-Soft/reqsai-infra --ref main -f web_ref="$SHA" -f api_ref=keep
+ echo "Requested the deploy of reqsai-web $SHA: https://github.com/Kntro-Soft/reqsai-infra/actions/workflows/deploy-mvp.yml" >> "$GITHUB_STEP_SUMMARY"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ab50ae..7cfa02b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,29 @@ _Feature module implementation (iam, billing, workspace, discovery) in progress.
### Added
+- **MVP feature flags** (`feature/mvp-feature-flags`): a typed `features` map in both environment
+ files (`FeatureKey` union + `FeatureFlags.isEnabled()`), all **off** by default, so the deployed
+ product shows only the MVP. A `featureGuard()` `canMatch` makes a disabled route behave like an
+ unknown URL (falls through to `/projects`), and the sidebar, command palette, buttons and cards
+ drop what it hides: `billing` (billing pages, checkout returns, Upgrade CTA), `usage`,
+ `integrations` (Jira pages + OAuth callback, job banner, import/push actions), `members` (org +
+ project members, invitation landing, palette action/hits, ownership transfer), `customRoles`
+ (project roles), and the `notifications` / `tokens` account placeholders. The sidebar also hides
+ a **Settings** entry with no reachable sub-page. Flipping a flag restores the feature with no
+ other change; see `docs/FEATURE-FLAGS.md` (incl. the backend's FREE-plan limits).
+- **Discovery — virtual-meeting audio capture** (`feature/system-audio-capture`): the analyst can now
+ record a Zoom / Google Meet / Teams call, not only a face-to-face meeting. A compact **audio-source
+ picker** next to the record button chooses **In person (microphone)** or **Virtual meeting (microphone +
+ meeting audio)**, remembered per user in localStorage. The virtual source opens the browser's
+ screen-share picker straight from the record click (`getDisplayMedia` with tab/window/system audio, voice
+ processing off and ReqsAI's own tab hidden), drops the unused video track and mixes the shared audio with
+ the mic in one mono Web Audio mixer — the backend keeps receiving the same 16 kHz Int16 PCM on `/ws/stt`,
+ and the level meter shows the mix. Sharing without ticking "Share tab audio" warns and lets the user
+ retry; a dismissed picker stays idle silently; "Stop sharing" mid-session keeps recording the mic with a
+ notice and a **Share again** action in the session bar; pausing keeps the shared tab, so resuming never
+ re-opens the picker. The virtual option is disabled (with the reason) outside desktop Chrome/Edge, and a
+ hint recommends headphones to avoid a duplicated transcript (`discovery.source.*` plus new
+ `discovery.rec.*` / `discovery.bar.*` keys, en/es).
- **Discovery — live session presence** (`feature/discovery-presence`): the discovery chat now shows who
else is viewing the **live** session — an overlapping avatar stack with a live pulse, a "+N" overflow
bubble and a viewer count, in both the page header and the live session bar. It is fed by a new
@@ -194,6 +217,55 @@ _Feature module implementation (iam, billing, workspace, discovery) in progress.
### Changed
+- **CI — deploy through reqsai-infra** (`ci/deploy-via-infra`): `deploy.yml` no longer syncs to S3 and
+ invalidates CloudFront; that stack no longer exists, so the old workflow would fail on the next push to
+ `main`. A push to `main` now asks `Kntro-Soft/reqsai-infra` to run its `deploy-mvp.yml` workflow with
+ `web_ref` set to the pushed commit; that workflow builds the linux/arm64 nginx image and deploys it to the
+ single-EC2 MVP host over SSM, and rebuilds reqsai-api from its `main`. Needs the repository secret
+ `INFRA_DEPLOY_TOKEN` (fine-grained PAT with Actions read and write on `reqsai-infra` only); without it the
+ job logs a notice and succeeds, so `main` never goes red. Manual runs only dispatch from `main`.
+- **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/README.md b/README.md
index 1dc7e2f..131862d 100644
--- a/README.md
+++ b/README.md
@@ -51,9 +51,14 @@ export const environment = {
production: false,
apiUrl: '',
wsUrl: '',
+ features: { billing: false, usage: false, integrations: false, /* … */ } satisfies FeatureFlagMap,
} as const;
```
+`features` son los feature flags de build: el MVP oculta billing, usage, integraciones (Jira),
+miembros/invitaciones, roles personalizados y los placeholders de cuenta. Ponlos en `true` en
+**ambos** archivos para reactivarlos — ver [`docs/FEATURE-FLAGS.md`](docs/FEATURE-FLAGS.md).
+
---
## Arquitectura
@@ -130,6 +135,7 @@ Detalle en [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|------------------------------------------------------|-----------------------------------------------------|
| [`docs/adr/`](docs/adr/) | Architecture Decision Records (el *por qué*) |
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Docker · AWS S3 + CloudFront · variables de entorno |
+| [`docs/FEATURE-FLAGS.md`](docs/FEATURE-FLAGS.md) | Feature flags del MVP y cómo reactivarlos |
| [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md) | Flujo de trabajo, ramas, commits, PR |
| [`CHANGELOG.md`](CHANGELOG.md) | Historial de cambios (Keep a Changelog) |
| [`.github/SECURITY.md`](.github/SECURITY.md) | Política de seguridad y reporte de vulnerabilidades |
diff --git a/docs/FEATURE-FLAGS.md b/docs/FEATURE-FLAGS.md
new file mode 100644
index 0000000..7464267
--- /dev/null
+++ b/docs/FEATURE-FLAGS.md
@@ -0,0 +1,74 @@
+# Feature Flags — Reqs-AI Web (Frontend)
+
+The deployed product ships only the **MVP** ("Podar el árbol"): capture a discovery session,
+turn it into Gherkin user stories, and ground the AI with the project glossary and constraints.
+Everything else stays in the codebase — and in the backend — but is switched off in the UI by a
+build-time feature flag. Turning a flag on brings the feature back with **no other code change**.
+
+## Flags
+
+All flags default to `false` in **both** `src/environments/environment.ts` and
+`src/environments/environment.prod.ts`.
+
+| Flag | Hides (routes → fall back to `/projects`) | Hides (UI) |
+|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `billing` | `/settings/billing`, `/billing/success`, `/billing/cancel` | Org settings nav entry · user-menu **Upgrade** CTA · Usage page **Manage plan** link |
+| `usage` | `/settings/usage` | Org settings nav entry |
+| `integrations` | `/settings/integrations`, `/settings/integrations/jira/callback` (Atlassian OAuth), `/projects/:id/settings/integrations` | Org + project settings nav entries · global Jira job banner (and its job polling / STOMP topic) · backlog **Import from Jira**, **Push all to Jira**, **Push to Jira (n)** · story detail **Push to Jira** · the backlog's Jira-target lookup |
+| `members` | `/settings/members`, `/projects/:id/settings/members`, `/invitations/accept` (plus the legacy `/members` redirects) | Org + project settings nav entries · project overview **Members** card · command-palette **Members** action and member search hits · org General **Transfer ownership** card (and its member fetch) |
+| `customRoles` | `/projects/:id/settings/roles`, `…/roles/new`, `…/roles/:roleId/edit` | Project settings nav entry (and the settings landing / overview "Settings" card no longer count it) |
+| `notifications` | `/account/notifications` | Account nav "Soon" placeholder |
+| `tokens` | `/account/tokens` | Account nav "Soon" placeholder |
+
+A hidden route does not match at all (`canMatch` returns `false`), so it behaves exactly like an
+unknown URL: the router falls through to the wildcard `**` → `/projects` (query params are kept by
+that redirect, as for any unknown URL). No blank page, no "no access" toast, and the lazy chunk is
+never downloaded.
+
+The sidebar also hides a **Settings** entry when none of its sub-pages is reachable (e.g. an org
+admin while `members` and `integrations` are off), and the org-level entry opens the first
+reachable sub-page instead of the owner-only General.
+
+### Decisions
+
+- **Invitation links** (`/invitations/accept`) are behind `members`. While it is off nobody can
+ send an invitation from the UI, and an owner has no screen to see or remove whoever joins, so a
+ stale invite link must not add people to an org. Turning `members` on restores the flow.
+- **Transfer ownership** (org General settings) is behind `members`: without members there is
+ nobody to pick, and its empty state asks the owner to invite someone.
+- The story status filter still lists `EXPORTED`: it is data a story may already carry, not a link.
+
+## Turning a feature on
+
+1. Set the flag to `true` in **both** environment files (the production build uses
+ `environment.prod.ts` via `fileReplacements`).
+2. Rebuild / redeploy. Nothing else changes: routes, nav entries and buttons come back as they were.
+
+Tests override flags with `provideFeatureFlags({ members: true })` in the TestBed providers.
+
+## Adding a flag
+
+1. Add the key to the `FeatureKey` union in `src/app/core/features/feature-flags.ts`; both
+ environment files then fail to compile until they define it (`satisfies FeatureFlagMap`).
+2. Routes: `canMatch: [featureGuard('key')]`. When the route already has a `canMatch` guard, chain
+ it — `featureGuard('key', requirePermissionMatch('X'))` — because Angular runs every guard of a
+ `canMatch` array eagerly.
+3. Sidebar: add `feature: 'key'` to the item in `src/app/layout/shell/shell-nav.ts`.
+4. Anything else (buttons, cards, palette actions, data fetches):
+ `inject(FeatureFlags).isEnabled('key')`.
+5. Extend the URL lists in `src/app/app.routes.spec.ts` and update this table.
+
+## Backend coupling
+
+Flags are **UI-only**: they are not a security boundary, and every backend endpoint stays live
+(role/permission checks still apply server-side).
+
+- **Plan limits are enforced by the backend.** Each new organization gets a FREE subscription and
+ the workspace module rejects work beyond its limits with `422`: 25 projects
+ (`PROJECT_PLAN_LIMIT_EXCEEDED`), 50 glossary terms per project
+ (`GLOSSARY_TERM_PLAN_LIMIT_EXCEEDED`), 10 documents per project
+ (`PROJECT_DOCUMENT_PLAN_LIMIT_EXCEEDED`) and 3 members (`MEMBER_PLAN_LIMIT_EXCEEDED`). The UI shows
+ the localized error toast; with `billing` off there is no upgrade path, so raise the FREE limits
+ server-side if the MVP needs more.
+- **AI token usage is metered, not enforced**: the backend records consumption against the plan
+ quota but no MVP flow is blocked by it, and no frontend guard or banner depends on billing.
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 3639804..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",
@@ -1082,12 +1123,26 @@
"empty": "No language found.",
"locked": "The language is fixed once recording starts."
},
+ "source": {
+ "label": "Audio source",
+ "mic": "In person (microphone)",
+ "meeting": "Virtual meeting (microphone + meeting audio)",
+ "unsupported": "Virtual meeting: available in desktop Chrome or Edge.",
+ "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",
"paused": "Paused",
"pause": "Pause",
"resume": "Resume",
- "stop": "Stop"
+ "stop": "Stop",
+ "meetingAudio": "Recording microphone + meeting audio",
+ "meetingAudioLost": "Meeting audio is not being captured. Share the meeting tab again.",
+ "reshare": "Share again",
+ "meetingAudioShort": "Meeting audio"
},
"rec": {
"micDenied": "Microphone permission denied. Enable it in your browser settings.",
@@ -1095,17 +1150,25 @@
"wsError": "Could not connect to the transcription server.",
"wsClosed": "The transcription connection was closed.",
"initFailed": "Could not start recording.",
- "audioInitFailed": "Could not initialize the local audio processor."
+ "audioInitFailed": "Could not initialize the local audio processor.",
+ "noMeetingAudio": "No audio was shared. Pick the meeting tab and tick \"Share tab audio\".",
+ "meetingAudioEnded": "Meeting audio sharing stopped. Recording continues with the microphone only.",
+ "meetingUnsupported": "Your browser cannot capture meeting audio. Use desktop Chrome or Edge.",
+ "shareFailed": "Could not capture the selected tab, window or screen."
},
"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": {
@@ -1137,7 +1200,7 @@
"saveAndAccept": "Save & accept",
"dismiss": "Dismiss",
"edit": "Edit",
- "cancelEdit": "Cancel",
+ "cancelEdit": "Cancel editing",
"done": "Done",
"titleField": "Title",
"scenario": "Scenario",
@@ -1151,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",
@@ -1213,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 1b55c02..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",
@@ -1082,12 +1123,26 @@
"empty": "No se encontró el idioma.",
"locked": "El idioma queda fijo al iniciar la grabación."
},
+ "source": {
+ "label": "Fuente de audio",
+ "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.",
+ "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",
"paused": "En pausa",
"pause": "Pausar",
"resume": "Reanudar",
- "stop": "Detener"
+ "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",
+ "meetingAudioShort": "Audio de la reunión"
},
"rec": {
"micDenied": "Permiso de micrófono denegado. Habilítalo en los ajustes de tu navegador.",
@@ -1095,17 +1150,25 @@
"wsError": "No se pudo conectar con el servidor de transcripción.",
"wsClosed": "Se cerró la conexión de transcripción.",
"initFailed": "No se pudo iniciar la grabación.",
- "audioInitFailed": "No se pudo inicializar el procesador de audio local."
+ "audioInitFailed": "No se pudo inicializar el procesador de audio local.",
+ "noMeetingAudio": "No se compartió audio. Elige la pestaña de la reunión y marca «Compartir audio de la pestaña».",
+ "meetingAudioEnded": "Se dejó de compartir el audio de la reunión. La grabación continúa solo con el micrófono.",
+ "meetingUnsupported": "Tu navegador no puede capturar el audio de la reunión. Usa Chrome o Edge de escritorio.",
+ "shareFailed": "No se pudo capturar la pestaña, ventana o pantalla elegida."
},
"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": {
@@ -1137,7 +1200,7 @@
"saveAndAccept": "Guardar y aceptar",
"dismiss": "Descartar",
"edit": "Editar",
- "cancelEdit": "Cancelar",
+ "cancelEdit": "Cancelar edición",
"done": "Listo",
"titleField": "Título",
"scenario": "Escenario",
@@ -1151,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",
@@ -1213,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/app.routes.spec.ts b/src/app/app.routes.spec.ts
new file mode 100644
index 0000000..2dfaf1e
--- /dev/null
+++ b/src/app/app.routes.spec.ts
@@ -0,0 +1,145 @@
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClientTesting } from '@angular/common/http/testing';
+import { Router, provideRouter, withRouterConfig } from '@angular/router';
+import { TranslocoTestingModule } from '@jsverse/transloco';
+import { of } from 'rxjs';
+import { routes } from './app.routes';
+import { AuthStore } from './core/auth/auth.store';
+import { CURRENT_TERMS_VERSION } from './core/auth/terms';
+import { PermissionsStore } from './core/authz/permissions.store';
+import { FeatureFlagMap, provideFeatureFlags } from './core/features/feature-flags';
+import { TenantContextService } from './core/tenant/tenant-context.service';
+
+/** Every non-MVP URL, each gated by one feature flag. */
+const HIDDEN_URLS = [
+ '/settings/billing',
+ '/billing/success',
+ '/billing/cancel',
+ '/settings/usage',
+ '/settings/integrations',
+ '/settings/integrations/jira/callback',
+ '/projects/p1/settings/integrations',
+ '/settings/members',
+ '/projects/p1/settings/members',
+ '/invitations/accept',
+ '/projects/p1/settings/roles',
+ '/projects/p1/settings/roles/new',
+ '/projects/p1/settings/roles/r1/edit',
+ '/account/notifications',
+ '/account/tokens',
+];
+
+/** MVP URLs that must keep resolving with every flag off. */
+const MVP_URLS = [
+ '/projects',
+ '/projects/p1/overview',
+ '/projects/p1/sessions/history',
+ '/projects/p1/stories',
+ '/projects/p1/stories/new',
+ '/projects/p1/stories/s1',
+ '/projects/p1/glossary',
+ '/projects/p1/constraints',
+ '/projects/p1/settings/general',
+ '/projects/p1/settings/danger',
+ '/settings/general',
+ '/account/profile',
+ '/account/security',
+ '/account/appearance',
+];
+
+/** The MVP release: every non-MVP feature off (explicit, so the test doesn't follow the env). */
+const ALL_OFF: FeatureFlagMap = {
+ billing: false,
+ usage: false,
+ integrations: false,
+ members: false,
+ customRoles: false,
+ notifications: false,
+ tokens: false,
+};
+
+const ALL_ON: FeatureFlagMap = {
+ billing: true,
+ usage: true,
+ integrations: true,
+ members: true,
+ customRoles: true,
+ notifications: true,
+ tokens: true,
+};
+
+/**
+ * Drives the REAL route table as a signed-in organization owner (stubbed auth, terms and
+ * permissions, so every role/permission guard passes) and reports where each URL ends up.
+ * No outlet is mounted, so the lazy pages are loaded but never rendered.
+ */
+describe('app routes with feature flags', () => {
+ function setup(flags: FeatureFlagMap): Router {
+ TestBed.configureTestingModule({
+ imports: [TranslocoTestingModule.forRoot({ langs: { en: {} } })],
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'always' })),
+ provideFeatureFlags(flags),
+ {
+ provide: AuthStore,
+ useValue: { isAuthenticated: () => true, organizationId: () => 'org-1' },
+ },
+ {
+ provide: TenantContextService,
+ useValue: { termsVersion: () => CURRENT_TERMS_VERSION },
+ },
+ {
+ provide: PermissionsStore,
+ useValue: {
+ loadOrgAuthorization: () => of(undefined),
+ loadProjectPermissions: () => of(undefined),
+ isOrgOwner: () => true,
+ isOrgOwnerOrAdmin: () => true,
+ has: () => true,
+ },
+ },
+ ],
+ });
+ return TestBed.inject(Router);
+ }
+
+ async function landing(router: Router, url: string): Promise {
+ await router.navigateByUrl(url);
+ return router.url;
+ }
+
+ describe('with the MVP flags (all off)', () => {
+ it.each(HIDDEN_URLS)('treats %s as an unknown URL (falls back to /projects)', async (url) => {
+ const router = setup(ALL_OFF);
+ expect(await landing(router, url)).toBe('/projects');
+ });
+
+ it.each(['/members', '/projects/p1/members'])(
+ 'sends the legacy %s redirect to the fallback too',
+ async (url) => {
+ const router = setup(ALL_OFF);
+ expect(await landing(router, url)).toBe('/projects');
+ },
+ );
+
+ it.each(MVP_URLS)('still resolves %s', async (url) => {
+ const router = setup(ALL_OFF);
+ expect(await landing(router, url)).toBe(url);
+ });
+
+ it('lands the project settings index on General', async () => {
+ const router = setup(ALL_OFF);
+ expect(await landing(router, '/projects/p1/settings')).toBe('/projects/p1/settings/general');
+ });
+ });
+
+ describe('with every flag on', () => {
+ it.each(HIDDEN_URLS)('resolves %s again', async (url) => {
+ const router = setup(ALL_ON);
+ expect(await landing(router, url)).toBe(url);
+ });
+ });
+});
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 8cc2eb0..c691742 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -1,5 +1,6 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
+import { featureGuard } from './core/guards/feature.guard';
import { launchGuard } from './core/guards/launch.guard';
import { onboardingGuard, orgGuard } from './core/guards/org.guard';
import {
@@ -21,9 +22,12 @@ export const routes: Routes = [
},
// Invitation landing: chrome-less, no auth guard so logged-out invitees can view it.
+ // Part of the `members` feature: while it is off nobody can invite or manage members,
+ // so a stale invite link falls through to the fallback like any unknown URL.
{
path: 'invitations/accept',
title: 'titles.acceptInvitation',
+ canMatch: [featureGuard('members')],
loadComponent: () =>
import('./features/workspace/pages/accept-invitation/accept-invitation').then(
(m) => m.AcceptInvitation,
@@ -43,6 +47,7 @@ export const routes: Routes = [
{
path: 'settings/integrations/jira/callback',
title: 'titles.integrations',
+ canMatch: [featureGuard('integrations')],
canActivate: [authGuard, termsGuard],
loadComponent: () =>
import('./features/workspace/pages/jira-oauth-callback/jira-oauth-callback').then(
@@ -133,6 +138,7 @@ export const routes: Routes = [
{
path: 'members',
title: 'titles.members',
+ canMatch: [featureGuard('members')],
canActivate: [requireOrgRole('ADMIN')],
data: { orgRole: 'ADMIN' },
loadComponent: () =>
@@ -141,6 +147,7 @@ export const routes: Routes = [
{
path: 'billing',
title: 'titles.billing',
+ canMatch: [featureGuard('billing')],
canActivate: [requireOrgRole('OWNER')],
data: { orgRole: 'OWNER' },
loadComponent: () =>
@@ -149,6 +156,7 @@ export const routes: Routes = [
{
path: 'integrations',
title: 'titles.integrations',
+ canMatch: [featureGuard('integrations')],
canActivate: [requireOrgRole('ADMIN')],
data: { orgRole: 'ADMIN' },
loadComponent: () =>
@@ -159,6 +167,7 @@ export const routes: Routes = [
{
path: 'usage',
title: 'titles.usage',
+ canMatch: [featureGuard('usage')],
canActivate: [requireOrgRole('OWNER')],
data: { orgRole: 'OWNER' },
loadComponent: () =>
@@ -170,6 +179,7 @@ export const routes: Routes = [
{
path: 'billing/success',
title: 'titles.billing',
+ canMatch: [featureGuard('billing')],
loadComponent: () =>
import('./features/billing/pages/checkout-result/checkout-result').then(
(m) => m.CheckoutResult,
@@ -179,6 +189,7 @@ export const routes: Routes = [
{
path: 'billing/cancel',
title: 'titles.billing',
+ canMatch: [featureGuard('billing')],
loadComponent: () =>
import('./features/billing/pages/checkout-result/checkout-result').then(
(m) => m.CheckoutResult,
@@ -214,6 +225,7 @@ export const routes: Routes = [
{
path: 'notifications',
title: 'titles.notifications',
+ canMatch: [featureGuard('notifications')],
loadComponent: () =>
import('./shared/components/coming-soon/coming-soon').then((m) => m.ComingSoon),
data: { titleKey: 'titles.notifications', icon: 'lucideBell' },
@@ -221,6 +233,7 @@ export const routes: Routes = [
{
path: 'tokens',
title: 'titles.tokens',
+ canMatch: [featureGuard('tokens')],
loadComponent: () =>
import('./shared/components/coming-soon/coming-soon').then((m) => m.ComingSoon),
data: { titleKey: 'titles.tokens', icon: 'lucideKey' },
@@ -322,7 +335,7 @@ export const routes: Routes = [
{
path: 'roles',
title: 'titles.projectRoles',
- canMatch: [requirePermissionMatch('ROLE_READ')],
+ canMatch: [featureGuard('customRoles', requirePermissionMatch('ROLE_READ'))],
canActivate: [requirePermission('ROLE_READ')],
data: { permission: 'ROLE_READ' },
loadComponent: () =>
@@ -333,6 +346,7 @@ export const routes: Routes = [
{
path: 'roles/new',
title: 'titles.newRole',
+ canMatch: [featureGuard('customRoles')],
canActivate: [requirePermission('ROLE_CREATE')],
data: { permission: 'ROLE_CREATE' },
loadComponent: () =>
@@ -343,6 +357,7 @@ export const routes: Routes = [
{
path: 'roles/:roleId/edit',
title: 'titles.editRole',
+ canMatch: [featureGuard('customRoles')],
canActivate: [requirePermission('ROLE_UPDATE')],
data: { permission: 'ROLE_UPDATE' },
loadComponent: () =>
@@ -353,7 +368,7 @@ export const routes: Routes = [
{
path: 'members',
title: 'titles.projectMembers',
- canMatch: [requirePermissionMatch('MEMBER_READ')],
+ canMatch: [featureGuard('members', requirePermissionMatch('MEMBER_READ'))],
canActivate: [requirePermission('MEMBER_READ')],
data: { permission: 'MEMBER_READ' },
loadComponent: () =>
@@ -364,7 +379,9 @@ export const routes: Routes = [
{
path: 'integrations',
title: 'titles.integrations',
- canMatch: [requirePermissionMatch('INTEGRATION_READ')],
+ canMatch: [
+ featureGuard('integrations', requirePermissionMatch('INTEGRATION_READ')),
+ ],
canActivate: [requirePermission('INTEGRATION_READ')],
data: { permission: 'INTEGRATION_READ' },
loadComponent: () =>
@@ -390,5 +407,6 @@ export const routes: Routes = [
],
},
+ // Unknown URLs — and the routes of a disabled feature (see featureGuard) — land here.
{ path: '**', redirectTo: 'projects' },
];
diff --git a/src/app/core/audio/audio-recorder.service.spec.ts b/src/app/core/audio/audio-recorder.service.spec.ts
new file mode 100644
index 0000000..3f175ec
--- /dev/null
+++ b/src/app/core/audio/audio-recorder.service.spec.ts
@@ -0,0 +1,385 @@
+import { TestBed } from '@angular/core/testing';
+import { MockInstance, vi } from 'vitest';
+import { AuthStore } from '../auth/auth.store';
+import { AuthResponse } from '../auth/auth.model';
+import { AudioRecorderService } from './audio-recorder.service';
+
+/** A capture track double: `stop()` is observable and `ended` can be dispatched. */
+class FakeTrack extends EventTarget {
+ readyState: MediaStreamTrackState = 'live';
+ readonly stop = vi.fn(() => {
+ this.readyState = 'ended';
+ });
+
+ constructor(readonly kind: 'audio' | 'video') {
+ super();
+ }
+}
+
+function fakeStream(...tracks: FakeTrack[]): MediaStream {
+ return {
+ getTracks: () => tracks,
+ getAudioTracks: () => tracks.filter((t) => t.kind === 'audio'),
+ getVideoTracks: () => tracks.filter((t) => t.kind === 'video'),
+ } as unknown as MediaStream;
+}
+
+/** The share picker result: a live audio + video pair, as Chrome returns for "Share tab audio". */
+function sharedTab(): { stream: MediaStream; audio: FakeTrack; video: FakeTrack } {
+ const audio = new FakeTrack('audio');
+ const video = new FakeTrack('video');
+ return { stream: fakeStream(video, audio), audio, video };
+}
+
+function micStream(): { stream: MediaStream; track: FakeTrack } {
+ const track = new FakeTrack('audio');
+ return { stream: fakeStream(track), track };
+}
+
+describe('AudioRecorderService — audio sources', () => {
+ let recorder: AudioRecorderService;
+ let getDisplayMedia: MockInstance;
+ let getUserMedia: MockInstance;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ recorder = TestBed.inject(AudioRecorderService);
+ // Never reach the real devices: each test scripts what the browser answers.
+ getDisplayMedia = vi
+ .spyOn(navigator.mediaDevices, 'getDisplayMedia')
+ .mockRejectedValue(new Error('unscripted getDisplayMedia'));
+ getUserMedia = vi
+ .spyOn(navigator.mediaDevices, 'getUserMedia')
+ .mockRejectedValue(new Error('unscripted getUserMedia'));
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ recorder.stopStreaming();
+ vi.restoreAllMocks();
+ });
+
+ describe('in person (microphone)', () => {
+ it('captures the microphone only', async () => {
+ getUserMedia.mockResolvedValue(micStream().stream);
+
+ expect(await recorder.requestPermission('mic')).toBe(true);
+
+ expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
+ expect(getDisplayMedia).not.toHaveBeenCalled();
+ expect(recorder.source()).toBe('mic');
+ expect(recorder.meetingAudio()).toBe(false);
+ });
+
+ it('defaults to the microphone', async () => {
+ getUserMedia.mockResolvedValue(micStream().stream);
+
+ expect(await recorder.requestPermission()).toBe(true);
+
+ expect(getDisplayMedia).not.toHaveBeenCalled();
+ expect(recorder.source()).toBe('mic');
+ });
+ });
+
+ describe('virtual meeting (microphone + meeting audio)', () => {
+ it('opens the share picker synchronously, before the microphone, to keep the click activation', async () => {
+ const tab = sharedTab();
+ getDisplayMedia.mockResolvedValue(tab.stream);
+ getUserMedia.mockResolvedValue(micStream().stream);
+
+ const pending = recorder.requestPermission('meeting');
+ // No await has happened yet: the picker must already be requested.
+ expect(getDisplayMedia).toHaveBeenCalledTimes(1);
+ expect(getUserMedia).not.toHaveBeenCalled();
+
+ expect(await pending).toBe(true);
+ expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
+ expect(getDisplayMedia.mock.invocationCallOrder[0]).toBeLessThan(
+ getUserMedia.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('asks for unprocessed system/tab audio, hides its own tab and allows switching', async () => {
+ getDisplayMedia.mockResolvedValue(sharedTab().stream);
+ getUserMedia.mockResolvedValue(micStream().stream);
+
+ await recorder.requestPermission('meeting');
+
+ expect(getDisplayMedia).toHaveBeenCalledWith(
+ expect.objectContaining({
+ video: true,
+ audio: expect.objectContaining({
+ echoCancellation: false,
+ noiseSuppression: false,
+ autoGainControl: false,
+ }),
+ systemAudio: 'include',
+ selfBrowserSurface: 'exclude',
+ surfaceSwitching: 'include',
+ }),
+ );
+ });
+
+ it('keeps the meeting audio and drops the unused video track', async () => {
+ const tab = sharedTab();
+ getDisplayMedia.mockResolvedValue(tab.stream);
+ getUserMedia.mockResolvedValue(micStream().stream);
+
+ expect(await recorder.requestPermission('meeting')).toBe(true);
+
+ expect(tab.video.stop).toHaveBeenCalled();
+ expect(tab.audio.stop).not.toHaveBeenCalled();
+ expect(recorder.source()).toBe('meeting');
+ expect(recorder.meetingAudio()).toBe(true);
+ expect(recorder.error()).toBeNull();
+ expect(recorder.notice()).toBeNull();
+ });
+
+ it('warns and does not start when the user shared without ticking "Share tab audio"', async () => {
+ const video = new FakeTrack('video');
+ getDisplayMedia.mockResolvedValue(fakeStream(video));
+
+ expect(await recorder.requestPermission('meeting')).toBe(false);
+
+ expect(video.stop).toHaveBeenCalled();
+ expect(getUserMedia).not.toHaveBeenCalled();
+ expect(recorder.notice()).toBe('discovery.rec.noMeetingAudio');
+ expect(recorder.error()).toBeNull();
+ expect(recorder.meetingAudio()).toBe(false);
+ expect(recorder.source()).toBe('mic');
+ });
+
+ it.each(['NotAllowedError', 'AbortError'])(
+ 'stays idle without any message when the picker is dismissed (%s)',
+ async (name) => {
+ getDisplayMedia.mockRejectedValue(new DOMException('dismissed', name));
+
+ expect(await recorder.requestPermission('meeting')).toBe(false);
+
+ expect(getUserMedia).not.toHaveBeenCalled();
+ expect(recorder.error()).toBeNull();
+ expect(recorder.notice()).toBeNull();
+ expect(console.error).not.toHaveBeenCalled();
+ },
+ );
+
+ it('reports a real capture failure', async () => {
+ getDisplayMedia.mockRejectedValue(new DOMException('busy', 'NotReadableError'));
+
+ expect(await recorder.requestPermission('meeting')).toBe(false);
+
+ expect(recorder.error()).toBe('discovery.rec.shareFailed');
+ expect(getUserMedia).not.toHaveBeenCalled();
+ });
+
+ it('reports an unsupported browser when getDisplayMedia is missing', async () => {
+ Object.defineProperty(navigator.mediaDevices, 'getDisplayMedia', {
+ value: undefined,
+ configurable: true,
+ });
+
+ expect(await recorder.requestPermission('meeting')).toBe(false);
+
+ expect(recorder.error()).toBe('discovery.rec.meetingUnsupported');
+ expect(getUserMedia).not.toHaveBeenCalled();
+ });
+
+ it('releases the shared tab when the microphone is denied', async () => {
+ const tab = sharedTab();
+ getDisplayMedia.mockResolvedValue(tab.stream);
+ getUserMedia.mockRejectedValue(new DOMException('denied', 'NotAllowedError'));
+
+ expect(await recorder.requestPermission('meeting')).toBe(false);
+
+ expect(tab.audio.stop).toHaveBeenCalled();
+ expect(recorder.error()).toBe('discovery.rec.micDenied');
+ expect(recorder.meetingAudio()).toBe(false);
+ });
+ });
+
+ describe('"Stop sharing" mid-session', () => {
+ it('falls back to the microphone with a notice instead of ending the recording', async () => {
+ const tab = sharedTab();
+ const mic = micStream();
+ getDisplayMedia.mockResolvedValue(tab.stream);
+ getUserMedia.mockResolvedValue(mic.stream);
+ await recorder.requestPermission('meeting');
+
+ tab.audio.dispatchEvent(new Event('ended'));
+
+ expect(recorder.meetingAudio()).toBe(false);
+ expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded');
+ expect(mic.track.stop).not.toHaveBeenCalled();
+ // Still a meeting recording: the bar offers to share again.
+ expect(recorder.source()).toBe('meeting');
+ });
+
+ it('re-shares the meeting audio from the session bar', async () => {
+ const first = sharedTab();
+ const second = sharedTab();
+ getDisplayMedia.mockResolvedValueOnce(first.stream).mockResolvedValueOnce(second.stream);
+ getUserMedia.mockResolvedValue(micStream().stream);
+ await recorder.requestPermission('meeting');
+ first.audio.dispatchEvent(new Event('ended'));
+
+ expect(await recorder.shareMeetingAudio()).toBe(true);
+
+ expect(recorder.meetingAudio()).toBe(true);
+ expect(recorder.notice()).toBeNull();
+ // A late event from the old share never drops the new one.
+ first.audio.dispatchEvent(new Event('ended'));
+ expect(recorder.meetingAudio()).toBe(true);
+ expect(second.audio.stop).not.toHaveBeenCalled();
+ });
+
+ it('keeps the "meeting audio lost" notice when the re-share picker is dismissed', async () => {
+ const tab = sharedTab();
+ getDisplayMedia
+ .mockResolvedValueOnce(tab.stream)
+ .mockRejectedValueOnce(new DOMException('dismissed', 'NotAllowedError'));
+ getUserMedia.mockResolvedValue(micStream().stream);
+ await recorder.requestPermission('meeting');
+ tab.audio.dispatchEvent(new Event('ended'));
+
+ expect(await recorder.shareMeetingAudio()).toBe(false);
+
+ expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded');
+ expect(recorder.meetingAudio()).toBe(false);
+ });
+ });
+
+ describe('pause and stop', () => {
+ it('keeps the shared tab across a pause (no picker on resume) but releases the microphone', async () => {
+ const tab = sharedTab();
+ const mic = micStream();
+ getDisplayMedia.mockResolvedValue(tab.stream);
+ getUserMedia.mockResolvedValue(mic.stream);
+ await recorder.requestPermission('meeting');
+
+ recorder.pauseStreaming();
+
+ expect(mic.track.stop).toHaveBeenCalled();
+ expect(tab.audio.stop).not.toHaveBeenCalled();
+ expect(recorder.meetingAudio()).toBe(true);
+ });
+
+ it('releases everything on stop and returns to the microphone source', async () => {
+ const tab = sharedTab();
+ const mic = micStream();
+ getDisplayMedia.mockResolvedValue(tab.stream);
+ getUserMedia.mockResolvedValue(mic.stream);
+ await recorder.requestPermission('meeting');
+ tab.audio.dispatchEvent(new Event('ended'));
+
+ recorder.stopStreaming();
+
+ expect(mic.track.stop).toHaveBeenCalled();
+ expect(tab.audio.stop).toHaveBeenCalled();
+ expect(recorder.meetingAudio()).toBe(false);
+ expect(recorder.source()).toBe('mic');
+ expect(recorder.notice()).toBeNull();
+ });
+ });
+});
+
+/** Minimal `/ws/stt` socket that opens on the next macrotask and records what is sent. */
+class FakeWebSocket {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSING = 2;
+ static readonly CLOSED = 3;
+ static last: FakeWebSocket | null = null;
+
+ readyState = FakeWebSocket.CONNECTING;
+ binaryType = 'blob';
+ onopen: (() => void) | null = null;
+ onerror: ((e: Event) => void) | null = null;
+ onclose: ((e: CloseEvent) => void) | null = null;
+ readonly close = vi.fn(() => {
+ this.readyState = FakeWebSocket.CLOSED;
+ });
+
+ constructor(readonly url: string) {
+ FakeWebSocket.last = this;
+ setTimeout(() => {
+ this.readyState = FakeWebSocket.OPEN;
+ this.onopen?.();
+ });
+ }
+
+ send(): void {
+ // PCM chunks are irrelevant to the graph-wiring assertions.
+ }
+}
+
+describe('AudioRecorderService — mixing', () => {
+ let recorder: AudioRecorderService;
+ let toneContext: AudioContext;
+
+ /** A real, silent audio track (the Web Audio graph rejects doubles). */
+ function realAudioStream(): MediaStream {
+ return toneContext.createMediaStreamDestination().stream;
+ }
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ TestBed.inject(AuthStore).setSession({
+ accessToken: 'token',
+ user: { id: 'user-1' },
+ organizationId: null,
+ } as unknown as AuthResponse);
+ recorder = TestBed.inject(AudioRecorderService);
+ toneContext = new AudioContext();
+ vi.stubGlobal('WebSocket', FakeWebSocket);
+ });
+
+ afterEach(async () => {
+ recorder.stopStreaming();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ await toneContext.close();
+ });
+
+ it('sums the mic and the meeting audio into one mono mixer and keeps the mic when sharing stops', async () => {
+ const display = realAudioStream();
+ const mic = realAudioStream();
+ const displayTrack = display.getAudioTracks()[0];
+ vi.spyOn(navigator.mediaDevices, 'getDisplayMedia').mockResolvedValue(display);
+ vi.spyOn(navigator.mediaDevices, 'getUserMedia').mockResolvedValue(mic);
+ const createGain = vi.spyOn(AudioContext.prototype, 'createGain');
+ const createSource = vi.spyOn(AudioContext.prototype, 'createMediaStreamSource');
+ const connect = vi.spyOn(AudioNode.prototype, 'connect');
+ /** The nodes `from` feeds, in connection order. */
+ const targetsOf = (from: AudioNode): unknown[] =>
+ connect.mock.calls.filter((_, i) => connect.mock.contexts[i] === from).map(([to]) => to);
+
+ expect(await recorder.requestPermission('meeting')).toBe(true);
+ await recorder.startStreaming('sess-1');
+ // The worklet is wired last, once its module has loaded.
+ await vi.waitFor(() =>
+ expect(connect.mock.calls.some(([to]) => to instanceof AudioWorkletNode)).toBe(true),
+ );
+
+ const mixer = createGain.mock.results[0].value as GainNode;
+ expect(mixer.channelCount).toBe(1);
+ expect(mixer.channelCountMode).toBe('explicit');
+ expect(mixer.channelInterpretation).toBe('speakers');
+ expect(createSource.mock.calls.map(([stream]) => stream)).toEqual([mic, display]);
+ for (const { value: source } of createSource.mock.results) {
+ expect(targetsOf(source as AudioNode)).toEqual([mixer]);
+ }
+ // Both the level meter and the PCM worklet read the mix, not a single source.
+ const [meter, worklet] = targetsOf(mixer);
+ expect(meter).toBeInstanceOf(AnalyserNode);
+ expect(worklet).toBeInstanceOf(AudioWorkletNode);
+
+ const displaySource = createSource.mock.results[1].value as MediaStreamAudioSourceNode;
+ const disconnect = vi.spyOn(displaySource, 'disconnect');
+ displayTrack.dispatchEvent(new Event('ended'));
+
+ expect(disconnect).toHaveBeenCalled();
+ expect(recorder.streaming()).toBe(true);
+ expect(FakeWebSocket.last?.close).not.toHaveBeenCalled();
+ expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded');
+ });
+});
diff --git a/src/app/core/audio/audio-recorder.service.ts b/src/app/core/audio/audio-recorder.service.ts
index fbc1cb0..794e5d6 100644
--- a/src/app/core/audio/audio-recorder.service.ts
+++ b/src/app/core/audio/audio-recorder.service.ts
@@ -1,26 +1,42 @@
import { Injectable, inject, signal } from '@angular/core';
import { AuthStore } from '../auth/auth.store';
import { environment } from '../../../environments/environment';
+import { AudioSource, MEETING_DISPLAY_MEDIA_OPTIONS, isCaptureDismissed } from './audio-source';
/** Number of bars exposed by the live input-level meter. */
const LEVEL_BARS = 12;
/** How often the level meter refreshes (ms) — cheap enough to leave running. */
const LEVEL_INTERVAL_MS = 100;
+/** Outcome of asking the user to share the meeting's tab, window or screen. */
+type MeetingShare = 'shared' | 'dismissed' | 'no-audio' | 'unsupported' | 'failed';
+
/**
- * Handles browser microphone recording, Web Audio downsampling to 16kHz 16-bit Mono PCM,
+ * Handles browser audio capture, Web Audio downsampling to 16kHz 16-bit Mono PCM,
* and real-time binary WebSocket streaming to the backend STT endpoint `/ws/stt`.
*
+ * Two sources (see {@link AudioSource}): the microphone alone, or — for virtual
+ * meetings — the microphone mixed with the audio of a surface shared through
+ * the screen-share picker. Both feed one mono mixer, so the backend always gets
+ * the same PCM stream. The shared surface survives pauses (resuming never
+ * re-opens the picker); if the user stops sharing, the mic keeps streaming.
+ *
* `error` holds an i18n key (`discovery.rec.*`) — the UI translates it, so this
- * service stays locale-agnostic. `levels` is a small AnalyserNode-driven input
- * meter (0..1 per bar) for a real waveform while streaming.
+ * service stays locale-agnostic; `notice` is the non-fatal counterpart (e.g.
+ * meeting audio lost while the recording carries on). `levels` is a small
+ * AnalyserNode-driven input meter (0..1 per bar) of the mix while streaming.
*/
@Injectable({ providedIn: 'root' })
export class AudioRecorderService {
private readonly auth = inject(AuthStore);
- private mediaStream: MediaStream | null = null;
+ private micStream: MediaStream | null = null;
+ /** The shared meeting surface (its video track already stopped); outlives pauses. */
+ private displayStream: MediaStream | null = null;
private audioContext: AudioContext | null = null;
+ /** Mono summing node every source feeds; drives the level meter and the worklet. */
+ private mixer: GainNode | null = null;
+ private displaySource: MediaStreamAudioSourceNode | null = null;
private audioWorkletNode: AudioWorkletNode | null = null;
private analyser: AnalyserNode | null = null;
private levelTimer: ReturnType | null = null;
@@ -29,32 +45,67 @@ export class AudioRecorderService {
/** i18n key of the current error, or null. */
readonly error = signal(null);
+ /** i18n key of a non-fatal warning (the recording, if any, carries on), or null. */
+ readonly notice = signal(null);
readonly streaming = signal(false);
/** Live input levels (0..1 per bar); empty while not streaming. */
readonly levels = signal([]);
+ /** Source of the current recording; back to `mic` once it stops. */
+ readonly source = signal('mic');
+ /** True while shared meeting audio is held (and mixed in whenever streaming). */
+ readonly meetingAudio = signal(false);
/**
- * Request microphone permission. Returns true if granted.
+ * Secures the capture for `source` before a recording starts. Returns true if granted.
+ * For `meeting` the screen-share picker opens FIRST: it needs the click's transient
+ * activation, which any earlier await could use up. The microphone follows.
*/
- async requestPermission(): Promise {
- try {
- this.error.set(null);
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- // Store the stream so we can reuse or stop it.
- this.mediaStream = stream;
- return true;
- } catch (err) {
- console.error('Microphone permission denied/failed:', err);
- this.error.set('discovery.rec.micDenied');
+ async requestPermission(source: AudioSource = 'mic'): Promise {
+ this.error.set(null);
+ this.notice.set(null);
+ this.releaseMic();
+
+ if (source === 'meeting') {
+ const share = await this.shareDisplay();
+ if (share !== 'shared') {
+ // Nothing starts: the user stays idle and can retry with one click.
+ this.reportShare(share);
+ return false;
+ }
+ } else {
+ this.releaseDisplay();
+ }
+
+ if (!(await this.acquireMic())) {
+ this.releaseDisplay();
return false;
}
+ this.source.set(source);
+ return true;
}
/**
- * Connects to `/ws/stt` and begins downsampling and streaming microphone input.
+ * Re-opens the screen-share picker during a session (e.g. after "Stop sharing")
+ * and mixes the new meeting audio into the live recording, or into the next
+ * resume while paused. Call it straight from a click handler.
+ */
+ async shareMeetingAudio(): Promise {
+ this.error.set(null);
+ const share = await this.shareDisplay();
+ if (share !== 'shared') {
+ this.reportShare(share);
+ return false;
+ }
+ this.notice.set(null);
+ this.source.set('meeting');
+ return true;
+ }
+
+ /**
+ * Connects to `/ws/stt` and begins downsampling and streaming the captured audio.
*/
async startStreaming(sessionId: string): Promise {
- this.stopStreaming();
+ this.stopPipeline();
this.error.set(null);
const token = this.auth.accessToken();
@@ -64,11 +115,15 @@ export class AudioRecorderService {
}
try {
- // Ensure microphone access has been requested
- if (!this.mediaStream) {
- const granted = await this.requestPermission();
+ // The mic is released on pause, so a resume re-acquires it (no prompt once granted).
+ if (!this.micStream) {
+ const granted = await this.acquireMic();
if (!granted) return;
}
+ if (this.source() === 'meeting' && !this.displayStream) {
+ // Sharing ended earlier (e.g. while paused): carry on with the mic only.
+ this.notice.set('discovery.rec.meetingAudioEnded');
+ }
const url = sttUrl(sessionId, token);
this.webSocket = new WebSocket(url);
@@ -79,10 +134,12 @@ export class AudioRecorderService {
void this.startAudioProcessing();
};
+ // Transport failures keep the shared meeting surface, so pause/resume can
+ // reconnect without the picker; stopping the session releases it.
this.webSocket.onerror = (e) => {
console.error('STT WebSocket error:', e);
this.error.set('discovery.rec.wsError');
- this.stopStreaming();
+ this.pauseStreaming();
};
this.webSocket.onclose = (event) => {
@@ -91,7 +148,7 @@ export class AudioRecorderService {
console.warn(`STT WebSocket closed abnormally: ${event.code} (${event.reason})`);
this.error.set('discovery.rec.wsClosed');
}
- this.stopStreaming();
+ this.pauseStreaming();
};
} catch (err) {
console.error('Failed to start streaming:', err);
@@ -101,9 +158,119 @@ export class AudioRecorderService {
}
/**
- * Stops recording, releases the microphone, and closes the WebSocket connection.
+ * Pauses: closes the WebSocket and the audio graph and releases the microphone,
+ * but keeps the shared meeting surface so resuming never re-opens the picker.
+ */
+ pauseStreaming(): void {
+ this.stopPipeline();
+ this.releaseMic();
+ }
+
+ /**
+ * Stops recording, releases the microphone and the shared meeting surface,
+ * and closes the WebSocket connection.
*/
stopStreaming(): void {
+ this.pauseStreaming();
+ this.releaseDisplay();
+ this.source.set('mic');
+ this.notice.set(null);
+ }
+
+ private async acquireMic(): Promise {
+ try {
+ this.micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ return true;
+ } catch (err) {
+ console.error('Microphone permission denied/failed:', err);
+ this.error.set('discovery.rec.micDenied');
+ return false;
+ }
+ }
+
+ /**
+ * Opens the screen-share picker and keeps the chosen surface's audio. Its first
+ * await is `getDisplayMedia` itself, so the caller's click activation is intact.
+ */
+ private async shareDisplay(): Promise {
+ if (typeof navigator.mediaDevices?.getDisplayMedia !== 'function') return 'unsupported';
+
+ let stream: MediaStream;
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia(MEETING_DISPLAY_MEDIA_OPTIONS);
+ } catch (err) {
+ if (isCaptureDismissed(err)) return 'dismissed';
+ console.error('Meeting audio capture failed:', err);
+ return 'failed';
+ }
+
+ const [audioTrack] = stream.getAudioTracks();
+ if (!audioTrack) {
+ // Shared without ticking "Share tab audio" (or a surface that carries none).
+ stream.getTracks().forEach((track) => track.stop());
+ return 'no-audio';
+ }
+
+ // Only the audio is used. Stopping the video track ends the frame capture while
+ // the audio track stays live, and "Stop sharing" still ends the audio track.
+ stream.getVideoTracks().forEach((track) => track.stop());
+ this.releaseDisplay();
+ this.displayStream = stream;
+ audioTrack.addEventListener('ended', () => this.onMeetingAudioEnded(stream), { once: true });
+ this.meetingAudio.set(true);
+ this.connectMeetingAudio();
+ return 'shared';
+ }
+
+ /**
+ * Surfaces a failed share. A dismissed picker is the user's own choice, so it
+ * reports nothing (and keeps any current notice, e.g. "meeting audio lost").
+ */
+ private reportShare(share: Exclude): void {
+ switch (share) {
+ case 'dismissed':
+ break;
+ case 'no-audio':
+ this.notice.set('discovery.rec.noMeetingAudio');
+ break;
+ case 'unsupported':
+ this.error.set('discovery.rec.meetingUnsupported');
+ break;
+ case 'failed':
+ this.error.set('discovery.rec.shareFailed');
+ break;
+ }
+ }
+
+ /** "Stop sharing" or the shared tab closing: drop the meeting audio, keep the mic. */
+ private onMeetingAudioEnded(stream: MediaStream): void {
+ if (this.displayStream !== stream) return;
+ this.releaseDisplay();
+ this.notice.set('discovery.rec.meetingAudioEnded');
+ }
+
+ /** Feeds the held meeting audio into the live mixer (no-op until the graph exists). */
+ private connectMeetingAudio(): void {
+ if (!this.audioContext || !this.mixer || !this.displayStream || this.displaySource) return;
+ this.displaySource = this.audioContext.createMediaStreamSource(this.displayStream);
+ this.displaySource.connect(this.mixer);
+ }
+
+ private releaseMic(): void {
+ this.micStream?.getTracks().forEach((track) => track.stop());
+ this.micStream = null;
+ }
+
+ private releaseDisplay(): void {
+ this.displaySource?.disconnect();
+ this.displaySource = null;
+ this.displayStream?.getTracks().forEach((track) => track.stop());
+ this.displayStream = null;
+ this.meetingAudio.set(false);
+ }
+
+ /** Tears down the WebSocket and the audio graph, leaving the captured streams alone. */
+ private stopPipeline(): void {
this.streaming.set(false);
if (this.levelTimer !== null) {
@@ -119,6 +286,8 @@ export class AudioRecorderService {
this.audioWorkletNode = null;
}
+ this.displaySource = null;
+ this.mixer = null;
if (this.audioContext) {
if (this.audioContext.state !== 'closed') {
void this.audioContext.close();
@@ -126,12 +295,11 @@ export class AudioRecorderService {
this.audioContext = null;
}
- if (this.mediaStream) {
- this.mediaStream.getTracks().forEach((track) => track.stop());
- this.mediaStream = null;
- }
-
if (this.webSocket) {
+ // Detach first: a late close event from this socket must not tear down the next one.
+ this.webSocket.onopen = null;
+ this.webSocket.onerror = null;
+ this.webSocket.onclose = null;
if (
this.webSocket.readyState === WebSocket.OPEN ||
this.webSocket.readyState === WebSocket.CONNECTING
@@ -145,20 +313,30 @@ export class AudioRecorderService {
}
private async startAudioProcessing(): Promise {
- if (!this.mediaStream) return;
+ if (!this.micStream) return;
try {
const AudioCtx =
window.AudioContext ||
(window as unknown as Window & { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
- this.audioContext = new AudioCtx();
- const source = this.audioContext.createMediaStreamSource(this.mediaStream);
-
- // Small analyser tap for the UI level meter (real waveform, no fakery).
- this.analyser = this.audioContext.createAnalyser();
+ const context = new AudioCtx();
+ this.audioContext = context;
+
+ // Every source sums into one mono mixer; the stereo meeting audio is down-mixed
+ // (speakers interpretation) so the rest of the chain sees a single channel.
+ const mixer = context.createGain();
+ mixer.channelCount = 1;
+ mixer.channelCountMode = 'explicit';
+ mixer.channelInterpretation = 'speakers';
+ this.mixer = mixer;
+ context.createMediaStreamSource(this.micStream).connect(mixer);
+ this.connectMeetingAudio();
+
+ // Small analyser tap for the UI level meter (real waveform of the mix, no fakery).
+ this.analyser = context.createAnalyser();
this.analyser.fftSize = 64;
- source.connect(this.analyser);
+ mixer.connect(this.analyser);
this.startLevelMeter();
// Define AudioWorklet inline to keep code self-contained and run on audio thread
@@ -177,14 +355,18 @@ export class AudioRecorderService {
const blob = new Blob([workletCode], { type: 'application/javascript' });
const blobURL = URL.createObjectURL(blob);
- await this.audioContext.audioWorklet.addModule(blobURL);
+ await context.audioWorklet.addModule(blobURL);
URL.revokeObjectURL(blobURL);
+ // Paused or stopped while the module loaded: this graph is already torn down.
+ if (this.audioContext !== context) return;
- this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
- source.connect(this.audioWorkletNode);
- this.audioWorkletNode.connect(this.audioContext.destination);
+ this.audioWorkletNode = new AudioWorkletNode(context, 'audio-processor');
+ mixer.connect(this.audioWorkletNode);
+ // The processor never writes its output, so this only keeps the node pulled by
+ // the render graph: the speakers get silence, never an echo of the mix.
+ this.audioWorkletNode.connect(context.destination);
- const inRate = this.audioContext.sampleRate;
+ const inRate = context.sampleRate;
const outRate = 16000;
const CHUNK_SIZE = 2048; // 2048 samples = 4096 bytes (since 1 sample is 2 bytes/16-bit)
diff --git a/src/app/core/audio/audio-source.spec.ts b/src/app/core/audio/audio-source.spec.ts
new file mode 100644
index 0000000..d3c6429
--- /dev/null
+++ b/src/app/core/audio/audio-source.spec.ts
@@ -0,0 +1,64 @@
+import { isCaptureDismissed, supportsMeetingAudio } from './audio-source';
+
+/** A navigator stub: optional getDisplayMedia and optional UA Client Hints. */
+function fakeNavigator(options: {
+ displayMedia?: boolean;
+ brands?: string[];
+ mobile?: boolean;
+ mediaDevices?: boolean;
+}): Navigator {
+ const { displayMedia = true, brands, mobile = false, mediaDevices = true } = options;
+ return {
+ mediaDevices: mediaDevices
+ ? { getDisplayMedia: displayMedia ? () => Promise.resolve() : undefined }
+ : undefined,
+ userAgentData: brands ? { brands: brands.map((brand) => ({ brand })), mobile } : undefined,
+ } as unknown as Navigator;
+}
+
+describe('supportsMeetingAudio', () => {
+ it('is available on desktop Chrome and Edge', () => {
+ expect(
+ supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Google Chrome', 'Not.A/Brand'] })),
+ ).toBe(true);
+ expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Microsoft Edge'] }))).toBe(
+ true,
+ );
+ });
+
+ it('is unavailable on Firefox and Safari (no UA Client Hints, no display audio)', () => {
+ expect(supportsMeetingAudio(fakeNavigator({}))).toBe(false);
+ });
+
+ it('is unavailable on mobile Chromium', () => {
+ expect(
+ supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Google Chrome'], mobile: true })),
+ ).toBe(false);
+ });
+
+ it('is unavailable without getDisplayMedia or mediaDevices', () => {
+ expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium'], displayMedia: false }))).toBe(
+ false,
+ );
+ expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium'], mediaDevices: false }))).toBe(
+ false,
+ );
+ });
+
+ it('is unavailable on non-Chromium engines exposing client hints', () => {
+ expect(supportsMeetingAudio(fakeNavigator({ brands: ['SomeOtherEngine'] }))).toBe(false);
+ });
+});
+
+describe('isCaptureDismissed', () => {
+ it('treats a closed picker or a denied prompt as a dismissal', () => {
+ expect(isCaptureDismissed(new DOMException('Permission denied', 'NotAllowedError'))).toBe(true);
+ expect(isCaptureDismissed(new DOMException('Aborted', 'AbortError'))).toBe(true);
+ });
+
+ it('reports real failures', () => {
+ expect(isCaptureDismissed(new DOMException('Busy', 'NotReadableError'))).toBe(false);
+ expect(isCaptureDismissed(new TypeError('bad constraints'))).toBe(false);
+ expect(isCaptureDismissed(null)).toBe(false);
+ });
+});
diff --git a/src/app/core/audio/audio-source.ts b/src/app/core/audio/audio-source.ts
new file mode 100644
index 0000000..919f90a
--- /dev/null
+++ b/src/app/core/audio/audio-source.ts
@@ -0,0 +1,71 @@
+/**
+ * Where the recorder takes its audio from: `mic` for face-to-face meetings, or
+ * `meeting` for virtual calls (Zoom / Meet / Teams), which mixes the microphone
+ * with the audio of a shared tab, window or screen.
+ */
+export type AudioSource = 'mic' | 'meeting';
+
+/** Chrome's `suppressLocalAudioPlayback` audio constraint (not in lib.dom yet). */
+interface DisplayAudioConstraints extends MediaTrackConstraints {
+ suppressLocalAudioPlayback?: boolean;
+}
+
+/** Chrome's display-capture extensions on top of the standard options (not in lib.dom yet). */
+interface MeetingDisplayMediaOptions extends Omit {
+ audio: DisplayAudioConstraints;
+ systemAudio?: 'include' | 'exclude';
+ windowAudio?: 'exclude' | 'window' | 'system';
+ selfBrowserSurface?: 'include' | 'exclude';
+ surfaceSwitching?: 'include' | 'exclude';
+}
+
+/** User-Agent Client Hints, exposed by Chromium-based browsers only (not in lib.dom yet). */
+interface NavigatorUAData {
+ readonly brands: readonly { readonly brand: string }[];
+ readonly mobile: boolean;
+}
+
+/**
+ * The screen-share picker request for meeting audio. Chrome rejects audio-only
+ * requests, so `video` stays true (the recorder stops that track right away).
+ * The meeting audio is already clean digital audio, so the voice-call
+ * processing is disabled; the tab keeps playing locally so the analyst still
+ * hears the call. ReqsAI's own tab is hidden from the picker.
+ */
+export const MEETING_DISPLAY_MEDIA_OPTIONS: MeetingDisplayMediaOptions = {
+ video: true,
+ audio: {
+ echoCancellation: false,
+ noiseSuppression: false,
+ autoGainControl: false,
+ suppressLocalAudioPlayback: false,
+ },
+ systemAudio: 'include',
+ windowAudio: 'system',
+ selfBrowserSurface: 'exclude',
+ surfaceSwitching: 'include',
+};
+
+/**
+ * True when this browser can capture a meeting's audio through the screen-share
+ * picker: desktop Chromium (Chrome, Edge…). Firefox and Safari implement
+ * `getDisplayMedia` without tab/system audio, and mobile browsers lack it, so
+ * the "virtual meeting" source is offered on desktop Chromium only.
+ */
+export function supportsMeetingAudio(nav: Navigator | undefined = globalThis.navigator): boolean {
+ if (typeof nav?.mediaDevices?.getDisplayMedia !== 'function') return false;
+ const uaData = (nav as Navigator & { userAgentData?: NavigatorUAData }).userAgentData;
+ if (!uaData || uaData.mobile) return false;
+ return uaData.brands.some((b) => b.brand === 'Chromium');
+}
+
+/**
+ * True when a capture request failed because the user dismissed the picker or
+ * the permission prompt (`NotAllowedError`, or `AbortError` in some browsers),
+ * as opposed to a real failure worth reporting.
+ */
+export function isCaptureDismissed(err: unknown): boolean {
+ return (
+ err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'AbortError')
+ );
+}
diff --git a/src/app/core/features/feature-flags.ts b/src/app/core/features/feature-flags.ts
new file mode 100644
index 0000000..93567cf
--- /dev/null
+++ b/src/app/core/features/feature-flags.ts
@@ -0,0 +1,42 @@
+import { Injectable, InjectionToken, Provider, inject } from '@angular/core';
+import { environment } from '../../../environments/environment';
+
+/**
+ * The product areas that can be switched off per build. A disabled feature disappears from the
+ * UI — nav entries, command-palette actions, buttons and CTAs — and its routes stop matching, so
+ * its URLs behave like unknown paths. The code and the backend stay in place: turning a flag back
+ * on in the environment files needs no other change. See docs/FEATURE-FLAGS.md.
+ */
+export type FeatureKey =
+ 'billing' | 'usage' | 'integrations' | 'members' | 'customRoles' | 'notifications' | 'tokens';
+
+/** One on/off switch per {@link FeatureKey}: the shape every environment file must provide. */
+export type FeatureFlagMap = Readonly>;
+
+/** The active flag map: the build's environment by default (see {@link provideFeatureFlags}). */
+const FEATURE_FLAGS = new InjectionToken('FEATURE_FLAGS', {
+ providedIn: 'root',
+ factory: () => environment.features,
+});
+
+/**
+ * Overrides individual flags on top of the environment's map, e.g.
+ * `provideFeatureFlags({ members: true })` in a test's providers.
+ */
+export function provideFeatureFlags(overrides: Partial): Provider {
+ return { provide: FEATURE_FLAGS, useValue: { ...environment.features, ...overrides } };
+}
+
+/**
+ * Read-only access to the build's feature flags. The map is static for the lifetime of the
+ * app (it comes from the environment file), so callers can read a flag once into a field.
+ */
+@Injectable({ providedIn: 'root' })
+export class FeatureFlags {
+ private readonly flags = inject(FEATURE_FLAGS);
+
+ /** True when `feature` is switched on for this build. */
+ isEnabled(feature: FeatureKey): boolean {
+ return this.flags[feature];
+ }
+}
diff --git a/src/app/core/guards/feature.guard.spec.ts b/src/app/core/guards/feature.guard.spec.ts
new file mode 100644
index 0000000..a1af175
--- /dev/null
+++ b/src/app/core/guards/feature.guard.spec.ts
@@ -0,0 +1,82 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { CanMatchFn, Route, Router, UrlSegment, provideRouter } from '@angular/router';
+import { vi } from 'vitest';
+import { provideFeatureFlags } from '../features/feature-flags';
+import { featureGuard } from './feature.guard';
+
+@Component({ template: '' })
+class Page {}
+
+const route = {} as Route;
+const segments = [] as UrlSegment[];
+
+/** Runs a `CanMatch` guard the way the router does: inside the injection context. */
+function run(guard: CanMatchFn): unknown {
+ return TestBed.runInInjectionContext(() => guard(route, segments, {} as never));
+}
+
+describe('featureGuard', () => {
+ describe('as a guard function', () => {
+ it('does not match while the feature is off', () => {
+ TestBed.configureTestingModule({ providers: [provideFeatureFlags({ billing: false })] });
+ expect(run(featureGuard('billing'))).toBe(false);
+ });
+
+ it('matches while the feature is on', () => {
+ TestBed.configureTestingModule({ providers: [provideFeatureFlags({ billing: true })] });
+ expect(run(featureGuard('billing'))).toBe(true);
+ });
+
+ it('never runs the chained guard while the feature is off', () => {
+ TestBed.configureTestingModule({ providers: [provideFeatureFlags({ members: false })] });
+ const next = vi.fn(() => true);
+
+ expect(run(featureGuard('members', next))).toBe(false);
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('defers to the chained guard while the feature is on', () => {
+ TestBed.configureTestingModule({ providers: [provideFeatureFlags({ members: true })] });
+ const next = vi.fn(() => false);
+
+ expect(run(featureGuard('members', next))).toBe(false);
+ expect(next).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe('in a route table', () => {
+ function navigate(flags: { billing: boolean }, url: string): Promise {
+ TestBed.configureTestingModule({
+ providers: [
+ provideFeatureFlags(flags),
+ provideRouter([
+ { path: 'home', component: Page },
+ {
+ path: 'settings',
+ children: [
+ { path: 'general', component: Page },
+ { path: 'billing', canMatch: [featureGuard('billing')], component: Page },
+ ],
+ },
+ { path: '**', redirectTo: 'home' },
+ ]),
+ ],
+ });
+ const router = TestBed.inject(Router);
+ return router.navigateByUrl(url).then(() => router.url);
+ }
+
+ it('makes a disabled route behave like an unknown URL (wildcard fallback)', async () => {
+ expect(await navigate({ billing: false }, '/settings/billing')).toBe('/home');
+ });
+
+ it('leaves sibling routes of a disabled one untouched', async () => {
+ expect(await navigate({ billing: false }, '/settings/general')).toBe('/settings/general');
+ });
+
+ it('resolves the route normally once the feature is on', async () => {
+ expect(await navigate({ billing: true }, '/settings/billing')).toBe('/settings/billing');
+ });
+ });
+});
diff --git a/src/app/core/guards/feature.guard.ts b/src/app/core/guards/feature.guard.ts
new file mode 100644
index 0000000..192985d
--- /dev/null
+++ b/src/app/core/guards/feature.guard.ts
@@ -0,0 +1,24 @@
+import { inject } from '@angular/core';
+import { CanMatchFn } from '@angular/router';
+import { FeatureFlags, FeatureKey } from '../features/feature-flags';
+
+/**
+ * `CanMatch` guard factory for a feature-flagged route. While `feature` is off the route
+ * simply does not match: the router keeps looking, so the URL falls through to the wildcard
+ * fallback exactly like an unknown path — no blank page, no "no access" toast, and the lazy
+ * chunk is never fetched. While it is on, the route matches as usual, or `next` decides.
+ *
+ * Pass the route's own `CanMatch` guard (e.g. `requirePermissionMatch`) as `next` instead of
+ * listing it beside this one: Angular runs every guard of a `canMatch` array eagerly, so a
+ * sibling permission guard would still fetch permissions — and toast a denial — for a route
+ * that is switched off.
+ *
+ * Usage: `canMatch: [featureGuard('billing')]`, or
+ * `canMatch: [featureGuard('members', requirePermissionMatch('MEMBER_READ'))]`.
+ */
+export function featureGuard(feature: FeatureKey, next?: CanMatchFn): CanMatchFn {
+ return (route, segments, currentSnapshot) => {
+ if (!inject(FeatureFlags).isEnabled(feature)) return false;
+ return next ? next(route, segments, currentSnapshot) : true;
+ };
+}
diff --git a/src/app/core/guards/permission.guard.ts b/src/app/core/guards/permission.guard.ts
index 2b62dfe..23fb155 100644
--- a/src/app/core/guards/permission.guard.ts
+++ b/src/app/core/guards/permission.guard.ts
@@ -12,6 +12,7 @@ import { TranslocoService } from '@jsverse/transloco';
import { firstValueFrom } from 'rxjs';
import { AuthStore } from '../auth/auth.store';
import { PermissionsStore } from '../authz/permissions.store';
+import { FeatureFlags } from '../features/feature-flags';
import { OrgRole } from '../authz/permissions.models';
import { ToastService } from '../../shared/toast/toast.service';
@@ -22,6 +23,7 @@ import { ToastService } from '../../shared/toast/toast.service';
*/
interface GuardDeps {
auth: AuthStore;
+ flags: FeatureFlags;
permissions: PermissionsStore;
router: Router;
toast: ToastService;
@@ -31,6 +33,7 @@ interface GuardDeps {
function resolveDeps(): GuardDeps {
return {
auth: inject(AuthStore),
+ flags: inject(FeatureFlags),
permissions: inject(PermissionsStore),
router: inject(Router),
toast: inject(ToastService),
@@ -163,8 +166,9 @@ export function requireOrgRole(role: OrgRole): CanActivateFn {
* Landing redirect for the project Settings index. The fixed `general` default is
* gated by `PROJECT_UPDATE`, so a member who can only reach (say) Members would be
* bounced with a "no access" toast just for entering Settings. Instead, redirect to the
- * FIRST settings sub-page the caller can actually access; fall back to the project
- * overview when none apply. No toast — this is a silent landing choice, not a denial.
+ * FIRST settings sub-page the caller can actually access — skipping the pages of a
+ * disabled feature — and fall back to the project overview when none apply. No toast:
+ * this is a silent landing choice, not a denial.
*/
export const projectSettingsLanding: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
const deps = resolveDeps();
@@ -174,8 +178,8 @@ export const projectSettingsLanding: CanActivateFn = async (route: ActivatedRout
const can = (p: string) => deps.permissions.isOrgOwnerOrAdmin() || deps.permissions.has(p);
const target =
(can('PROJECT_UPDATE') && 'general') ||
- (can('MEMBER_READ') && 'members') ||
- (can('ROLE_READ') && 'roles') ||
+ (deps.flags.isEnabled('members') && can('MEMBER_READ') && 'members') ||
+ (deps.flags.isEnabled('customRoles') && can('ROLE_READ') && 'roles') ||
(can('PROJECT_DELETE') && 'danger') ||
null;
return projectId && target
diff --git a/src/app/features/billing/pages/usage/usage.ts b/src/app/features/billing/pages/usage/usage.ts
index 7c22dfe..d8ed2d9 100644
--- a/src/app/features/billing/pages/usage/usage.ts
+++ b/src/app/features/billing/pages/usage/usage.ts
@@ -5,6 +5,7 @@ import { lucideCircleAlert } from '@ng-icons/lucide';
import { TranslocoPipe } from '@jsverse/transloco';
import { RouterLink } from '@angular/router';
import { AuthStore } from '../../../../core/auth/auth.store';
+import { FeatureFlags } from '../../../../core/features/feature-flags';
import { HlmButton, HlmIcon, HlmSkeleton } from '../../../../shared/ui';
import { BillingStore } from '../../data/billing.store';
@@ -98,9 +99,11 @@ import { BillingStore } from '../../data/billing.store';
{{ 'billing.planName.' + u.planType | transloco }}
-
- {{ 'usage.managePlan' | transloco }}
-
+ @if (billingEnabled) {
+
+ {{ 'usage.managePlan' | transloco }}
+
+ }
}
@@ -113,6 +116,8 @@ export class Usage {
protected readonly usage = this.store.usage;
protected readonly usageState = this.store.usageState;
+ /** "Manage plan" opens the billing page, so it needs the `billing` feature as well. */
+ protected readonly billingEnabled = inject(FeatureFlags).isEnabled('billing');
protected readonly clampedPercentage = computed(() => {
const pct = this.usage()?.usagePercentage ?? 0;
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
new file mode 100644
index 0000000..771802a
--- /dev/null
+++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts
@@ -0,0 +1,79 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TranslocoTestingModule } from '@jsverse/transloco';
+import { AudioSourcePicker } from './audio-source-picker';
+import { AudioSource } from '../../../../core/audio/audio-source';
+
+describe('AudioSourcePicker', () => {
+ function render(
+ inputs: { value?: AudioSource; meetingSupported?: boolean; disabled?: boolean } = {},
+ ): { fixture: ComponentFixture; el: HTMLElement } {
+ TestBed.configureTestingModule({
+ imports: [AudioSourcePicker, TranslocoTestingModule.forRoot({ langs: { en: {} } })],
+ });
+ const fixture = TestBed.createComponent(AudioSourcePicker);
+ fixture.componentRef.setInput('value', inputs.value ?? 'mic');
+ fixture.componentRef.setInput('meetingSupported', inputs.meetingSupported ?? true);
+ fixture.componentRef.setInput('disabled', inputs.disabled ?? false);
+ fixture.detectChanges();
+ return { fixture, el: fixture.nativeElement as HTMLElement };
+ }
+
+ function option(el: HTMLElement, source: AudioSource): HTMLButtonElement {
+ return el.querySelector(`[data-testid="audio-source-${source}"]`) as HTMLButtonElement;
+ }
+
+ it('marks the current source as pressed', () => {
+ const { el } = render({ value: 'meeting' });
+
+ expect(option(el, 'meeting').getAttribute('aria-pressed')).toBe('true');
+ 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[] = [];
+ fixture.componentInstance.value.subscribe((v) => changes.push(v));
+
+ option(el, 'meeting').click();
+ fixture.detectChanges();
+
+ expect(changes).toEqual(['meeting']);
+ expect(option(el, 'meeting').getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it('keeps the virtual option visible but inert where meeting audio is unsupported', () => {
+ const { fixture, el } = render({ meetingSupported: false });
+ let unavailable = 0;
+ fixture.componentInstance.unavailablePicked.subscribe(() => unavailable++);
+
+ const meeting = option(el, 'meeting');
+ expect(meeting.getAttribute('aria-disabled')).toBe('true');
+ expect(meeting.getAttribute('title')).toContain('discovery.source.unsupported');
+
+ meeting.click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.value()).toBe('mic');
+ expect(unavailable).toBe(1);
+ });
+
+ it('ignores clicks while locked by a live session', () => {
+ const { fixture, el } = render({ disabled: true });
+
+ option(el, 'meeting').click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.value()).toBe('mic');
+ expect(option(el, 'mic').getAttribute('aria-disabled')).toBe('true');
+ });
+});
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
new file mode 100644
index 0000000..4db5b3a
--- /dev/null
+++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts
@@ -0,0 +1,102 @@
+import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
+import { TranslocoPipe } from '@jsverse/transloco';
+import { provideIcons } from '@ng-icons/core';
+import { lucideMic, lucideMonitorSpeaker } from '@ng-icons/lucide';
+import { AudioSource } from '../../../../core/audio/audio-source';
+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;
+}
+
+/**
+ * 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.
+ */
+@Component({
+ selector: 'app-audio-source-picker',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [HlmIcon, TranslocoPipe],
+ viewProviders: [provideIcons({ lucideMic, lucideMonitorSpeaker })],
+ host: { class: 'block' },
+ template: `
+