From 431b2865cfb2db08cf798663480a788cf069fdf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 00:53:00 +0000 Subject: [PATCH 1/2] Wire recommendations.v1 into the UI render surface and exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend has been projecting Phase 2 device cards into the frozen, schema-validated, citation-gated recommendations.v1 envelope (ADR 0003) and attaching it to every producer_summary interpretation — but no UI code read it. The cards rendered from the raw arrays only, leaving the validated projection decorative. This makes the envelope load-bearing on three surfaces: - New reader service (src/services/recommendationsContract.ts): indexes the envelope so each raw device card can be paired with its validated contract entry. Pairing is by (device, parameter, normalized value) — device+parameter alone is ambiguous when Phase 2 repeats a pair with different values, and a wrong pairing would surface a confidently-wrong working range. The value parser is a deliberate TS mirror of the backend's parse_value (same cross-boundary duplication pattern as audio_mime.py / audioFile.ts). - Mix Chain + Patch cards: cards backed by a contract entry carry a "Validated" chip in the header and, expanded, a recommendations.v1 block showing the normalized value and the published working range. Cards without a match are exactly the uncited cards the projection refused — the citation gate made visible. Synthetic frontend-derived cards (limiter fallback, stereo width, MIDI Clip Guide) never match by construction. - Markdown export: a "Validated Recommendations (recommendations.v1)" table with device, parameter, normalized value, working range, and cited measurements. (The JSON export already carried the envelope verbatim inside phase2.) Verified: tsc clean, full Vitest suite 782 passed, production build green. Playwright smoke not runnable in this environment (cdn.playwright.dev blocked by network allowlist). https://claude.ai/code/session_01Q8cG71HowqrHxa7dXJ5f3a --- apps/ui/src/components/AnalysisResults.tsx | 88 ++++++++++- .../components/analysisResultsViewModel.ts | 42 +++++- .../src/services/recommendationsContract.ts | Bin 0 -> 7120 bytes apps/ui/src/utils/exportUtils.ts | 20 +++ .../services/analysisResultsViewModel.test.ts | 125 ++++++++++++++++ apps/ui/tests/services/exportUtils.test.ts | 43 ++++++ .../services/recommendationsContract.test.ts | 137 ++++++++++++++++++ 7 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 apps/ui/src/services/recommendationsContract.ts create mode 100644 apps/ui/tests/services/recommendationsContract.test.ts diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 5042eac9..c8558b96 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -54,6 +54,11 @@ import { ConfidenceBandBadge } from './sessionMusician/ConfidenceBandBadge'; import { RecommendationVerificationBadge } from './RecommendationVerificationBadge'; import { toConfidenceBand } from '../services/sessionMusician/confidenceBand'; import { loadAppliedIds, toggleAppliedId } from '../services/appliedRecommendations'; +import { + formatContractRange, + formatContractValue, +} from '../services/recommendationsContract'; +import type { RecommendationContractEntry } from '../types'; import { buildArrangementViewModel, buildMixChainGroups, @@ -238,6 +243,64 @@ function AppliedCheckbox({ ); } +/** + * "Validated" chip for cards backed by the recommendations.v1 contract + * (ADR 0003): the backend admitted this card to the schema-validated, + * citation-gated envelope. Cards without it are exactly the ones the + * projection refused (typically: no Phase 1 citation), so the badge is the + * citation gate made visible. Renders nothing when no entries match. + */ +function ContractValidatedBadge({ + entries, + testId, +}: { + entries: RecommendationContractEntry[]; + testId: string; +}) { + if (entries.length === 0) return null; + return ( + + ✓ Validated + + ); +} + +/** + * Expanded-card block listing the contract's normalized view of each backing + * entry: parsed value + unit and the published working range. The range is + * net-new information (the per-unit tolerance neighborhood the contract + * derives) — the raw value string already renders in the parameter grid. + */ +function ContractEntriesBlock({ + entries, + testId, +}: { + entries: RecommendationContractEntry[]; + testId: string; +}) { + if (entries.length === 0) return null; + return ( +
+

+ Validated · recommendations.v1 +

+ {entries.map((entry, idx) => { + const range = formatContractRange(entry); + return ( +

+ {entry.parameter}: {formatContractValue(entry)} + {range ? ` · working range ${range}` : ''} +

+ ); + })} +
+ ); +} + function Collapsible({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) { return (
+
{/* Audit Finding #3: primary citation visible in the collapsed header so the chain-of-custody @@ -2253,6 +2325,11 @@ export function AnalysisResults({ ))} + +

PRO TIP

@@ -2353,6 +2430,10 @@ export function AnalysisResults({ trackContext={patch.trackContext} category={patch.category} /> +

{/* Audit Finding #3: primary citation in the collapsed header so the chain-of-custody @@ -2423,6 +2504,11 @@ export function AnalysisResults({ ))} + +

PRO TIP

diff --git a/apps/ui/src/components/analysisResultsViewModel.ts b/apps/ui/src/components/analysisResultsViewModel.ts index 9d31753b..fca1b0f7 100644 --- a/apps/ui/src/components/analysisResultsViewModel.ts +++ b/apps/ui/src/components/analysisResultsViewModel.ts @@ -1,9 +1,20 @@ -import { AbletonRecommendation, Phase1Result, Phase2Result } from "../types"; +import { + AbletonRecommendation, + Phase1Result, + Phase2Result, + RecommendationContractEntry, + RecommendationsContract, +} from "../types"; import { type ConfidenceBand, getConfidenceBand, toConfidenceBand, } from "../services/sessionMusician/confidenceBand"; +import { + buildRecommendationsContractIndex, + findContractEntries, + type RecommendationsContractIndex, +} from "../services/recommendationsContract"; export interface ConfidenceBadgeViewModel { label: string; @@ -144,6 +155,15 @@ export interface MixChainCardViewModel { * don't render an empty block. */ phase1Fields: string[]; + /** + * recommendations.v1 (ADR 0003) entries backing this card — the + * schema-validated, citation-gated projection of the same raw device card. + * Empty when the card was not admitted to the contract (no Phase 1 + * citation, no usable value) or when the envelope is absent (older stored + * results, stem_summary). Synthetic frontend-derived cards are always + * empty: they never passed through the backend projection. + */ + contractEntries: RecommendationContractEntry[]; } export interface MixChainGroupViewModel { @@ -173,6 +193,8 @@ export interface PatchCardViewModel { * across the items that group into this single patch card. Deduplicated. */ phase1Fields: string[]; + /** See MixChainCardViewModel.contractEntries — merged across the grouped items. */ + contractEntries: RecommendationContractEntry[]; } /** @@ -925,6 +947,8 @@ function makeLimiterFallbackCard(phase1: Phase1Result, nextOrder: number) { // it consumes to derive its parameters, so the CitationBlock shows what // makes this fallback specific to *this* track. phase1Fields: ["truePeak", "lufsIntegrated", "stereoWidth"], + // Frontend-derived, never passed through the backend projection. + contractEntries: [], }; } @@ -990,9 +1014,11 @@ export function buildMixChainGroups( phase1: Phase1Result, chain: Phase2Result["mixAndMasterChain"] | undefined, sonicElements?: Phase2Result["sonicElements"], + recommendations?: RecommendationsContract | null, ): MixChainGroupViewModel[] { if (!Array.isArray(chain) || chain.length === 0) return []; + const contractIndex = buildRecommendationsContractIndex(recommendations); const total = chain.length; const cards = chain.map((item, index) => { @@ -1031,6 +1057,7 @@ export function buildMixChainGroups( // backend Phase 2 schema, enforced by server_phase2.py + phase2Validator) // so the CitationBlock above each card has structured citations. phase1Fields: Array.isArray(item.phase1Fields) ? [...item.phase1Fields] : [], + contractEntries: findContractEntries(contractIndex, item), group, highEndCues: highEnd.cues, } satisfies MixChainCardViewModel & { group: ProcessingGroup; highEndCues: string[] }; @@ -1159,6 +1186,8 @@ function buildStereoWidthPatchCard( // Audit Finding #2: synthetic stereo-width card cites the fields it // consumes when generating its parameter recommendations. phase1Fields: ['stereoWidth', 'stereoCorrelation', 'truePeak'], + // Frontend-derived, never passed through the backend projection. + contractEntries: [], }; } @@ -1173,6 +1202,7 @@ export function buildPatchCards( const melodyInsights = buildMelodyInsights(phase1); const recommendations = Array.isArray(phase2.abletonRecommendations) ? phase2.abletonRecommendations : []; const grouped = groupRecommendationsByDevice(recommendations); + const contractIndex = buildRecommendationsContractIndex(phase2.recommendations); // Audit Finding #1B (overlap): Gemini sometimes emits the same device in // both `mixAndMasterChain` (processing) and `abletonRecommendations` @@ -1241,6 +1271,13 @@ export function buildPatchCards( ), ); + // Contract entries merged across the grouped items, mirroring the + // phase1Fields merge above. Set-dedupe works on entry object identity: + // the index hands back the same envelope objects for identical cards. + const mergedContractEntries = Array.from( + new Set(group.items.flatMap((item) => findContractEntries(contractIndex, item))), + ); + return { id: `patch-${index}-${group.device}`, device: group.device, @@ -1256,6 +1293,7 @@ export function buildPatchCards( ), transcriptionDerived: midiFocused, phase1Fields: mergedPhase1Fields, + contractEntries: mergedContractEntries, }; }); @@ -1288,6 +1326,8 @@ export function buildPatchCards( melodyInsights.source === 'transcription' ? ['transcriptionDetail.averageConfidence', 'key', 'bpm'] : ['melodyDetail.pitchConfidence', 'key', 'bpm'], + // Frontend-derived, never passed through the backend projection. + contractEntries: [], }); } diff --git a/apps/ui/src/services/recommendationsContract.ts b/apps/ui/src/services/recommendationsContract.ts new file mode 100644 index 0000000000000000000000000000000000000000..675201192d689b92109407f27000d3316435bfc7 GIT binary patch literal 7120 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 RB@n*H+SI1KVdmx5e*rC7U#tKC literal 0 HcmV?d00001 diff --git a/apps/ui/src/utils/exportUtils.ts b/apps/ui/src/utils/exportUtils.ts index 2b42de7e..d8e44a09 100644 --- a/apps/ui/src/utils/exportUtils.ts +++ b/apps/ui/src/utils/exportUtils.ts @@ -1,4 +1,8 @@ import { Phase1Result, Phase2Result } from '../types'; +import { + formatContractRange, + formatContractValue, +} from '../services/recommendationsContract'; export function downloadFile(content: string, fileName: string, contentType: string) { const a = document.createElement('a'); @@ -124,6 +128,22 @@ export function generateMarkdown( phase2.abletonRecommendations.forEach((rec) => { md += `| ${rec.device} | ${rec.category} | ${rec.parameter} | ${rec.value} | ${rec.reason} |\n`; }); + md += '\n'; + } + + // recommendations.v1 (ADR 0003): the schema-validated, citation-gated + // projection the backend attaches to the interpretation. Every entry here + // cites the Phase 1 measurement(s) that justify it — the machine-checkable + // half of the report, alongside the raw cards above. + if (phase2.recommendations && phase2.recommendations.recommendations.length > 0) { + md += `### Validated Recommendations (${phase2.recommendations.version})\n`; + md += 'Schema-validated projection of the device cards above. Entries are admitted only when they cite at least one Phase 1 measurement.\n'; + md += '| Device | Parameter | Value | Working Range | Cited Measurements |\n'; + md += '| :--- | :--- | :--- | :--- | :--- |\n'; + phase2.recommendations.recommendations.forEach((entry) => { + const range = formatContractRange(entry) ?? '—'; + md += `| ${entry.device} | ${entry.parameter} | ${formatContractValue(entry)} | ${range} | ${entry.cited_measurements.join(', ')} |\n`; + }); } return md; diff --git a/apps/ui/tests/services/analysisResultsViewModel.test.ts b/apps/ui/tests/services/analysisResultsViewModel.test.ts index aa1b9b0c..104076d1 100644 --- a/apps/ui/tests/services/analysisResultsViewModel.test.ts +++ b/apps/ui/tests/services/analysisResultsViewModel.test.ts @@ -823,4 +823,129 @@ describe('analysisResultsViewModel helpers', () => { expect(masterCeilingDb(0)).toBeCloseTo(-0.3, 5); expect(masterCeilingDb(null)).toBeCloseTo(-0.3, 5); }); + + // recommendations.v1 wiring (ADR 0003): cards backed by the validated + // envelope expose their contract entries; cards the projection refused + // (uncited) and synthetic frontend-derived cards expose none. + it('buildMixChainGroups attaches contract entries to the matching card only', () => { + const groups = buildMixChainGroups( + measurement, + [ + { + order: 1, + device: 'Drum Buss', + parameter: 'Drive', + value: '6 dB', + reason: 'Adds drum bite and transient character.', + phase1Fields: ['spectralBalance.lowBass'], + }, + { + order: 2, + device: 'EQ Eight', + parameter: 'Low Cut', + value: '30 Hz', + reason: 'Cleans up sub energy in bass layers.', + // No phase1Fields — the backend projection excludes uncited cards, + // so this card has no envelope entry to pair with. + }, + ], + undefined, + { + version: 'recommendations.v1', + recommendations: [ + { + device: 'Drum Buss', + parameter: 'Drive', + value: 6, + unit: 'dB', + range: [3, 9], + cited_measurements: ['spectralBalance.lowBass'], + }, + ], + }, + ); + + const cards = groups.flatMap((group) => group.cards); + const drumBuss = cards.find((card) => card.device === 'Drum Buss'); + const eqEight = cards.find((card) => card.device === 'EQ Eight'); + const limiter = cards.find((card) => card.device === 'Limiter'); + + expect(drumBuss?.contractEntries).toHaveLength(1); + expect(drumBuss?.contractEntries[0]).toMatchObject({ value: 6, unit: 'dB', range: [3, 9] }); + expect(eqEight?.contractEntries).toEqual([]); + // The synthetic limiter fallback is frontend-derived and never entered + // the backend projection. + expect(limiter?.contractEntries).toEqual([]); + }); + + it('buildPatchCards merges contract entries across grouped items and leaves synthetic cards empty', () => { + const phase2: Phase2Result = { + trackCharacter: 'Character.', + detectedCharacteristics: [], + arrangementOverview: { summary: 'Summary', segments: [] }, + sonicElements: { + kick: 'Kick.', + bass: 'Bass.', + melodicArp: 'Arp.', + grooveAndTiming: 'Groove.', + effectsAndTexture: 'FX.', + }, + mixAndMasterChain: [], + secretSauce: { title: 'Sauce', explanation: 'Explain', implementationSteps: ['Step'] }, + confidenceNotes: [], + abletonRecommendations: [ + { + device: 'Wavetable', + category: 'SYNTHESIS', + parameter: 'Position', + value: '0.5', + reason: 'Builds the lead supersaw character.', + phase1Fields: ['key'], + }, + { + device: 'Wavetable', + category: 'SYNTHESIS', + parameter: 'Unison Voices', + value: '5', + reason: 'Thickens the supersaw stack.', + phase1Fields: ['stereoWidth'], + }, + ], + recommendations: { + version: 'recommendations.v1', + recommendations: [ + { + device: 'Wavetable', + parameter: 'Position', + value: 0.5, + unit: null, + range: null, + cited_measurements: ['key'], + }, + { + device: 'Wavetable', + parameter: 'Unison Voices', + value: 5, + unit: null, + range: null, + cited_measurements: ['stereoWidth'], + }, + ], + }, + } as Phase2Result; + + const cards = buildPatchCards(measurement, phase2); + const wavetable = cards.find((card) => card.device === 'Wavetable'); + expect(wavetable?.contractEntries).toHaveLength(2); + expect(wavetable?.contractEntries.map((entry) => entry.parameter)).toEqual([ + 'Position', + 'Unison Voices', + ]); + + // Synthetic cards (stereo width fallback, MIDI Clip Guide) are + // frontend-derived and carry no contract entries. + const synthetic = cards.filter((card) => card.device !== 'Wavetable'); + expect(synthetic.length).toBeGreaterThan(0); + expect(synthetic.every((card) => card.contractEntries.length === 0)).toBe(true); + }); }); diff --git a/apps/ui/tests/services/exportUtils.test.ts b/apps/ui/tests/services/exportUtils.test.ts index ad0cafe4..417ade91 100644 --- a/apps/ui/tests/services/exportUtils.test.ts +++ b/apps/ui/tests/services/exportUtils.test.ts @@ -139,4 +139,47 @@ describe('generateMarkdown', () => { expect(markdown).not.toContain('- **Harmonic Content**:'); expect(markdown).not.toContain('undefined'); }); + + // recommendations.v1 wiring (ADR 0003): the markdown report carries the + // schema-validated, citation-gated projection alongside the raw cards. + it('renders the validated recommendations.v1 table when the envelope is present', () => { + const markdown = generateMarkdown(basePhase1, { + ...basePhase2, + recommendations: { + version: 'recommendations.v1', + recommendations: [ + { + device: 'Drum Buss', + parameter: 'Drive', + value: 5, + unit: 'dB', + range: [2, 8], + cited_measurements: ['spectralBalance.lowBass', 'truePeak'], + }, + { + device: 'Operator', + parameter: 'Waveform', + value: 'Sine', + unit: null, + range: null, + cited_measurements: ['key'], + }, + ], + }, + }); + + expect(markdown).toContain('### Validated Recommendations (recommendations.v1)'); + expect(markdown).toContain('| Drum Buss | Drive | 5 dB | 2–8 dB | spectralBalance.lowBass, truePeak |'); + expect(markdown).toContain('| Operator | Waveform | Sine | — | key |'); + }); + + it('omits the validated recommendations section when the envelope is absent or empty', () => { + expect(generateMarkdown(basePhase1, basePhase2)).not.toContain('Validated Recommendations'); + expect( + generateMarkdown(basePhase1, { + ...basePhase2, + recommendations: { version: 'recommendations.v1', recommendations: [] }, + }), + ).not.toContain('Validated Recommendations'); + }); }); diff --git a/apps/ui/tests/services/recommendationsContract.test.ts b/apps/ui/tests/services/recommendationsContract.test.ts new file mode 100644 index 00000000..28aba6ec --- /dev/null +++ b/apps/ui/tests/services/recommendationsContract.test.ts @@ -0,0 +1,137 @@ +import { + buildRecommendationsContractIndex, + findContractEntries, + formatContractRange, + formatContractValue, + parseRecommendationValue, +} from '../../src/services/recommendationsContract'; +import { RecommendationContractEntry, RecommendationsContract } from '../../src/types'; + +function entry(partial: Partial): RecommendationContractEntry { + return { + device: 'EQ Eight', + parameter: 'Low Cut', + value: 30, + unit: 'Hz', + range: [24, 36], + cited_measurements: ['spectralBalance.subBass'], + ...partial, + }; +} + +function contract(entries: RecommendationContractEntry[]): RecommendationsContract { + return { version: 'recommendations.v1', recommendations: entries }; +} + +describe('parseRecommendationValue', () => { + // Mirror of apps/backend/recommendations_contract.py parse_value — the + // case table below matches the backend's documented examples so a drift + // in either side surfaces here. + it('parses the backend parser case table identically', () => { + expect(parseRecommendationValue('4 kHz')).toEqual({ number: 4000, unit: 'hz' }); + expect(parseRecommendationValue('-15 dB')).toEqual({ number: -15, unit: 'db' }); + expect(parseRecommendationValue('200 ms')).toEqual({ number: 200, unit: 'ms' }); + expect(parseRecommendationValue('3:1')).toEqual({ number: 3, unit: 'ratio' }); + expect(parseRecommendationValue('30%')).toEqual({ number: 30, unit: 'pct' }); + expect(parseRecommendationValue('0.6')).toEqual({ number: 0.6, unit: '' }); + expect(parseRecommendationValue('+12st')).toEqual({ number: 12, unit: 'st' }); + expect(parseRecommendationValue('2 semitones')).toEqual({ number: 2, unit: 'st' }); + expect(parseRecommendationValue('1.5 s')).toEqual({ number: 1.5, unit: 's' }); + }); + + it('returns null for non-numeric, empty, and missing values', () => { + expect(parseRecommendationValue('Sine')).toBeNull(); + expect(parseRecommendationValue('')).toBeNull(); + expect(parseRecommendationValue(' ')).toBeNull(); + expect(parseRecommendationValue(null)).toBeNull(); + expect(parseRecommendationValue(undefined)).toBeNull(); + }); + + it('passes through finite numbers as unitless', () => { + expect(parseRecommendationValue(0.5)).toEqual({ number: 0.5, unit: '' }); + expect(parseRecommendationValue(Number.NaN)).toBeNull(); + }); +}); + +describe('buildRecommendationsContractIndex + findContractEntries', () => { + it('pairs a raw card with its contract entry across the value normalization boundary', () => { + // Raw card says "4 kHz"; the contract normalized it to {4000, "Hz"}. + const index = buildRecommendationsContractIndex( + contract([entry({ device: 'Auto Filter', parameter: 'Cutoff', value: 4000, unit: 'Hz', range: [3200, 4800] })]), + ); + + const matches = findContractEntries(index, { + device: 'Auto Filter', + parameter: 'Cutoff', + value: '4 kHz', + }); + expect(matches).toHaveLength(1); + expect(matches[0].value).toBe(4000); + }); + + it('is case- and whitespace-insensitive on device and parameter', () => { + const index = buildRecommendationsContractIndex(contract([entry({})])); + + expect( + findContractEntries(index, { device: ' eq eight ', parameter: 'LOW CUT', value: '30 Hz' }), + ).toHaveLength(1); + }); + + it('disambiguates duplicate device+parameter pairs by value', () => { + // Same device+parameter twice with different values (e.g. one in + // mixAndMasterChain, one in abletonRecommendations). Pairing must be + // value-aware or the wrong working range would render — the + // confidently-wrong failure mode this surface exists to prevent. + const lowCut30 = entry({ value: 30, range: [27, 33] }); + const lowCut100 = entry({ value: 100, range: [97, 103] }); + const index = buildRecommendationsContractIndex(contract([lowCut30, lowCut100])); + + const matches = findContractEntries(index, { + device: 'EQ Eight', + parameter: 'Low Cut', + value: '100 Hz', + }); + expect(matches).toEqual([lowCut100]); + }); + + it('matches non-numeric values as strings', () => { + const sine = entry({ device: 'Operator', parameter: 'Waveform', value: 'Sine', unit: null, range: null }); + const index = buildRecommendationsContractIndex(contract([sine])); + + expect( + findContractEntries(index, { device: 'Operator', parameter: 'Waveform', value: 'sine' }), + ).toEqual([sine]); + }); + + it('returns no entries for uncited cards (absent from the envelope) and for absent envelopes', () => { + const index = buildRecommendationsContractIndex(contract([entry({})])); + expect( + findContractEntries(index, { device: 'Glue Compressor', parameter: 'Ratio', value: '2:1' }), + ).toEqual([]); + + const emptyIndex = buildRecommendationsContractIndex(undefined); + expect( + findContractEntries(emptyIndex, { device: 'EQ Eight', parameter: 'Low Cut', value: '30 Hz' }), + ).toEqual([]); + }); +}); + +describe('formatContractValue + formatContractRange', () => { + it('formats numeric entries with their display unit', () => { + expect(formatContractValue(entry({ value: 30, unit: 'Hz' }))).toBe('30 Hz'); + expect(formatContractValue(entry({ value: 0.6, unit: null }))).toBe('0.6'); + expect(formatContractValue(entry({ value: 'Sine', unit: null }))).toBe('Sine'); + }); + + it('formats ranges and returns null when the contract published none', () => { + expect(formatContractRange(entry({ value: 30, unit: 'Hz', range: [24, 36] }))).toBe('24–36 Hz'); + expect(formatContractRange(entry({ value: 3, unit: 'ratio', range: [2, 4] }))).toBe('2–4 ratio'); + expect(formatContractRange(entry({ value: 'Sine', unit: null, range: null }))).toBeNull(); + }); + + it('strips float noise from kHz-multiplied magnitudes', () => { + // 4.7 kHz parses to 4.7 * 1000 on both sides; display must not leak + // IEEE754 artifacts like "4700.000000000001". + expect(formatContractValue(entry({ value: 4.7 * 1000, unit: 'Hz' }))).toBe('4700 Hz'); + }); +}); From 60d7f43a839d2f4e462c785acbe6f88c81380c95 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Fri, 12 Jun 2026 10:19:21 +1200 Subject: [PATCH 2/2] fix(ui): keep recommendation pairing key source textual --- .../src/services/recommendationsContract.ts | Bin 7120 -> 7130 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/ui/src/services/recommendationsContract.ts b/apps/ui/src/services/recommendationsContract.ts index 675201192d689b92109407f27000d3316435bfc7..7bdfd795591b7a5fdb47a55060231cfc2f2f465c 100644 GIT binary patch delta 30 fcmca$e#?9Vp8#7-sR0m7<`+