Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions frontend/components/bundle/BundleGlyph.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<template>
<span class="flex h-11 w-11 flex-none items-center justify-center rounded-st-md text-white shadow-st-xs" :class="tint" aria-hidden="true">
<StIcon name="solar:notebook-bold-duotone" :size="22" />
</span>
</template>

<script setup lang="ts">
/**
* The coloured tile a bundle is recognised by, in the picker and in the session summary.
*
* A bundle stores no colour, so one is derived from its id — stable for the life of the
* bundle and identical everywhere it appears, with no migration. Literal hexes rather
* than tokens: these are identity colours, and the glyph on top is always white, so they
* must not move with the theme.
*/
import { computed } from 'vue';
import { StIcon } from 'subturtle-ui';

const props = defineProps<{ seed?: string }>();

// Full class strings only — Tailwind scans this file as text.
const TINTS = [
'bg-gradient-to-br from-[#f43f5e] to-[#e11d48]',
'bg-gradient-to-br from-[#34d399] to-[#10b981]',
'bg-gradient-to-br from-[#60a5fa] to-[#3b82f6]',
'bg-gradient-to-br from-[#fbbf24] to-[#f59e0b]',
'bg-gradient-to-br from-[#475569] to-[#1e293b]',
'bg-gradient-to-br from-[#f472b6] to-[#ec4899]',
];

// djb2 rather than a char-code sum: consecutive ObjectIds share every character but the
// last few, and a plain sum maps them onto two or three adjacent buckets.
const tint = computed(() => {
const seed = props.seed || '';
let hash = 5381;
for (const ch of seed) hash = ((hash << 5) + hash + ch.charCodeAt(0)) >>> 0;
return TINTS[hash % TINTS.length];
});
</script>
364 changes: 185 additions & 179 deletions frontend/components/bundle/StartLiveSessionForm.vue

Large diffs are not rendered by default.

156 changes: 91 additions & 65 deletions frontend/components/live/VoicePicker.vue
Original file line number Diff line number Diff line change
@@ -1,74 +1,100 @@
<template>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
<button
v-for="v in normalized"
:key="v.name"
type="button"
@click="$emit('update:modelValue', v.name)"
:class="[
'flex flex-col items-center gap-1.5 rounded-xl border p-3 text-center transition-all focus:outline-none',
modelValue === v.name
? 'border-primary bg-primary/5 ring-2 ring-primary/30'
: 'border-gray-200 hover:border-primary/50 dark:border-gray-700',
]"
>
<img
v-if="v.avatarUrl"
:src="v.avatarUrl"
:alt="v.label"
class="h-12 w-12 rounded-full object-cover"
/>
<span
v-else
class="flex h-12 w-12 items-center justify-center rounded-full text-lg font-semibold text-white"
:style="{ backgroundColor: v.avatarColor }"
>{{ v.initial }}</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ v.label }}</span>
<span v-if="v.description" class="text-xs text-gray-500 dark:text-gray-400">
{{ v.description }}
</span>
</button>
</div>
<div class="grid grid-cols-2 gap-3.5 lg:grid-cols-4">
<button
v-for="v in normalized"
:key="v.name"
type="button"
:aria-pressed="modelValue === v.name"
class="st-focus-ring flex flex-col items-center gap-2 rounded-st-lg border-[1.5px] px-3 pb-3.5 pt-5 text-center transition duration-200 ease-out"
:class="modelValue === v.name ? 'border-st-primary bg-st-primary-soft' : 'border-st-line bg-st-card hover:border-st-ink-300 hover:shadow-st-xs'"
@click="$emit('update:modelValue', v.name)"
>
<!-- Waveform mark. Inline rather than an icon: the design's bars are a fixed
five-step shape with no Solar equivalent, and it animates while playing. -->
<span class="flex h-8 items-end justify-center gap-[3px]" :class="modelValue === v.name ? 'text-st-primary' : 'text-st-ink-300'" aria-hidden="true">
<span
v-for="(h, i) in BARS"
:key="i"
class="w-[3.5px] rounded-st-pill bg-current"
:class="playing === v.name ? 'st-wave-bar' : ''"
:style="{ height: `${h}px`, animationDelay: `${i * 90}ms` }"
/>
</span>

<span class="text-st-base font-extrabold leading-tight text-st-strong">{{ v.label }}</span>
<span v-if="v.description" class="text-st-sm font-semibold leading-[1.35] text-st-muted">{{ v.description }}</span>

<!-- Sample playback. `.stop` so hearing a voice does not also select it. -->
<span
class="mt-1 inline-flex items-center gap-1.5 rounded-st-pill px-3 py-1.5 text-st-sm font-bold transition duration-150 ease-out"
:class="playing === v.name ? 'bg-st-primary text-white' : 'bg-st-ink-100 text-st-muted hover:bg-st-ink-150'"
role="button"
tabindex="0"
:aria-label="playing === v.name ? t('live-practice.voice.stop', { name: v.label }) : t('live-practice.voice.preview-aria', { name: v.label })"
@click.stop="preview(v.name)"
@keydown.enter.stop.prevent="preview(v.name)"
@keydown.space.stop.prevent="preview(v.name)"
>
<StIcon :name="loading === v.name ? 'solar:refresh-bold' : 'solar:play-bold'" :size="13" :class="loading === v.name ? 'animate-spin' : ''" />
{{ playing === v.name ? t('live-practice.voice.playing') : t('live-practice.voice.preview') }}
</span>
</button>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { CoachVoice } from '~/types/live-session.type';
import { computed } from 'vue';
import { StIcon } from 'subturtle-ui';
import type { CoachVoice } from '~/types/live-session.type';
import { useVoicePreview } from '~/composables/useVoicePreview';

// Accepts either rich server voices (CoachVoice[]) or a plain name list
// (string[]) so the same picker serves the server-backed Gemini bundle flow and
// the OpenAI/Gemini `StartNew` variants that still pass their own name arrays.
const props = defineProps<{
modelValue: string;
voices: (string | CoachVoice)[];
}>();
const { t } = useI18n();

defineEmits<{ 'update:modelValue': [value: string] }>();
// Accepts either rich server voices (CoachVoice[]) or a plain name list
// (string[]) so the same picker serves the server-backed Gemini bundle flow and
// the OpenAI/Gemini `StartNew` variants that still pass their own name arrays.
const props = defineProps<{
modelValue: string;
voices: (string | CoachVoice)[];
}>();

// Deterministic palette so name-only lists still get a stable colored avatar.
const FALLBACK_COLORS = [
'#7C3AED', '#2563EB', '#0D9488', '#EA580C',
'#DB2777', '#D97706', '#4F46E5', '#059669',
];
defineEmits<{ 'update:modelValue': [value: string] }>();

const normalized = computed(() =>
(props.voices || []).map((v) => {
const voice: Partial<CoachVoice> & { name: string } =
typeof v === 'string' ? { name: v, label: v } : v;
const label = voice.label || voice.name;
let color = voice.avatarColor;
if (!color) {
const sum = [...voice.name].reduce((a, c) => a + c.charCodeAt(0), 0);
color = FALLBACK_COLORS[sum % FALLBACK_COLORS.length];
}
return {
name: voice.name,
label,
description: voice.description,
avatarColor: color,
avatarUrl: voice.avatarUrl || null,
initial: label.charAt(0).toUpperCase(),
};
})
);
const { preview, playing, loading } = useVoicePreview();

/** The design's waveform silhouette, in px. */
const BARS = [11, 19, 28, 16, 9];

const normalized = computed(() =>
(props.voices || []).map((v) => {
const voice: Partial<CoachVoice> & { name: string } = typeof v === 'string' ? { name: v, label: v } : v;
return {
name: voice.name,
label: voice.label || voice.name,
description: voice.description,
};
})
);
</script>

<style scoped>
/* Only while a sample plays; `prefers-reduced-motion` holds the bars still. */
.st-wave-bar {
animation: st-wave 900ms ease-in-out infinite;
transform-origin: bottom;
}
@keyframes st-wave {
0%,
100% {
transform: scaleY(0.5);
}
50% {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.st-wave-bar {
animation: none;
}
}
</style>
Loading