All blocks
Block
Voice input
Hold the mic to dictate. While the audio is being transcribed, a shimmering label (spartan's shimmer utility) sits above the input until the transcript drops in.
Mic → transcribing → ready to send
State: idle
@if (state() === 'transcribing') {
<!-- spartan's shimmer utility -->
<span role="status" class="shimmer text-muted-foreground text-sm">Transcribing audio…</span>
}
<!-- spartan input-group: textarea plus a block-end addon -->
<div hlmInputGroup class="rounded-3xl">
<textarea
hlmInputGroupTextarea
[placeholder]="state() === 'recording' ? 'Listening...' : 'Speak or type'"
[value]="value()"
(input)="value.set($any($event.target).value)"
(keydown.enter)="onEnter($event)"
></textarea>
<div hlmInputGroupAddon align="block-end" class="justify-between">
<button hlmBtn size="icon-sm"
[variant]="state() === 'recording' ? 'destructive' : 'ghost'"
[hlmTooltip]="state() === 'recording' ? 'Stop' : 'Voice'"
(click)="toggleVoice()">
<ng-icon class="text-[length:--spacing(4)]"
[name]="state() === 'recording' ? 'lucideSquare' : 'lucideMic'" />
</button>
<button hlmBtn size="icon-sm" class="rounded-full" hlmTooltip="Send"
[disabled]="state() !== 'idle' || !value().trim()"
(click)="onSubmit()">
<ng-icon name="lucideArrowUp" class="text-[length:--spacing(3)]" />
</button>
</div>
</div>
// Component
type State = 'idle' | 'recording' | 'transcribing';
protected readonly state = signal<State>('idle');
protected toggleVoice(): void {
if (this.state() === 'idle') {
this.state.set('recording');
// wire MediaRecorder here
} else if (this.state() === 'recording') {
this.finishRecording(); // upload + state.set('transcribing')
}
}