Compare commits

..

2 Commits

Author SHA1 Message Date
429b52e6f5 fix(web): polls, recordings, reviews and reminders use @pi/ui's fields and buttons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:37 +02:00
3c211f0e56 test(web): the routes spec knows the public release notes page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:37 +02:00
6 changed files with 307 additions and 352 deletions

View File

@@ -25,6 +25,7 @@ describe('appRoutes', () => {
'portfolio',
'explore',
'share/:token',
'notes/:token',
'cartera',
'explorar',
'p/:key',
@@ -32,12 +33,13 @@ describe('appRoutes', () => {
expect(children[0].canActivate).toContain(authGuard);
expect(children[1].canActivate).toContain(authGuard);
expect(children[2].canActivate).toBeUndefined();
// The page for clients needs no session: its link is the key.
// The pages for clients need no session: their link is the key.
expect(children[3].canActivate).toBeUndefined();
expect(children[6].canActivate).toBeUndefined();
expect(children[4].canActivate).toBeUndefined();
expect(children[7].canActivate).toBeUndefined();
// The Spanish paths of shared links lead to the English ones.
expect(children[4].redirectTo).toBe('portfolio');
expect(children[5].redirectTo).toBe('explore');
expect(children[5].redirectTo).toBe('portfolio');
expect(children[6].redirectTo).toBe('explore');
});
it('gives each page its own state, not the root', async () => {

View File

@@ -409,7 +409,8 @@ h3 {
// A field's label floats above its box; other fields leave room for it
// below them (their hint line), a segmented control does not.
> pi-segmented-control {
> pi-segmented-control,
> p:has(+ pi-text-field, + pi-textarea, + pi-select-field, + pi-date-field) {
margin-bottom: 0.75rem;
}
}

View File

@@ -169,6 +169,11 @@ export const APP_ICONS: Record<string, string> = {
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>',
'user-profile-company':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3v16M9 21h12V11l-6-2"/></svg>',
poll: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V12M12 20V5M19 20v-9M3 20h18"/></svg>',
video:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10.5 5-3v9l-5-3"/></svg>',
mic: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6"/></svg>',
stop: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
whatsapp:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5.2A8 8 0 1 1 21 12Z"/></svg>',
};

View File

@@ -726,19 +726,6 @@ a.ts-key {
font-style: italic;
}
.ts-resolve__label {
display: grid;
gap: 0.375rem;
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-resolve__text {
font: inherit;
color: var(--ts-ink);
resize: vertical;
}
.ts-reply__actions {
display: flex;
gap: 0.75rem;
@@ -782,59 +769,31 @@ a.ts-key {
font-size: 0.875rem;
}
/* Small text buttons, not @pi/ui's sized ones. */
.ts-reminder__action {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-weight: 600;
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
.ts-reminder__actions {
display: inline-flex;
gap: 0.25rem;
margin-left: auto;
}
.ts-reminder__choices {
display: grid;
gap: 0.375rem;
gap: 0.5rem;
}
.ts-reminder__choice {
display: flex;
justify-content: space-between;
gap: 1rem;
height: auto !important;
min-width: 0 !important;
padding: 0.625rem 0.875rem !important;
border: 1px solid var(--ts-line);
border-radius: 0.5rem;
background: var(--ts-surface);
color: var(--ts-ink);
font: inherit;
text-align: left;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: var(--ts-brand);
}
}
.ts-reminder__field {
.ts-reminder__custom {
display: grid;
gap: 0.375rem;
color: var(--ts-muted);
font-size: 0.8125rem;
grid-template-columns: minmax(0, 1fr) 8rem;
gap: 0.75rem;
margin: 0;
padding: 0;
border: 0;
.ts-input {
font: inherit;
color: var(--ts-ink);
legend {
margin-bottom: 1.25rem;
padding: 0;
color: var(--ts-ink-soft);
font-size: 0.875rem;
font-weight: 600;
}
}
@@ -888,45 +847,6 @@ a.ts-key {
}
}
/* Small buttons, not @pi/ui's sized ones. */
.ts-reviews__button {
height: auto !important;
min-width: 0 !important;
padding: 0.25rem 0.75rem !important;
border: 1px solid var(--ts-line-strong) !important;
border-radius: 999px !important;
background: var(--ts-surface) !important;
color: var(--ts-ink);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
&.is-ok {
border-color: transparent !important;
background: var(--ts-c-green) !important;
color: #fff;
}
&:hover,
&:focus-visible {
filter: brightness(1.05);
}
}
.ts-reviews__link {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
/* A quick poll in a comment: each option a bar of its share. */
.ts-poll {
display: grid;
@@ -994,47 +914,44 @@ a.ts-key {
font-size: 0.75rem;
}
/* Small text buttons, not @pi/ui's sized ones. */
.ts-poll__close {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.ts-poll-editor {
display: grid;
gap: 0.375rem;
gap: 1rem;
margin: 0;
padding: 0.75rem;
border: 1px dashed var(--ts-line-strong);
border-radius: 0.5rem;
padding: 1.25rem 1rem 1rem;
border: 1px solid var(--ts-line);
border-radius: 0.75rem;
background: var(--ts-surface);
legend {
padding: 0 0.25rem;
color: var(--ts-muted);
padding: 0 0.375rem;
color: var(--ts-ink-soft);
font-size: 0.8125rem;
font-weight: 600;
}
}
.ts-poll-editor__row {
display: flex;
gap: 0.5rem;
align-items: center;
align-items: flex-end;
.ts-input {
pi-text-field {
flex: 1;
font: inherit;
}
}
.ts-poll-editor__actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
/* Poll, screen and voice: what a comment can carry besides words. */
.ts-composer-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}

View File

@@ -318,20 +318,22 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
@if (reminder.note) {
· «{{ reminder.note }}»
}
<button
type="button"
class="ts-reminder__action"
(click)="openReminder()"
>
Cambiar
</button>
<button
type="button"
class="ts-reminder__action"
(click)="facade.setReminder(null)"
>
Quitar
</button>
<span class="ts-reminder__actions">
<pi-action-button
size="x-small"
color="grey"
appearance="outline"
text="Cambiar"
(execute)="openReminder()"
/>
<pi-action-button
size="x-small"
color="grey"
appearance="ghost"
text="Quitar"
(execute)="facade.setReminder(null)"
/>
</span>
</p>
}
@@ -382,32 +384,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
}
<div class="ts-reviews__actions">
@if (review.reviewer.id === facade.me()) {
<button
type="button"
class="ts-reviews__button is-ok"
(click)="openReviewAnswer(review.id, approved)"
>
✓ Todo bien
</button>
<button
type="button"
class="ts-reviews__button"
(click)="openReviewAnswer(review.id, changes)"
>
Pedir cambios
</button>
<pi-icon-button
icon="check"
color="primary"
size="small"
text="Todo bien"
(execute)="openReviewAnswer(review.id, approved)"
/>
<pi-icon-button
icon="edit"
color="grey"
appearance="outline"
size="small"
text="Pedir cambios"
(execute)="openReviewAnswer(review.id, changes)"
/>
}
@if (
review.requester.id === facade.me() ||
project.canManage()
) {
<button
type="button"
class="ts-reviews__link"
(click)="facade.cancelReview(review.id)"
>
Retirar
</button>
<pi-action-button
size="small"
color="grey"
appearance="ghost"
[hasPadding]="false"
text="Retirar"
(execute)="facade.cancelReview(review.id)"
/>
}
</div>
} @else {
@@ -968,20 +972,24 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
· cerrada{{
poll.winner === null ? ', sin ganadora' : ''
}}
} @else if (
comment.author.id === facade.me() ||
project.canManage()
) {
·
<button
type="button"
class="ts-poll__close"
(click)="facade.closePoll(comment.id)"
>
Cerrar y decidir
</button>
}
</p>
@if (
!poll.closed &&
(comment.author.id === facade.me() ||
project.canManage())
) {
<div>
<pi-icon-button
icon="check"
color="grey"
appearance="outline"
size="small"
text="Cerrar y decidir"
(execute)="facade.closePoll(comment.id)"
/>
</div>
}
</div>
}
@if (
@@ -1072,31 +1080,27 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
"
novalidate
>
<label class="ts-resolve__label">
Conclusión (opcional): quedará como decisión, arriba del
ticket
<textarea
class="ts-input ts-resolve__text"
rows="2"
maxlength="20000"
placeholder="Vamos con…"
[value]="conclusion()"
(input)="conclusion.set($any($event.target).value)"
></textarea>
</label>
<pi-textarea
placeholder="Vamos con…"
[(value)]="conclusion"
>
<pi-label
>Conclusión (opcional): quedará como decisión, arriba
del ticket</pi-label
>
</pi-textarea>
<div class="ts-reply__actions">
<pi-action-button
color="primary"
text="Resolver"
(execute)="resolve(thread.comment)"
/>
<button
type="button"
class="ts-plain-link"
(click)="resolving.set(null)"
>
Cancelar
</button>
<pi-action-button
color="grey"
appearance="ghost"
text="Cancelar"
(execute)="resolving.set(null)"
/>
</div>
</form>
} @else if (replyingTo() === thread.comment.id) {
@@ -1211,64 +1215,71 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/>
@if (pollOn()) {
<fieldset class="ts-poll-editor">
<legend>Encuesta (2 a 6 opciones)</legend>
<legend>Encuesta · de 2 a 6 opciones</legend>
@for (option of pollOptions(); track $index) {
<div class="ts-poll-editor__row">
<input
class="ts-input"
maxlength="80"
[attr.aria-label]="'Opción ' + ($index + 1)"
[placeholder]="'Opción ' + ($index + 1)"
[value]="option"
(input)="
setPollOption($index, $any($event.target).value)
<pi-text-field
[placeholder]="
$index === 0 ? 'Vamos con Stripe' : 'Otra opción'
"
/>
[value]="option"
(valueChange)="setPollOption($index, $event)"
>
<pi-label>Opción {{ $index + 1 }}</pi-label>
</pi-text-field>
@if (pollOptions().length > 2) {
<button
type="button"
class="ts-poll__close"
<pi-icon-button
icon="trash"
color="grey"
appearance="ghost"
[attr.aria-label]="
'Quitar la opción ' + ($index + 1)
"
(click)="removePollOption($index)"
>
×
</button>
(execute)="removePollOption($index)"
/>
}
</div>
}
<div class="ts-poll-editor__actions">
@if (pollOptions().length < 6) {
<button
type="button"
class="ts-poll__close"
(click)="pollOptions.set([...pollOptions(), ''])"
>
+ Añadir opción
</button>
<pi-icon-button
icon="plus"
color="grey"
appearance="outline"
size="small"
text="Añadir opción"
(execute)="pollOptions.set([...pollOptions(), ''])"
/>
}
<button
type="button"
class="ts-poll__close"
(click)="pollOn.set(false)"
>
Quitar la encuesta
</button>
<pi-action-button
size="small"
color="grey"
appearance="ghost"
[hasPadding]="false"
text="Quitar la encuesta"
(execute)="pollOn.set(false)"
/>
</div>
</fieldset>
} @else {
<div>
<button
type="button"
class="ts-poll__close"
(click)="startPoll()"
>
📊 Añadir una encuesta
</button>
</div>
}
<ts-media-recorder (recorded)="addRecording($event)" />
<div
class="ts-composer-tools"
role="group"
aria-label="Añadir al comentario"
>
@if (!pollOn()) {
<pi-icon-button
icon="poll"
color="grey"
appearance="outline"
size="small"
text="Encuesta"
title="Una encuesta rápida: al cerrarla, la ganadora queda como decisión"
(execute)="startPoll()"
/>
}
<ts-media-recorder (recorded)="addRecording($event)" />
</div>
<pi-file-upload
label="Adjuntar al comentario"
[multiple]="true"
@@ -1449,30 +1460,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<p class="ts-muted">
Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda.
</p>
<label class="ts-reminder__field">
Quién
<select
class="ts-input"
[value]="reviewer()"
(change)="reviewer.set($any($event.target).value)"
>
<option value="">Elige a alguien…</option>
@for (member of reviewers(); track member.id) {
<option [value]="member.id">{{ member.name }}</option>
}
</select>
</label>
<label class="ts-reminder__field">
Qué mirar (opcional)
<textarea
class="ts-input ts-resolve__text"
rows="3"
maxlength="1000"
placeholder="Revisa el flujo en móvil, sobre todo el pago"
[value]="reviewNote()"
(input)="reviewNote.set($any($event.target).value)"
></textarea>
</label>
<pi-select-field
placeholder="Elige a alguien…"
[options]="reviewerOptions()"
[value]="reviewer() || null"
(valueChange)="reviewer.set($event ?? '')"
>
<pi-label [isRequired]="true">Quién</pi-label>
</pi-select-field>
<pi-textarea
placeholder="Revisa el flujo en móvil, sobre todo el pago"
[(value)]="reviewNote"
>
<pi-label>Qué mirar (opcional)</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
@@ -1489,20 +1490,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(submit)="$event.preventDefault(); sendReviewAnswer()"
novalidate
>
<label class="ts-reminder__field">
{{
<pi-textarea
[placeholder]="
answering()?.verdict === approved
? 'Probado en móvil y escritorio'
: 'El botón de pagar no responde en Safari'
"
[(value)]="reviewAnswer"
>
<pi-label [isRequired]="answering()?.verdict === changes">{{
answering()?.verdict === approved
? 'Un comentario (opcional)'
: 'Qué hay que cambiar'
}}
<textarea
class="ts-input ts-resolve__text"
rows="4"
maxlength="1000"
[value]="reviewAnswer()"
(input)="reviewAnswer.set($any($event.target).value)"
></textarea>
</label>
}}</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
@@ -1522,7 +1523,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<ng-template #reminderForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveReminder(reminderAt())"
(submit)="$event.preventDefault(); saveCustomReminder()"
novalidate
>
<p class="ts-muted">
@@ -1531,42 +1532,40 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</p>
<div class="ts-reminder__choices" role="group" aria-label="Cuándo">
@for (choice of reminderChoices(); track choice.label) {
<button
type="button"
class="ts-reminder__choice"
(click)="saveReminder(choice.at)"
>
<span>{{ choice.label }}</span>
<span class="ts-muted">{{ when(choice.at) }}</span>
</button>
<pi-action-button
size="w100"
color="grey"
appearance="outline"
[text]="choice.label + ' · ' + when(choice.at)"
(execute)="saveReminder(choice.at)"
/>
}
</div>
<label class="ts-reminder__field">
Otro momento
<input
class="ts-input"
type="datetime-local"
[min]="nowLocal()"
[value]="reminderAt()"
(input)="reminderAt.set($any($event.target).value)"
/>
</label>
<label class="ts-reminder__field">
Nota (opcional)
<input
class="ts-input"
maxlength="280"
placeholder="Llamar a QA antes de la release"
[value]="reminderNote()"
(input)="reminderNote.set($any($event.target).value)"
/>
</label>
<fieldset class="ts-reminder__custom">
<legend>Otro momento</legend>
<pi-date-field [min]="today()" [(value)]="reminderDay">
<pi-label>Día</pi-label>
</pi-date-field>
<pi-select-field
[options]="timeOptions"
[value]="reminderTime()"
(valueChange)="reminderTime.set($event ?? '09:00')"
>
<pi-label>Hora</pi-label>
</pi-select-field>
</fieldset>
<pi-text-field
placeholder="Llamar a QA antes de la release"
[(value)]="reminderNote"
>
<pi-label>Nota (opcional)</pi-label>
</pi-text-field>
<pi-action-button
size="w100"
color="primary"
text="Recordármelo"
[disabled]="!reminderAt()"
(execute)="saveReminder(reminderAt())"
[disabled]="!customReminder()"
(execute)="saveCustomReminder()"
/>
</form>
</ng-template>
@@ -1698,9 +1697,23 @@ export class IssuePage {
)
.map((m) => ({ id: m.user.id, name: m.user.name })),
);
/** The reminder being written: `datetime-local` value and note. */
protected readonly reminderAt = signal('');
protected readonly reviewerOptions = computed<PiSelectFieldOption<string>[]>(
() => this.reviewers().map((m) => ({ value: m.id, label: m.name })),
);
/** The reminder being written: day, time and note. */
protected readonly reminderDay = signal<Date | null>(null);
protected readonly reminderTime = signal('09:00');
protected readonly reminderNote = signal('');
protected readonly timeOptions = TIME_OPTIONS;
/** The day and time chosen, if both make a moment still to come. */
protected readonly customReminder = computed(() => {
const day = this.reminderDay();
if (!day) return null;
const [hours, minutes] = this.reminderTime().split(':').map(Number);
const at = new Date(day);
at.setHours(hours, minutes, 0, 0);
return at.getTime() > Date.now() ? at : null;
});
protected readonly reminderChoices = signal<ReminderChoice[]>([]);
protected readonly when = whenLabel;
private readonly editForm =
@@ -2158,12 +2171,18 @@ export class IssuePage {
protected openReminder(): void {
const current = this.facade.issue()?.myReminder;
this.reminderChoices.set(reminderChoices());
this.reminderAt.set(current ? toLocalInput(new Date(current.at)) : '');
const at = current ? new Date(current.at) : null;
this.reminderDay.set(at);
this.reminderTime.set(at ? halfHourOf(at) : '09:00');
this.reminderNote.set(current?.note ?? '');
this.drawer.open('Recordármelo', this.reminderForm());
}
/** `at`: ISO, or the `datetime-local` value (local time). */
protected saveCustomReminder(): void {
const at = this.customReminder();
if (at) this.saveReminder(at.toISOString());
}
protected saveReminder(at: string): void {
if (!at) return;
const when = new Date(at);
@@ -2172,9 +2191,11 @@ export class IssuePage {
this.drawer.closeAll();
}
/** Now, as the `min` of a `datetime-local` input. */
protected nowLocal(): string {
return toLocalInput(new Date());
/** The first day a reminder can be set on. */
protected today(): Date {
const day = new Date();
day.setHours(0, 0, 0, 0);
return day;
}
protected move(to: string): void {
@@ -2303,7 +2324,7 @@ export class IssuePage {
protected setPollOption(index: number, value: string): void {
this.pollOptions.update((options) =>
options.map((o, i) => (i === index ? value : o)),
options.map((o, i) => (i === index ? value.slice(0, 80) : o)),
);
}
@@ -2548,7 +2569,18 @@ export class IssuePage {
}
/** A date as a `datetime-local` value (local time, to the minute). */
function toLocalInput(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
const pad = (n: number) => String(n).padStart(2, '0');
/** The times a reminder can ring at: every half hour. */
const TIME_OPTIONS: PiSelectFieldOption<string>[] = Array.from(
{ length: 48 },
(_, i) => {
const time = `${pad(Math.floor(i / 2))}:${i % 2 ? '30' : '00'}`;
return { value: time, label: time };
},
);
/** A moment's time, to the half hour before it. */
function halfHourOf(date: Date): string {
return `${pad(date.getHours())}:${date.getMinutes() < 30 ? '00' : '30'}`;
}

View File

@@ -6,6 +6,7 @@ import {
output,
signal,
} from '@angular/core';
import { PiIconButtonComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
/** Long enough to show a bug, short enough to stay under the upload limit. */
@@ -42,68 +43,65 @@ function mimeFor(kind: Kind): string {
@Component({
selector: 'ts-media-recorder',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconButtonComponent],
template: `
@if (recording(); as kind) {
<span class="ts-recorder__live" role="status">
<span class="ts-recorder__dot" aria-hidden="true"></span>
Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }} ·
{{ clock(elapsed()) }} / {{ clock(max) }}
Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }}
<span class="ts-recorder__clock"
>{{ clock(elapsed()) }} / {{ clock(max) }}</span
>
</span>
<button type="button" class="ts-recorder__button" (click)="stop()">
■ Parar
</button>
<pi-icon-button
icon="stop"
color="warn"
appearance="outline"
size="small"
text="Parar"
(execute)="stop()"
/>
} @else {
@if (canScreen) {
<button
type="button"
class="ts-recorder__button"
<pi-icon-button
icon="video"
color="grey"
appearance="outline"
size="small"
text="Grabar pantalla"
title="Graba la pantalla (y tu voz, si das permiso al micrófono) y lo adjunta al comentario"
(click)="start('screen')"
>
🎥 Grabar pantalla
</button>
(execute)="start('screen')"
/>
}
@if (canVoice) {
<button
type="button"
class="ts-recorder__button"
<pi-icon-button
icon="mic"
color="grey"
appearance="outline"
size="small"
text="Grabar audio"
title="Graba un audio y lo adjunta al comentario"
(click)="start('voice')"
>
🎙 Grabar audio
</button>
(execute)="start('voice')"
/>
}
}
`,
styles: `
:host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
}
/* Small text buttons, not @pi/ui's sized ones. */
.ts-recorder__button {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
display: contents;
}
.ts-recorder__live {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
padding: 0 0.75rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red) 12%, transparent);
font-size: 0.875rem;
font-weight: 600;
}
.ts-recorder__clock {
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.ts-recorder__dot {