Mert Özen Her Satırda Daha İleri
Yapay Zeka Angular ile AI Arayüzleri — #07

AI UX ve Erişilebilirlik: Bekleme Hissi, aria-live, Klavye, Geri Bildirim ve Şeffaflık

Mert Özen 14 Kas 2026 12 dk 1 görüntülenme
AI UX ve Erişilebilirlik: Bekleme Hissi, aria-live, Klavye, Geri Bildirim ve Şeffaflık

Streaming bir AI cevabı ekran okuyucuya parça parça değil, tur bitince tek bir LiveAnnouncer duyurusuyla verilmeli; odak input'ta kalmalı, bekleme süresi ve AI etiketi görünmeli. Bu yazıda aria-live'ın üç cümlede 70 değişiklik ürettiğini, Send/Stop değişiminin odağı body'ye düşürdüğünü ve axe-core'un bunu görmediğini ölçüyor, kontrastı düzeltiyor ve beğen/beğenme geri bildirimini backend'e bağlıyoruz.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0, Node.js 24), Angular CDK (Component Dev Kit; Angular ekibinin erişilebilirlik ve etkileşim araçları) 22.2.7 ve .NET 10 ile derlenip denenmiştir; backend'de Microsoft.Extensions.AI 11.0.0 ve OllamaSharp 5.5.2, tarayıcı denemelerinde Playwright 1.65 ile headless Chromium 158 ve @axe-core/playwright 4.14, yerel modelde Ollama 0.34.4 (qwen3:4b) kullanılmıştır. Gerçek bir ekran okuyucuyla (Windows'ta açık kaynak NVDA (NonVisual Desktop Access), Apple cihazlarında VoiceOver) deneme yapılmadı; duyurular canlı bölgelerdeki DOM (Document Object Model; sayfanın tarayıcıdaki element ağacı) değişiklikleri sayılarak ölçüldü.

Önce Tahmin Et

Bookstore asistanının (sitedeki serilerin ortak örnek uygulaması Bookstore, bir online kitapçı) ekranı çalışıyor: akan metin, kartlar, onay, hata ve iptal. Şimdi ekranı göremeyen bir kullanıcı düşün. Mesajlarının durduğu alana aria-live="polite" eklersen, üç cümlelik bir cevap boyunca ekran okuyucuya kaç değişiklik bildirilir? Eklemezsen kaç? Bir de şu: kullanıcı "Send" butonuna tıkladıktan sonra klavye odağı nerede kalır, ve bir otomatik erişilebilirlik taraması (axe-core) bu sorunu yakalar mı?

Bu yazıda asistana erişilebilirlik ve güven katmanını ekliyoruz: bekleme hissi, ekran okuyucuya doğru zamanda tek bir duyuru, odak ve klavye, hareket ve kontrast, "AI tarafından üretildi" etiketi ve beğen/beğenme geri bildirimi. Erişilebilirliğin genel kuralları Accessibility (a11y) serisinde; burada yalnız AI arayüzüne özgü olanlara bakıyoruz.

Bekleme Hissi: Durum, Gösterge ve Süre

1. yazıda kullanıcının beklediği sürenin neredeyse tamamının ilk token'dan önce geçtiğini ölçmüştük. qwen3:4b bu yazıdaki denemelerde "Dune'u bir gence üç cümleyle anlat" sorusunda ilk metni 38,6. saniyede verdi ve cevabın tamamı 1,7 saniyede aktı. 38 saniye boyunca ekranda yalnız "Thinking…" yazıyorsa kullanıcı sayfanın donduğunu düşünür. Üç şey ekledim: hareket eden bir gösterge, 3 saniyeden sonra görünen bir sayaç ve cevap bitince toplam süre.

@if (message.role === 'assistant' && message.text === '' && store.busy() && last) {
  <p class="status typing">
    <span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
    {{ store.status() === 'submitted' ? 'Sending…' : 'Thinking…' }}
    @if (elapsed() >= 3) {
      <span class="elapsed">{{ elapsed() }} s</span>
    }
  </p>
}
// Bekleme süresini saniye saniye göster; ilk saniyelerde gösterme
let timer = 0;
effect(() => {
  clearInterval(timer);
  this.elapsed.set(0);
  if (this.store.busy()) {
    timer = setInterval(() => this.elapsed.update((s) => s + 1), 1000);
  }
});
inject(DestroyRef).onDestroy(() => clearInterval(timer));

Sayacın ilk 3 saniyede görünmemesi bilinçli: kısa cevaplarda bir sayının belirip kaybolması dikkat dağıtıyor, uzun beklemede ise "hâlâ çalışıyor" bilgisini veriyor. Noktalar aria-hidden="true": ekran okuyucu için anlamları yok. 5. yazıdaki tool durum satırları ("Looking through the catalog…") da bekleme hissinin parçası; modelin ne yaptığını söyleyen bir cümle, dönen bir göstergeden daha çok güven veriyor.

Store'daki usage signal'ına da toplam süreyi ekledim. done event'i geldiğinde gönderimden o ana kadar geçen süre hesaplanıyor:

case 'done':
  ended = true;
  // Kullanıcının beklediği toplam süre: gönderimden son event'e kadar
  this.usage.update((u) => u && { ...u, durationMs: Math.round(performance.now() - startedAt) });
  this.finish(id, 'complete', 'done');
  break;

Ekranın altındaki satır artık Last answer: 608 input / 159 output tokens in 3.9 s gösteriyor. Token sayısını son kullanıcıya göstermek her ürün için doğru değil; Bookstore'da bu satır yönetim ve test ortamı için anlamlı. Kullanıcıya maliyet göstermen gerekiyorsa sağlayıcının fiyat sayfasını tarihli ve sunucuda hesaplanmış olarak göster; tarayıcıda token fiyatı tutma.

Ekran Okuyucu ve Akan Metin

Önce mevcut durumu ölçtüm. Playwright'ta bir MutationObserver, aria-live değeri polite ya da assertive olan ve role değeri log, status ya da alert olan element'lerin içindeki her DOM değişikliğini zaman damgasıyla kaydediyor. Her kayıt, ekran okuyucunun bir duyuru yapabileceği bir değişiklik.

6. yazının sonundaki kodda sonuç sıfırdı: sayfada hiç canlı bölge yok. Görme engelli kullanıcı "Send"e bastıktan sonra hiçbir şey duymuyor ve cevabın geldiğini ancak sayfayı baştan gezerek fark ediyor. İlk akla gelen düzeltme, mesaj listesine aria-live="polite" eklemek:

<!-- YAPMA: akan her parça ayrı bir duyuru adayı olur -->
<section class="log" #log (scroll)="onScroll()" aria-live="polite">
$ node run07.mjs live "Describe Dune in three sentences for a teenager."
live region updates: 70
    102 ms polite    "Describe Dune in three sentences for a teenager.Sending…"
    205 ms polite    "Describe Dune in three sentences for a teenager.Thinking…"
  38656 ms polite    "Describe Dune in three sentences for a teenager.D"
  38684 ms polite    "Describe Dune in three sentences for a teenager.Dune"
# ...
  40367 ms polite    " thrilling, thought-provoking adventures.\n Sources: [1] Dune"

Üç cümle için 70 değişiklik. 2. yazıda delta'ları requestAnimationFrame ile kare başına topluyorduk; o olmasa sayı delta sayısına çıkardı. Ekran okuyucuların bu değişiklikleri nasıl birleştirdiği ürüne göre değişiyor, ama her durumda kullanıcı ya parça parça kelimeler duyar ya da kendi yaptığı bir şeyin (bir butonu okutmak gibi) sürekli kesildiğini yaşar. assertive ile durum daha kötü: her değişiklik o an okunan şeyin sözünü keser. MDN (Mozilla Developer Network; web standartları dokümantasyonu) de assertive'i yalnız hemen dikkat isteyen kritik bildirimler için öneriyor.

Bir tuzak daha var: chat için sık önerilen role="log" örtük olarak bir canlı bölgedir; MDN'in role sayfasına göre örtük aria-live değeri polite. Yani mesaj listesine yalnız role="log" eklemek de aynı 70 duyuruyu üretir. Benim tercihim mesaj listesini canlı bölge yapmamak, tur bitince tek bir duyuru yapmak. Angular CDK'nın LiveAnnouncer'ı bunun için var:

// Akan metin canlı bölgede değil; ekran okuyucuya yalnız tur bitince tek bir duyuru gider
let previous: ChatStatus = this.store.status();
effect(() => {
  const status = this.store.status();
  if (status === previous) {
    return;
  }
  previous = status;
  const last = this.store.messages().at(-1);
  if (status === 'done' && last?.role === 'assistant') {
    const approval = last.approval?.state === 'pending' ? last.approval : null;
    const text = approval ? `Approval needed. ${describe(approval.tool, approval.arguments)}` : plainText(last.text);
    void this.announcer.announce(`Assistant: ${text}`, 'polite');
  } else if (status === 'aborted') {
    void this.announcer.announce('Stopped.', 'polite');
  }
});
// Ekran okuyucuya markdown işaretleri ve [n] atıfları okunmasın
function plainText(markdown: string): string {
  return markdown
    .replace(/\[(\d+)\]/g, '')
    .replace(/[*_`#>]/g, '')
    .replace(/\s+/g, ' ')
    .trim();
}

Duyuru ChatStore'un state machine'ine bağlı (3. yazı): streaming → done geçişinde cevabın düz metni, aborted'da "Stopped.", onay bekleyen bir turda da onay kartındaki soru okunuyor. Hata için ayrı bir duyuru yok; hata banner'ına role="alert" ekledim ve tarayıcı onu kendisi duyuruyor. İkisini birden yapmak aynı hatanın iki kez okunması demek. Aynı ölçüm:

$ node run07.mjs live
live region updates: 1
   6351 ms polite    "Assistant: Dune is longer than The Hobbit."

# LiveAnnouncer'ın sayfaya eklediği element
<div class="cdk-live-announcer-element cdk-visually-hidden" aria-atomic="true" aria-live="polite"
     id="cdk-live-announcer-0">Assistant: Dune is longer than The Hobbit.</div>

LiveAnnouncer element'i sayfada önceden duruyor ve görsel olarak gizli; MDN'in "canlı bölge içerik değişmeden önce DOM'da olmalı" kuralını bizim yerimize uyguluyor. Akış sürerken o mesajın article'ına aria-busy="true" da koydum; yardımcı teknolojiye bu bölümün hâlâ değiştiğini bildiriyor.

Bu yaklaşımın bir bedeli var: çok uzun bir cevapta kullanıcı ilk cümleyi duymak için cevabın tamamını bekliyor. Bunun orta yolu, tamamlanan blokları (4. yazıda dondurduğumuz markdown blokları) birer birer duyurmak; Küçük Bir Deneme'de bırakıyorum.

Odak ve Klavye

6. yazının sonundaki kodla odağı ölçtüm:

$ node run07.mjs focus
after click Send : button Send
after answer     : body BookstoreBooksCart:
typed "x" goes to: nowhere

Fareyle "Send"e tıklayınca odak butona geçiyor. Akış başlayınca @if (store.busy()) butonu DOM'dan kaldırıp yerine "Stop"u koyuyor ve kaldırılan element'in odağı body'ye düşüyor. Cevap bitince aynı şey "Stop" için oluyor. Klavyeyle gezen bir kullanıcı ikinci soruyu yazmak için sayfanın başından tekrar Tab'lamak zorunda; yazdığı harf hiçbir yere gitmiyor. Aynı sayfada axe-core taraması şunu dedi:

$ node run07.mjs axe
empty: 0 violations
answered: 0 violations

Otomatik tarama DOM'un o anki hâline bakıyor; odağın nereye düştüğünü, ne zaman ne duyurulduğunu görmüyor. Bu iki sorunu ancak senaryoyu çalıştıran bir test ya da elle klavye denemesi yakalıyor. Düzeltme iki satır:

// Gönderdikten sonra odak input'ta kalır; kullanıcı bir sonraki soruyu hemen yazabilir
this.input().nativeElement.focus();
// Send/Stop butonu yer değiştirince odak body'ye düşmesin
afterRenderEffect(() => {
  this.store.busy();
  if (document.activeElement === document.body) {
    this.input().nativeElement.focus();
  }
});

İkincisi yalnız odak kaybolduysa devreye giriyor; kullanıcı bilerek başka bir yere (bir kaynak link'ine, onay kartına) geçtiyse odağı geri çekmiyor. Klavye kısayolu olarak Escape akışı durduruyor; "Stop" butonunda bunu aria-keyshortcuts="Escape" ile bildiriyorum:

@Component({
  selector: 'app-assistant',
  imports: [MessageBody, GenerativeBlock, ApprovalCard],
  templateUrl: './assistant.html',
  styleUrl: './assistant.css',
  host: { '(keydown.escape)': 'store.stop()' },
})
$ node run07.mjs focus
after click Send : input Message to the Bookstore assistant
after answer     : input Message to the Bookstore assistant
typed "x" goes to: input

$ node run07.mjs escape
bubble: Stopped.
   2721 ms polite    "Stopped."
focus: Message to the Bookstore assistant

Input'un erişilebilir adı da artık placeholder'dan değil aria-label'dan geliyor; placeholder kullanıcı yazmaya başlayınca kayboluyor ve tek ad kaynağı olarak ona güvenmek yerine adı açıkça vermek daha sağlam.

Hareket ve Kontrast

Yanıp sönen noktalar, işletim sisteminde hareketi azaltmayı seçmiş kullanıcı için durmalı:

/* Hareketi azaltmak isteyen kullanıcı için noktalar sabit durur */
@media (prefers-reduced-motion: reduce) {
  .typing span[aria-hidden] {
    animation: none;
  }
}

Playwright'ta reducedMotion: 'reduce' ile açılan sayfada noktanın animation-name değeri none, varsayılanla açılanda Angular'ın scope'ladığı _ngcontent-ng-c4214144041_blink. Bu yazıdaki ikinci axe taraması ise bir sorun buldu. "AI-generated" etiketini ekledikten sonra:

answered: 1 violations
  color-contrast (serious): Elements must meet minimum color contrast ratio thresholds -> 1 node(s): .meta > span
  Element has insufficient color contrast of 4.39 (foreground color: #6b7280, background color: #f3f4f6,
  font size: 9.0pt (12px), font weight: normal). Expected contrast ratio of 4.5:1

Aynı gri, önceki yazılardan beri "Thinking…" ve "Stopped." satırlarında da kullanılıyordu; tarama o satırlar ekrandayken çalışmadığı için daha önce görünmemişti. Rengi #4b5563 yaptım ve tarama yeniden sıfıra indi. Otomatik tarama işe yarıyor ama yalnız taradığı anı görüyor; akışın farklı anlarında (bekleme, hata, onay) taramayı tekrarlamak gerekiyor.

Güven ve Şeffaflık

Her tamamlanmış cevabın altında artık kısa bir satır var:

@else if (message.ending === 'complete') {
  <div class="meta">
    <span>AI-generated, may contain mistakes.</span>
    <button type="button" class="link" [attr.aria-pressed]="message.feedback === 'up'" (click)="store.rate(message.id, 'up')">Helpful</button>
    <button type="button" class="link" [attr.aria-pressed]="message.feedback === 'down'" (click)="store.rate(message.id, 'down')">Not helpful</button>
  </div>
}

Etiket, cevabın bir model tarafından üretildiğini ve yanlış olabileceğini söylüyor; 1. yazıdaki ölçümde aynı soru iki farklı kitapla cevaplanmıştı. 4. yazıdaki kaynak listesi bu etiketin tamamlayıcısı: kullanıcı cevabı doğrulayabileceği yeri görüyor. Input'un altına da bir uyarı ekledim ve aria-describedby ile input'a bağladım:

<input #input [value]="draft()" (input)="draft.set(input.value)" placeholder="Ask the Bookstore assistant"
  aria-label="Message to the Bookstore assistant" aria-describedby="privacy-hint" />
<p id="privacy-hint" class="hint">Do not share passwords, card numbers or other personal data with the assistant.</p>

Kullanıcının yazdığı her şey sunucudaki konuşma geçmişine ve oradan modele gidiyor; bulut sağlayıcıda bu, verinin üçüncü bir tarafa çıkması demek. Uyarı bunu kullanıcıya söylemenin en basit yolu; sunucu tarafında kişisel veriyi ayıklamak ayrı bir iş.

Geri Bildirim: Beğen ve Beğenme

Butonlar aria-pressed ile durumlarını bildiriyor ve tıklama store'daki rate'i çağırıyor:

// Beğen/beğenme: cevabı sunucudaki konuşmada sırasıyla tanımlar ve backend'e gönderir
async rate(messageId: number, rating: 'up' | 'down'): Promise<void> {
  const conversationId = this.conversationId();
  const answers = this.messages().filter((m) => m.role === 'assistant' && m.ending === 'complete');
  const turn = answers.findIndex((m) => m.id === messageId);
  if (conversationId === null || turn === -1) {
    return;
  }
  this.patch(messageId, { feedback: rating });
  const ok = await this.api.feedback({ conversationId, turn, rating });
  if (!ok) {
    this.patch(messageId, { feedback: null });
  }
}
// Cevaba verilen geri bildirim; değerlendirme döngüsü için log'a yazılır
app.MapPost("/chat/feedback", IResult (FeedbackRequest feedback, ConversationStore conversations, ILogger<Program> logger) =>
{
    if (feedback.Rating is not ("up" or "down") || conversations.Find(feedback.ConversationId) is null
        || feedback.Comment?.Length > 500)
    {
        return TypedResults.Problem(title: "Invalid feedback", statusCode: 400);
    }
    logger.LogInformation("Feedback {Rating} for conversation {ConversationId}, turn {Turn}: {Comment}",
        feedback.Rating, feedback.ConversationId, feedback.Turn, feedback.Comment ?? "");
    return TypedResults.NoContent();
}).RequireRateLimiting("chat");
$ node run07.mjs feedback
meta: AI-generated, may contain mistakes. | Helpful | Not helpful
aria-pressed: [ 'Helpful=false', 'Not helpful=true' ]
usage: Last answer: 608 input / 159 output tokens in 3.9 s

# sunucu log'u
info: Program[0]
      Feedback down for conversation c2e9164c-e7a3-4c33-8b8b-1e85cd62aee0, turn 0:

Cevabı turn ile, yani sunucuda saklanan tamamlanmış cevapların sırasıyla tanımlıyorum. Bu, sunucunun yalnız düzgün biten turları geçmişe yazdığı (3. yazı) için tutarlı, ama kırılgan: geçmiş kısaltıldığında ya da bir tur yeniden üretildiğinde sıra kayabilir. Production'da sunucu done event'inde her cevap için bir ID göndermeli ve geri bildirim o ID'ye bağlanmalı. Bu log satırları tek başına bir şey söylemiyor; hangi prompt sürümüyle, hangi tool çağrılarıyla ve hangi modelle üretilen cevabın beğenilmediğini bilmek gerekiyor. Değerlendirme döngüsünü AI Agent'ları ve MCP (Model Context Protocol; modellerin dış araçlara ve verilere bağlanması için açık protokol) serisinin "Agent'ları Değerlendirmek: Trajectory, Tool Call Accuracy, Task Adherence ve Production Gözlemi" yazısı anlatıyor.

Üretimde Dikkat

Ekran okuyucuyla gerçekten dene. Bu yazıdaki ölçüm DOM'daki değişiklikleri sayıyor; NVDA, JAWS (Windows için yaygın ticari ekran okuyucu) ve VoiceOver bunları farklı birleştiriyor ve farklı zamanlarda okuyor. Hiç değilse bir Windows + NVDA ve bir mobil + VoiceOver denemesi release öncesi listende olsun.

Uzun cevabı tek seferde okutmanın sınırına dikkat et. Birkaç paragraflık bir cevap tek bir duyuruda uzun bir monolog olur; ekran okuyucu kullanıcısı yine de cevabı mesaj listesinde baştan gezebilmeli. Mesaj listesinin başlıklı, gezilebilir bir yapıda (her mesaj bir article) olması bu yüzden önemli.

Geri bildirimi bağlamıyla sakla. Cevap ID'si, prompt sürümü, model adı ve tool çağrıları olmadan bir "beğenmedim" sayısı neyin düzeltileceğini söylemez. Kullanıcının serbest metin yorumunu da kişisel veri olarak ele al.

Durum metinlerini kısa tut. "Thinking…", "Looking through the catalog…" ve süre sayacı bekleme hissini iyileştiriyor, ama hepsi aynı anda ekranda olursa kalabalık oluşturuyor. Akış başladığında bekleme satırının kaybolması bu yüzden.

Sık Düşülen Tuzaklar

Birincisi, akan metni canlı bölgeye koymak. aria-live="polite" ile üç cümle 70 değişiklik üretti; assertive ile her biri o an okunanın sözünü keser.

İkincisi, role="log"'un örtük olarak canlı bölge olduğunu unutmak. Chat için doğru görünen bu rol, aria-live yazmadan aynı sonucu verir.

Üçüncüsü, @if ile değişen butonların odağı kaybettirmesi. Send/Stop değişimi odağı body'ye düşürdü ve axe-core bunu görmedi.

Dördüncüsü, aynı olayı iki kez duyurmak. Hata banner'ı role="alert" iken bir de LiveAnnouncer ile hata okutmak, kullanıcıya aynı cümleyi iki kez dinletir.

Beşincisi, geri bildirimi cevapla eşleştirmeden toplamak. Sıra numarasıyla eşleştirme demo için yeterli, production'da sunucunun verdiği bir cevap ID'si gerekiyor.

Küçük Bir Deneme

★☆☆ "Not helpful"a basıldığında kısa bir yorum alanı aç (en fazla 500 karakter) ve yorumu comment olarak gönder. Alan açıldığında odak alana, gönderildiğinde "Thanks for the feedback." duyurusuyla input'a dönsün.

★★☆ Sunucunun done event'ine bir answerId ekle, ChatMessage'a taşı ve geri bildirimi turn yerine bu ID ile gönder. Yeniden üretilen bir cevabın geri bildirimi eski cevaba karışıyor mu, kontrol et.

★★★ Uzun cevaplar için blok blok duyuru yap: 4. yazıdaki StreamingMarkdown'ın dondurduğu her blok tamamlandığında yalnız o bloğun düz metnini LiveAnnouncer ile duyur. run07.mjs live ölçümünü tekrarla: üç paragraflık bir cevapta kaç duyuru oluyor?

Bu yazıdaki iki önemli sorunu (kaybolan odak ve eksik duyuru) bir otomatik tarama yakalamadı, bir senaryo testi yakaladı. Bir sonraki yazıda bu tür senaryoları kalıcı testlere çeviriyoruz: modeli devreden çıkaran sahte stream'ler, Vitest fake timer'larıyla 30 saniyelik timeout, MSW (Mock Service Worker; ağ isteklerini taklit eden library) ile ağ seviyesinde event-stream ve Playwright'ta page.route ile iptalin isteği gerçekten kestiğini doğrulamak.