feat(i18n): translate the whole interface

Mark every UI string the unmarked-text check reported (534) plus text it
cannot see (helpers, joined labels, runtime-built panels), and translate
the result to Spanish and Russian: 1,679 messages each.

- Kept on purpose, with the reason where the text lives: provider and
  product names, place and camera names, codes and units, dB values, the HUD
  terminal readouts, demo shot titles that saved projects look up by title,
  and text for AI tools and the voice model.
- Modules shared with the server or AI tools stay free of the i18n import
  (local msg); the wind reading and voice session stay English and are
  translated where the page shows them.
- npm test now runs the unmarked-text check.
- Fixes on the way: a duplicated radio privacy paragraph, the contact panel
  toggle saying Expand while open, the CSS filter mistaking & for CSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 19:24:42 +04:00
1 parent ee72133574
commit 3129e531e6
99 files changed
+4670 -1294

No files matched your search

+14 -11
View File
@@ -1,6 +1,9 @@
import { t } from '../i18n/index.js';
/** Build the voice control independently of its connection backend. */
// The assistant's name on its card. i18n-ignore: product name
const ASSISTANT_NAME = 'GEV';
export function createVoiceControl({ reset = false } = {}) {
let root = document.getElementById('gev-voice-control');
if (root && reset) {
@@ -17,13 +20,13 @@ export function createVoiceControl({ reset = false } = {}) {
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
<div id="gev-voice-status">${t('OFF')}</div>
<div class="gev-voice-cost">
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">STD</button>
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>
</div>
<button id="gev-voice-button" type="button" aria-label="${t('Voice control — activate to toggle voice; hold Space to speak')}" aria-describedby="gev-voice-help">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
<span class="gev-mic-label">ON/OFF</span>
<span class="gev-mic-label">${t('ON/OFF')}</span>
</button>
<div class="gev-voice-visualizer" aria-hidden="true">
${Array.from({ length: 15 }, (_, index) => `<span style="--bar:${index}"></span>`).join('')}
@@ -35,23 +38,23 @@ export function createVoiceControl({ reset = false } = {}) {
<span class="gev-voice-help-kicker">${t('VOICE CONTROL')}</span>
<span class="gev-voice-help-detail">${t('Hold Space to speak · tap Space to activate focused controls')}</span>
</div>
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="Voice assistant">
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="${t('Voice assistant')}">
<div class="gev-voice-card-header">
<span class="gev-voice-card-kicker">VOICE</span>
<span class="gev-voice-card-kicker">${t('VOICE')}</span>
<span id="gev-voice-card-phase" class="gev-voice-card-phase"></span>
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="Dismiss voice card">×</button>
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="${t('Dismiss voice card')}">×</button>
</div>
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span class="gev-voice-card-who">YOU</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">THIS</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">GEV</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="Plan" hidden></ol>
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span class="gev-voice-card-who">${t('YOU')}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">${t('THIS')}</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">${ASSISTANT_NAME}</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="${t('Plan')}" hidden></ol>
<div id="gev-voice-card-result" class="gev-voice-card-result" hidden>
<div id="gev-voice-card-result-title" class="gev-voice-card-result-title"></div>
<div id="gev-voice-card-chips" class="gev-voice-card-chips" hidden></div>
<ul id="gev-voice-card-lines" class="gev-voice-card-lines"></ul>
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" aria-label="Referenced items" hidden></ol>
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" aria-label="${t('Referenced items')}" hidden></ol>
<details id="gev-voice-card-notes" class="gev-voice-card-notes" hidden>
<summary>NOTES &amp; SOURCES</summary>
<summary>${t('NOTES & SOURCES')}</summary>
<ul id="gev-voice-card-note-list" class="gev-voice-card-lines"></ul>
</details>
</div>
+6
View File
@@ -1642,15 +1642,20 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) {
throw new Error(`Unknown CCTV action: ${args.action || 'missing'}`);
}
// Labels are place names returned to the voice model as tool data.
const RADIO_COUNTRY_CENTERS = new Map([
// i18n-ignore: place name in tool data
['us', { lat: 39.8, lon: -98.6, country: 'US', label: 'United States' }],
// i18n-ignore: place name in tool data
['usa', { lat: 39.8, lon: -98.6, country: 'US', label: 'United States' }],
[
'united states',
// i18n-ignore: place name in tool data
{ lat: 39.8, lon: -98.6, country: 'US', label: 'United States' },
],
[
'united states of america',
// i18n-ignore: place name in tool data
{ lat: 39.8, lon: -98.6, country: 'US', label: 'United States' },
],
]);
@@ -2251,6 +2256,7 @@ async function trackEntity(
action: 'track_entity',
kind: 'fire',
layerId: 'local-firms',
// i18n-ignore: entity name in a tool result for the voice model
label: strongest.label || 'Strongest fire',
latitude: strongest.latitude,
longitude: strongest.longitude,
+15 -6
View File
@@ -49,7 +49,7 @@ export class RealtimeCost {
const pendingTier = resolveVoiceModel(this.voiceTier).tier;
const isMini = pendingTier === 'mini';
if (this.ui?.tierButton) {
this.ui.tierButton.textContent = isMini ? 'MINI' : 'STD';
this.ui.tierButton.textContent = isMini ? t('MINI') : t('STD');
this.ui.tierButton.setAttribute(
'aria-pressed',
isMini ? 'true' : 'false',
@@ -65,7 +65,7 @@ export class RealtimeCost {
'Voice model: {pendingId} — click to switch to {target}; applies next session',
{
pendingId,
target: isMini ? 'standard' : 'mini',
target: isMini ? t('standard') : t('mini'),
},
);
}
@@ -74,13 +74,22 @@ export class RealtimeCost {
if (liveAuth === 'oauth') {
const { responses, input, output, captions } = this.oauthUsage;
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = 'COST UNKNOWN';
this.ui.costValue.textContent = t('COST UNKNOWN');
this.ui.costValue.dataset.level = 'unknown';
this.ui.costValue.title =
`ChatGPT OAuth session: ${responses} response(s), ${input} input and ${output} output tokens, ${captions} caption transcription(s) reported. ` +
'USD cost and the API spend cap are unavailable for this auth mode.' +
t(
'ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.',
{ responses, input, output, captions },
) +
' ' +
t(
'USD cost and the API spend cap are unavailable for this auth mode.',
) +
(state.incomplete
? ' Usage is incomplete because a response was still in flight when the session ended.'
? ' ' +
t(
'Usage is incomplete because a response was still in flight when the session ended.',
)
: '');
return;
}
+1
View File
@@ -364,6 +364,7 @@ export class RealtimeInput {
*/
updateVoiceButtonLabel() {
if (!this.ui.buttonLabel) return;
// i18n-ignore: the GEV MIC button's name; translated tips refer to it as MIC
this.ui.buttonLabel.textContent = 'MIC';
if (this.ui.helpDetail) {
this.ui.helpDetail.textContent = resolveVoiceControlHint(
+24 -6
View File
@@ -2,6 +2,17 @@ import { createVoiceControl } from './control.js';
import { createVoiceSession } from './session.js';
import { VoiceCardControls } from './voiceCard.js';
import { PointerReticle } from './pointerReticle.js';
import { t, msg } from '../i18n/index.js';
// Session states are machine values (dataset.status); only the label shown for
// them is translated. Unknown states show as they are, upper-cased.
const SESSION_STATE_LABELS = Object.freeze({
idle: msg('OFF'),
connecting: msg('CONNECTING'),
listening: msg('LISTENING'),
executing: msg('EXECUTING'),
error: msg('ERROR'),
});
function createDefaultReticle(pointer) {
const element = pointer?.element;
@@ -52,8 +63,9 @@ export function createVoiceCommands({
if (ui.tierButton) ui.tierButton.hidden = !capabilities.costControls;
if (ui.costValue) ui.costValue.hidden = !capabilities.costControls;
if (!capabilities.pushToTalk) {
ui.button.setAttribute('aria-label', 'Toggle voice control');
if (ui.helpDetail) ui.helpDetail.textContent = 'Activate to toggle voice';
ui.button.setAttribute('aria-label', t('Toggle voice control'));
if (ui.helpDetail)
ui.helpDetail.textContent = t('Activate to toggle voice');
}
// Retain the existing controller's inspection surface for browser tools.
const controls = adapter.controller || session;
@@ -61,15 +73,21 @@ export function createVoiceCommands({
const updateStatus = session.subscribe((event) => {
if (event.type !== 'state') return;
ui.root.dataset.status = event.state;
ui.status.textContent =
event.state === 'idle' ? 'OFF' : event.state.toUpperCase();
ui.status.textContent = SESSION_STATE_LABELS[event.state]
? t(SESSION_STATE_LABELS[event.state])
: event.state.toUpperCase();
// The session reports 'Voice off' in English: it is portable and has no
// locale, so that detail shows as the idle label. Other details are
// provider messages and show as they arrive.
const idle = event.state === 'idle';
const detail = event.detail === 'Voice off' ? '' : event.detail;
ui.detail.textContent =
event.detail || (event.state === 'idle' ? 'Voice off' : 'Voice active');
detail || (idle ? t('Voice off') : t('Voice active'));
ui.button.setAttribute('aria-pressed', String(session.isActive()));
if (ui.errorDetail)
ui.errorDetail.textContent =
event.state === 'error'
? event.detail || 'Voice could not be started.'
? event.detail || t('Voice could not be started.')
: '';
if (event.state === 'error') ui.root.classList?.remove('error-dismissed');
});
+202 -44
View File
@@ -16,9 +16,14 @@
*
* Builders are small pure functions keyed by tool name. Tools without one are
* returned unchanged.
*
* Only `display` and the plan/progress labels are interface text, translated
* with t(). `say`, `identityLine` and the progress lines are not shown: they go
* to the voice model to be spoken, so they stay as written.
*/
import { analystHeadline } from './resultDisplay.js';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
const LAYER_NOUNS = Object.freeze({
flights: 'aircraft',
@@ -53,13 +58,26 @@ function listPhrase(items) {
return `${list.slice(0, -1).join(', ')} and ${list.at(-1)}`;
}
function feet(meters) {
function feetRounded(meters) {
const value = Number(meters);
if (!Number.isFinite(value)) return null;
const rounded = Math.round((value * 3.28084) / 100) * 100;
return Number.isFinite(value)
? Math.round((value * 3.28084) / 100) * 100
: null;
}
function feet(meters) {
const rounded = feetRounded(meters);
if (rounded === null) return null;
return `${rounded.toLocaleString('en-US')} feet`;
}
/** The card's altitude, in the active language. */
function shownFeet(meters) {
const rounded = feetRounded(meters);
if (rounded === null) return null;
return t('{value} feet', { value: formatNumber(rounded) });
}
function kilometers(km) {
const value = Number(km);
if (!Number.isFinite(value)) return null;
@@ -76,9 +94,98 @@ function feedTag(state) {
return MATERIAL_FEED_TAGS[state] || null;
}
/** Feed states as the card shows them; the state values stay English. */
const FEED_STATE_LABELS = Object.freeze({
nominal: msg('nominal'),
loading: msg('loading'),
degraded: msg('degraded'),
stale: msg('stale'),
partial: msg('partial'),
fallback: msg('fallback'),
unavailable: msg('unavailable'),
});
function feedStateLabel(state) {
const key = FEED_STATE_LABELS[state];
return key ? t(key) : state;
}
/** A feed chip only when the state is worth seeing; nominal is the default. */
function feedChips(state) {
return state && state !== 'nominal' ? [{ label: state }] : [];
return state && state !== 'nominal' ? [{ label: feedStateLabel(state) }] : [];
}
/** '{label} on' / '{label} off' as the card shows it. */
function switchedTitle(label, on) {
return on ? t('{label} on', { label }) : t('{label} off', { label });
}
// Card titles name the layer's things in whole phrases, so each language can
// inflect the noun; the English matches LAYER_NOUNS.
function frameTitle(layerId) {
switch (layerId) {
case 'flights':
return t('Frame aircraft');
case 'military':
return t('Frame military aircraft');
case 'ais-live-vessels':
return t('Frame ships');
case 'satellites':
return t('Frame satellites');
default:
return t('Frame contacts');
}
}
function inFrameTitle(layerId, count) {
switch (layerId) {
case 'flights':
return tn(
count,
'{count} aircraft in frame',
'{count} aircraft in frame',
);
case 'military':
return tn(
count,
'{count} military aircraft in frame',
'{count} military aircraft in frame',
);
case 'ais-live-vessels':
return tn(count, '{count} ships in frame', '{count} ships in frame');
case 'satellites':
return tn(
count,
'{count} satellites in frame',
'{count} satellites in frame',
);
default:
return tn(
count,
'{count} contacts in frame',
'{count} contacts in frame',
);
}
}
function nearestTitle(layerId) {
switch (layerId) {
case 'military':
return t('Nearest military aircraft');
case 'ais-live-vessels':
return t('Nearest ships');
case 'satellites':
return t('Nearest satellites');
default:
return t('Nearest aircraft');
}
}
/** Two shown items joined, or the one item. */
function shownPair(items) {
return items.length === 2
? t('{first} and {second}', { first: items[0], second: items[1] })
: items[0] || '';
}
/** Ids such as `austin` or `local-firms` read as names on screen. */
@@ -117,7 +224,7 @@ function setLayerVisibility(result, args = {}) {
return {
say,
display: {
title: `${label} ${on ? 'on' : 'off'}`,
title: switchedTitle(label, on),
chips: on ? feedChips(result.feedState) : [],
sources: result.source ? [{ label: result.source }] : [],
},
@@ -138,7 +245,11 @@ function flyToLocation(result) {
display: {
title: label,
lines: result.rangeM
? [`Camera range ${kilometers(result.rangeM / 1000)}`]
? [
t('Camera range {range}', {
range: kilometers(result.rangeM / 1000),
}),
]
: [],
},
};
@@ -162,7 +273,7 @@ function frameOverhead(result) {
return {
say,
display: {
title: `Frame ${noun}`,
title: frameTitle(result?.layerId),
lines: [result?.error].filter(Boolean),
},
};
@@ -181,12 +292,13 @@ function frameOverhead(result) {
return {
say: sentence(`Framed ${countText} ${noun} within ${radius}`),
display: {
title: `${count} ${noun} in frame`,
title: inFrameTitle(result.layerId, count),
lines: [
`Within ${radius} of the view`,
result.detectionEnabled ? 'Labels on' : null,
t('Within {radius} of the view', { radius }),
result.detectionEnabled ? t('Labels on') : null,
].filter(Boolean),
notes: count >= 80 ? ['Framing uses the nearest 80 loaded contacts'] : [],
notes:
count >= 80 ? [t('Framing uses the nearest 80 loaded contacts')] : [],
},
referents,
};
@@ -212,7 +324,7 @@ function selectNearestAircraft(result) {
return {
say,
display: {
title: `Nearest ${noun}`,
title: nearestTitle(result?.layerId),
lines: [result?.error].filter(Boolean),
chips: feedChips(feed.state),
sources,
@@ -239,8 +351,14 @@ function selectNearestAircraft(result) {
display: {
title: name,
lines: [
distance ? `${distance} from ${location}` : null,
altitude ? `Altitude ${altitude}` : null,
distance
? t('{distance} from {location}', { distance, location })
: null,
altitude
? t('Altitude {altitude}', {
altitude: shownFeet(aircraft.altitudeM),
})
: null,
].filter(Boolean),
chips: feedChips(feed.state),
sources,
@@ -251,14 +369,30 @@ function selectNearestAircraft(result) {
};
}
function routeText(properties) {
const SPOKEN_ROUTE = Object.freeze({
between: (origin, destination) => `${origin} to ${destination}`,
from: (origin) => `from ${origin}`,
to: (destination) => `to ${destination}`,
});
const SHOWN_ROUTE = Object.freeze({
between: (origin, destination) =>
t('{origin} to {destination}', { origin, destination }),
from: (origin) => t('from {origin}', { origin }),
to: (destination) => t('to {destination}', { destination }),
});
function routeText(properties, words = SPOKEN_ROUTE) {
const route = spokenLabel(properties.route || '', 40);
if (route) return route.replace(/\s*(?:-|→|>)\s*/g, ' to ');
if (route)
return route
.split(/\s*(?:-|→|>)\s*/)
.reduce((origin, destination) => words.between(origin, destination));
const origin = spokenLabel(properties.routeOrigin || '', 12);
const destination = spokenLabel(properties.routeDestination || '', 12);
if (origin && destination) return `${origin} to ${destination}`;
if (origin) return `from ${origin}`;
if (destination) return `to ${destination}`;
if (origin && destination) return words.between(origin, destination);
if (origin) return words.from(origin);
if (destination) return words.to(destination);
return null;
}
@@ -301,9 +435,13 @@ function getEntityContext(result) {
title,
lines: [
properties.registration
? `Registration ${spokenLabel(properties.registration, 16)}`
? t('Registration {registration}', {
registration: spokenLabel(properties.registration, 16),
})
: null,
route
? t('Route {route}', { route: routeText(properties, SHOWN_ROUTE) })
: null,
route ? `Route ${route}` : null,
].filter(Boolean),
sources: selected.source ? [{ label: selected.source }] : [],
},
@@ -350,10 +488,10 @@ function getCurrentViewState(result) {
.map((part) => sentence(part[0].toUpperCase() + part.slice(1)))
.join(' '),
display: {
title: 'View state',
title: t('View state'),
lines: enabled.map(
(layer) =>
`${spokenLabel(layer.name || layer.id, 32)} · ${layer.count ?? 0} · ${layer.feedState || 'nominal'}`,
`${spokenLabel(layer.name || layer.id, 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`,
),
},
schedule: 'silent',
@@ -387,13 +525,15 @@ function annotateMap(result) {
say: parts.length ? parts.join(' ') : null,
display: {
title: result?.ok
? `Marked ${drawn.length} ${drawn.length === 1 ? 'place' : 'places'}`
: 'Nothing marked',
? tn(drawn.length, 'Marked {count} place', 'Marked {count} places')
: t('Nothing marked'),
// The numbered referents list the marks; lines stay for extra facts.
lines: [],
notes: [
...(failed.length ? [`Not found: ${failed.join(', ')}`] : []),
...(result?.outlinePending ? ['Tracing outlines'] : []),
...(failed.length
? [t('Not found: {places}', { places: failed.join(', ') })]
: []),
...(result?.outlinePending ? [t('Tracing outlines')] : []),
],
},
referents,
@@ -492,22 +632,36 @@ export function planStepLabel(name, args = {}) {
switch (name) {
case 'annotate_map': {
const targets = annotationTargets(args);
return targets.length
? `Mark ${listPhrase(targets.slice(0, 2))}${targets.length > 2 ? ` +${targets.length - 2}` : ''}`
: 'Mark the map';
if (!targets.length) return t('Mark the map');
const shown = shownPair(targets.slice(0, 2));
return targets.length > 2
? t('Mark {targets} +{more}', {
targets: shown,
more: targets.length - 2,
})
: t('Mark {targets}', { targets: shown });
}
case 'fly_to_location':
return `Fly to ${displayName(args.query || args.locationId || 'location', 40)}`;
return t('Fly to {place}', {
place: displayName(args.query || args.locationId || 'location', 40),
});
case 'select_nearest_aircraft':
return `Nearest aircraft to ${displayName(args.locationQuery || args.locationId || 'here', 32)}`;
return t('Nearest aircraft to {place}', {
place: displayName(args.locationQuery || args.locationId || 'here', 32),
});
case 'set_layer_visibility':
return `${displayName(args.layerId || 'Layer', 32)} ${args.enabled === false ? 'off' : 'on'}`;
return switchedTitle(
displayName(args.layerId || 'Layer', 32),
args.enabled !== false,
);
case 'get_entity_context':
return 'Read the view';
return t('Read the view');
case 'get_current_view_state':
return 'Check view state';
return t('Check view state');
case 'frame_overhead':
return `Frame ${spokenLabel(args.target || 'flights', 24)}`;
return t('Frame {target}', {
target: spokenLabel(args.target || 'flights', 24),
});
default:
return spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40);
}
@@ -532,18 +686,22 @@ export function narrationLabel(name, args = {}) {
/** On-screen labels for progress steps. */
const STEP_LABELS = Object.freeze({
resolve: 'Finding places',
outline: 'Tracing outline',
search: 'Looking up place',
fly: 'Flying',
layer: 'Turning on layer',
refresh: 'Loading aircraft',
nearest: 'Picking nearest',
resolve: msg('Finding places'),
outline: msg('Tracing outline'),
search: msg('Looking up place'),
fly: msg('Flying'),
layer: msg('Turning on layer'),
refresh: msg('Loading aircraft'),
nearest: msg('Picking nearest'),
});
/** On-screen label for a progress step. */
export function progressStepLabel(step, label) {
const base = STEP_LABELS[step] || spokenLabel(step || 'Working', 24);
const base = STEP_LABELS[step]
? t(STEP_LABELS[step])
: step
? spokenLabel(step, 24)
: t('Working');
return label ? `${base}: ${spokenLabel(label, 32)}` : base;
}
+1 -1
View File
@@ -93,7 +93,7 @@ test('markup: the card nests inside the voice control with every element and a p
assert.match(controlSource, new RegExp(`id="${id}"`), `${id} is missing`);
}
assert.match(controlSource, /id="gev-voice-card-live"[^>]*aria-live="polite"/);
assert.match(controlSource, /aria-label="Dismiss voice card"/);
assert.match(controlSource, /aria-label="\$\{t\('Dismiss voice card'\)\}"/);
assert.match(css, /#gev-voice-control \.gev-voice-card \{/);
assert.match(css, /body\.recording-mode #gev-voice-control/, 'recording mode hides the control, card included');
assert.match(css, /body\.ui-clean-view #command-dock/, 'Clean-UI hides the dock, card included');
+5 -2
View File
@@ -2,6 +2,7 @@ import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
import { presentResult } from './resultDisplay.js';
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
import { t } from '../i18n/index.js';
/**
* Pure presentation of the voice card nested in the voice control: captions,
* the current plan, the latest result and its numbered referents.
@@ -244,7 +245,9 @@ export function voiceCardView(state) {
const display = state.result?.display || null;
const allNotes = [
...(display?.notes || []),
...(display?.sources || []).map((source) => `Source: ${source.label}`),
...(display?.sources || []).map((source) =>
t('Source: {label}', { label: source.label }),
),
];
// Analyst notes carry count/scope/provenance qualifications. Preserve their
// full sanitized text in at most six existing rows, joining overflow into
@@ -274,7 +277,7 @@ export function voiceCardView(state) {
},
pointer: {
visible: Boolean(state.pointer),
kind: state.pointer?.kind === 'here' ? 'HERE' : 'THIS',
kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'),
text: spokenLabel(state.pointer?.label || '', 32),
},
assistant: {
+2 -2
View File
@@ -50,7 +50,7 @@ export const VOICE_MODELS = Object.freeze({
standard: Object.freeze({
tier: 'standard',
id: 'gpt-realtime-2',
label: 'STANDARD',
label: 'STANDARD', // i18n-ignore: tier name for reports and scripts; the app's tier button sets its own text
/** USD per 1M tokens — gpt-realtime-2. */
rates: Object.freeze({
textInput: 4,
@@ -66,7 +66,7 @@ export const VOICE_MODELS = Object.freeze({
mini: Object.freeze({
tier: 'mini',
id: 'gpt-realtime-2.1-mini',
label: 'MINI',
label: 'MINI', // i18n-ignore: tier name for reports and scripts; the app's tier button sets its own text
/** USD per 1M tokens — gpt-realtime-2.1-mini (~3.2× cheaper on audio). */
rates: Object.freeze({
textInput: 0.6,