Login screens were specified in login.md, which is NOT in the global knowledge mds
login.md was not imported — it was a team doc covering LDAP bind, JWT sessions and a three-layer whitelist that do not apply here.
EN
## Login screens were specified in `login.md`, which is NOT in the global knowledge mds
**`login.md` was not imported** — it was a team doc covering LDAP bind, JWT sessions and a
three-layer whitelist that do not apply here. What survives from it, and what still holds when you
build a sign-in page: logo centered **above** the card, "Sign in with your Windows credentials"
style wording matched to the actual auth method, a **Forgot password?** link to the provider's own
change-password URL, a plain-text username input (never `type="email"`), a generic `first.last`
placeholder, nothing below the card, and never flash the form to someone already signed in.
The original rule, kept for its reasoning: The login page is the one screen whose layout, wording
and behaviour are fixed by the authentication doc: logo centered above the card, *"Sign in with your
Windows credentials"*, **Forgot password?** beside the Password label pointing at Microsoft's own
change-password page, a plain-text username field (never `type="email"`, which rejects a bare
username), a generic `first.last` placeholder rather than a real person's name, nothing under the
card, the session resolved server-side so an already-signed-in user never sees the form flash, and a
real navigation after success so the browser offers to save the credentials.
This pointer exists because the natural instinct is to treat "how should the login page look?" as a
design question and come here — and the two rules this file does carry about that screen (the
[logo asset](#afiniti-logos--two-distinct-assets-never-interchange-them) and the
[auth-flash rule](#auth-dependent-screens--resolve-the-session-on-the-server-never-flash-a-wrong-state))
are findable only if you already know to look for them.
TR
## Login ekranları `login.md` dosyasında tanımlıydı, o dosya global knowledge md'lerde YOK
**`login.md` içe aktarılmadı** — LDAP bind, JWT session'ları ve burada geçerli olmayan üç
katmanlı bir beyaz listeyi kapsayan bir ekip dokümanıydı. Ondan ayakta kalan ve bir giriş sayfası
kurarken hâlâ geçerli olan kısım: logo kartın **üstünde** ortalanmış, gerçek kimlik doğrulama
yöntemine uydurulmuş "Sign in with your Windows credentials" tarzı bir ifade, sağlayıcının kendi
şifre değiştirme adresine giden bir **Forgot password?** linki, düz metin kullanıcı adı input'u
(asla `type="email"`), genel bir `first.last` placeholder'ı, kartın altında hiçbir şey, ve zaten
giriş yapmış birine formu bir an bile gösterme.
Orijinal kural, gerekçesi için korundu: Giriş sayfası, yerleşimi, ifadesi ve davranışı kimlik
doğrulama dokümanıyla sabitlenmiş tek ekrandır: logo kartın üstünde ortalanmış, *"Sign in with your
Windows credentials"*, Password etiketinin yanında Microsoft'un kendi şifre değiştirme sayfasına
işaret eden **Forgot password?**, düz metin kullanıcı adı alanı (asla `type="email"`, çünkü yalın
bir kullanıcı adını reddeder), gerçek bir kişinin adı yerine genel bir `first.last` placeholder'ı,
kartın altında hiçbir şey, session'ın sunucu tarafında çözülmesi ki zaten giriş yapmış bir kullanıcı
formu bir an bile görmesin, ve başarıdan sonra gerçek bir yönlendirme ki tarayıcı kimlik bilgilerini
kaydetmeyi önersin.
Bu işaretçi var çünkü doğal içgüdü "giriş sayfası nasıl görünmeli?" sorusunu bir tasarım sorusu
sayıp buraya gelmek — ve bu dosyanın o ekran hakkında taşıdığı iki kural
([logo görseli](#afiniti-logos--two-distinct-assets-never-interchange-them) ve
[auth-flash kuralı](#auth-dependent-screens--resolve-the-session-on-the-server-never-flash-a-wrong-state))
ancak aramayı zaten biliyorsan bulunabilir.
Style References
calendly.md — Calendly.com style reference (light, quiet professional calendar theme).
EN
## Style References
- [`calendly.md`](./design_themes/calendly/calendly.md) — Calendly.com style reference (light, quiet professional calendar theme).
- [`fancy-linkedin.md`](./design_themes/fancy_linkedin/fancy-linkedin.md) — "Fancy LinkedIn" creator-dashboard design system (floating windows, blue→purple accents).
- `notifications.md` — tool-notification design across channels (mail / MS Teams / Webex); first note: **dual-Outlook (new + classic) HTML-email compatibility** (table layout, inline CSS, `bgcolor`+style, VML buttons, MSO DPI fix). Read before building any email/notification.
- [`design_themes/spark_theme/spark_theme.md`](./design_themes/spark_theme/spark_theme.md) — "Afiniti Spark" energetic dashboard theme (blue→cyan brand gradients, gold reward accents, color-matched glow buttons, glassmorphism topbar, gamification widgets). Ships a ready-to-link stylesheet [`spark-theme.css`](./design_themes/spark_theme/spark-theme.css). Use for internal tools, portals, and gamified/community dashboards.
- `formula_tooltip.md` — Formula tooltip pattern: Lucide Info icon, two-panel popover ("How is this calculated?" + Technical Definition), React/Radix implementation, copy-paste template. Use whenever a column or metric needs a formula explanation.
- `formula_tooltip.md` — Formula tooltip pattern: when a column/metric needs a "How is this calculated?" tooltip, use this pattern.
- [`design-filters.md`](./design-filters.md) — Filter & sort rules, and it **owns every filter rule in the estate** — table columns *and* view-wide (bar) filter controls: every column filterable + sortable, click-the-name to open a **popover** that starts with everything selected and **applies only on Apply** (outside-click / `Esc` discard), a **range** instead of a value list on numeric columns, a **field chooser** on stacked cells, option lists **shipped with the page and cascaded** by every other active filter (plus the coarse-⊃-fine hierarchy gotcha), the sort icon's 3-click cycle back to the table's default sort, active-filter indication, bar filters that **open on hover**, the "All …" first option and its scope-defining exception, and the ban on bar time filters at or below the charts' drawn granularity. Read it before building any filter or sort — `design-dashboard.md` delegates here rather than restating it.
- [`design-form-validation.md`](./design-form-validation.md) — Mandatory-field validation: on submit, highlight invalid required fields **on the fields themselves** (red border/ring), never via a message elsewhere; keep the submit button clickable and block the submit until fixed. Read before building any form/modal with required fields.
TR
## Stil Referansları
- [`calendly.md`](./design_themes/calendly/calendly.md) — Calendly.com stil referansı (açık, sakin, profesyonel takvim teması).
- [`fancy-linkedin.md`](./design_themes/fancy_linkedin/fancy-linkedin.md) — "Fancy LinkedIn" içerik üretici dashboard tasarım sistemi (yüzen pencereler, mavi→mor vurgular).
- `notifications.md` — kanallar arası araç bildirimi tasarımı (mail / MS Teams / Webex); ilk not: **çift Outlook (yeni + klasik) HTML e-posta uyumluluğu** (tablo yerleşimi, satır içi CSS, `bgcolor`+style, VML butonlar, MSO DPI düzeltmesi). Herhangi bir e-posta/bildirim kurmadan önce oku.
- [`design_themes/spark_theme/spark_theme.md`](./design_themes/spark_theme/spark_theme.md) — "Afiniti Spark" enerjik dashboard teması (mavi→camgöbeği marka gradyanları, altın ödül vurguları, renk uyumlu parlama butonları, glassmorphism üst bar, oyunlaştırma widget'ları). Doğrudan bağlanabilir bir stil sayfası içerir: [`spark-theme.css`](./design_themes/spark_theme/spark-theme.css). Şirket içi araçlar, portallar ve oyunlaştırılmış/topluluk dashboard'ları için kullan.
- `formula_tooltip.md` — Formül tooltip kalıbı: Lucide Info simgesi, iki panelli popover ("How is this calculated?" + Technical Definition), React/Radix uygulaması, kopyala-yapıştır şablonu. Bir kolonun ya da metriğin formül açıklamasına ihtiyacı olduğunda kullan.
- `formula_tooltip.md` — Formül tooltip kalıbı: bir kolon/metrik "How is this calculated?" tooltip'i gerektirdiğinde bu kalıbı kullan.
- [`design-filters.md`](./design-filters.md) — Filtre ve sıralama kuralları; ayrıca **alandaki her filtre kuralının sahibi odur** — tablo kolonları *ve* görünüm geneli (bar) filtre kontrolleri: her kolon filtrelenebilir + sıralanabilir, adına tıklayınca her şey seçili başlayan ve **yalnızca Apply ile uygulanan** bir **popover** açılır (dışarı tıklama / `Esc` iptal eder), sayısal kolonlarda değer listesi yerine bir **aralık**, üst üste dizilmiş hücrelerde bir **alan seçici**, sayfayla birlikte **gönderilen ve aktif diğer her filtreye göre zincirlenen** seçenek listeleri (artı kaba-⊃-ince hiyerarşi tuzağı), sıralama simgesinin tablonun varsayılan sıralamasına dönen 3 tıklamalık döngüsü, aktif filtre göstergesi, **hover ile açılan** bar filtreleri, "All …" ilk seçeneği ve kapsam tanımlayan istisnası, ve grafiklerin çizilen kırılımına eşit ya da ondan ince bar zaman filtrelerinin yasağı. Herhangi bir filtre ya da sıralama kurmadan önce oku — `design-dashboard.md` bunu tekrarlamak yerine buraya devreder.
- [`design-form-validation.md`](./design-form-validation.md) — Zorunlu alan doğrulaması: gönderimde geçersiz zorunlu alanları **alanların kendi üstünde** işaretle (kırmızı kenarlık/halka), asla başka bir yerdeki mesajla değil; gönder butonunu tıklanabilir tut ve düzeltilene kadar gönderimi engelle. Zorunlu alanları olan herhangi bir form/modal kurmadan önce oku.
Formula Tooltips
When a metric column, KPI card, or dashboard value has a non-obvious formula, add a formula tooltip.
EN
## Formula Tooltips
When a metric column, KPI card, or dashboard value has a non-obvious formula, add a formula tooltip. See `formula_tooltip.md` for the full UX pattern, panel structure, content rules, and implementation code.
TR
## Formül Tooltip'leri
Bir metrik kolonunun, KPI kartının ya da dashboard değerinin formülü aşikâr değilse, bir formül tooltip'i ekle. Tam UX kalıbı, panel yapısı, içerik kuralları ve uygulama kodu için `formula_tooltip.md` dosyasına bak.
Numbers are digits, never words
Every number in an interface is written as a digit: headings, labels, buttons, badges, table cells, body copy.
EN
## Numbers are digits, never words
Every number in an interface is written as a digit: headings, labels, buttons, badges, table cells,
body copy. `3 tools, 9 stages`, not `three tools, nine stages`. There is no spell-out-under-ten
threshold. A digit is scannable, it aligns in a column, it survives translation without agreeing
with anything, and the reader does not have to parse a sentence to find a quantity.
The rule is about **counts and quantities**. `one` used to mean "single" or "a" is a word, not a
number — `one API surface`, `one source of truth`, `one transaction` stay as they are, and so do
ordinals like `first` and `second`. Applies to text shown on screen, not to chat, commit messages or
these documents.
TR
## Sayılar rakamla yazılır, asla kelimeyle
Arayüzdeki her sayı rakamla yazılır: başlıklar, etiketler, butonlar, badge'ler, tablo hücreleri,
gövde metni. `3 tools, 9 stages`, `three tools, nine stages` değil. Ona kadar yazıyla yazma diye
bir eşik yok. Rakam gözle taranabilir, kolonda hizalanır, hiçbir şeyle uyuşmak zorunda kalmadan
çeviriden sağ çıkar ve okuyan bir miktarı bulmak için cümle çözmek zorunda kalmaz.
Kural **adet ve miktarlar** hakkındadır. "tek" ya da "bir" anlamında kullanılan `one` bir sayı değil
kelimedir — `one API surface`, `one source of truth`, `one transaction` olduğu gibi kalır, `first`
ve `second` gibi sıra sayıları da öyle. Ekranda gösterilen metin için geçerlidir; sohbet, commit
mesajları ve bu dokümanlar için değil.
No Browser Autofill on Custom Inputs
Inputs that are NOT part of a normal login/profile form — tag/token entry boxes (email chips, search-tag inputs), inline…
EN
## No Browser Autofill on Custom Inputs
Inputs that are NOT part of a normal login/profile form — tag/token entry boxes (email chips, search-tag inputs), inline editors, filter fields — must suppress the browser's autofill/autocomplete dropdown and password-manager icons. They pollute a custom UI with unrelated saved emails/addresses. On every such `<input>` set: `autoComplete="off"`, `autoCorrect="off"`, `autoCapitalize="off"`, `spellCheck={false}`, plus a non-standard `name` (browsers ignore `autoComplete="off"` on fields named `email`/`name`/`address` — use something like `name="xyz-entry"`), and the password-manager opt-outs `data-1p-ignore`, `data-lpignore="true"`, `data-form-type="other"`. Genuine login/credential fields keep their proper `autoComplete` values (`username`, `current-password`) — this rule is only for custom non-credential inputs.
TR
## Özel Input'larda Tarayıcı Otomatik Doldurması Yok
Normal bir login/profil formunun parçası OLMAYAN input'lar — tag/token giriş kutuları (e-posta chip'leri, arama etiketi input'ları), satır içi editörler, filtre alanları — tarayıcının otomatik doldurma açılır listesini ve şifre yöneticisi simgelerini bastırmalı. Bunlar özel bir arayüzü alakasız kayıtlı e-posta ve adreslerle kirletir. Böyle her `<input>` üzerinde şunları ayarla: `autoComplete="off"`, `autoCorrect="off"`, `autoCapitalize="off"`, `spellCheck={false}`, ayrıca standart dışı bir `name` (tarayıcılar `email`/`name`/`address` adlı alanlarda `autoComplete="off"` değerini yok sayar — `name="xyz-entry"` gibi bir şey kullan) ve şifre yöneticisi devre dışı bırakmaları `data-1p-ignore`, `data-lpignore="true"`, `data-form-type="other"`. Gerçek login/kimlik alanları kendi doğru `autoComplete` değerlerini korur (`username`, `current-password`) — bu kural sadece kimlik taşımayan özel input'lar içindir.
Chip / Token Inputs (email & multi-value entry)
Any field that collects more than one value — recipient lists (To / Cc), tags, keywords — must be a chip/token input, not a…
EN
## Chip / Token Inputs (email & multi-value entry)
Any field that collects **more than one value** — recipient lists (To / Cc), tags, keywords — must be a
**chip/token input**, not a bare comma-separated text box. The chip is the confirmation: the user sees
each accepted value as a discrete box, so they never wonder whether their entry "took".
- **Validity is signalled by becoming a chip.** On commit (Enter, comma, or blur) a **valid** value
turns into a removable chip; an **invalid** one stays as raw text in the box and the box border goes
red — no chip, nothing lost. The user fixes it in place. Do not silently drop invalid input, and do
not add an invalid value as a chip. For emails, validate against a real pattern
(`/^[^\s@]+@[^\s@]+\.[^\s@]+$/`), not just "contains an @".
- **Chips are removable** — an `×` on each chip, and **Backspace on an empty input removes the last
chip**. De-dupe on add (case-insensitive for emails).
- **Locked chips for non-negotiable values.** Values the user may not remove (e.g. system admins who are
always CC'd) render as **locked chips** — shown first, visually distinct (muted, a lock glyph), no `×`.
The server stays authoritative: it re-applies the locked floor and merges the user's additions
(dedup), never trusting the client to have kept them.
- **Persistence stores the list**, not a joined string — e.g. `localStorage.setItem(key, JSON.stringify(list))`.
When reading back, tolerate an older single-string format (split on `,`/`;`) so remembered values
survive the upgrade.
- **Suppress browser autofill** on the entry `<input>` per *No Browser Autofill on Custom Inputs* above
(a chip box is exactly the "tag/token entry" case that rule targets) — including a unique non-standard
`name` per instance so two boxes in one modal (To + Cc) don't collide.
- **Required-field feedback follows `design-form-validation.md`.** On a submit attempt,
a required chip field with **zero chips** gets the red ring on its container (not a message
elsewhere); the submit stays clickable and is blocked until fixed.
Reference implementation: Reward Comp Tool → Data Quality → "Domain Review" report modal
(`ai_apps/Reward_Comp_Tool/app/data-quality/page.tsx`, `EmailTagInput` with `locked` / `invalid`
props) + its API (`app/api/data-quality/report-domain/route.ts`, which merges client Cc onto the
`SUPER_ADMINS` floor).
TR
## Chip / Token Input'ları (e-posta ve çok değerli giriş)
**Birden fazla değer** toplayan her alan — alıcı listeleri (To / Cc), etiketler, anahtar kelimeler —
virgülle ayrılmış yalın bir metin kutusu değil, **chip/token input'u** olmalı. Chip, onayın kendisidir:
kullanıcı kabul edilen her değeri ayrı bir kutu olarak görür, böylece girdisinin "tutup tutmadığını"
hiç merak etmez.
- **Geçerlilik, chip'e dönüşerek bildirilir.** Kesinleştiğinde (Enter, virgül veya odak kaybı) **geçerli**
bir değer kaldırılabilir bir chip'e dönüşür; **geçersiz** olan kutuda ham metin olarak kalır ve kutunun
kenarlığı kırmızıya döner — chip yok, kaybolan da yok. Kullanıcı onu yerinde düzeltir. Geçersiz girdiyi
sessizce atma ve geçersiz bir değeri chip olarak ekleme. E-postalarda gerçek bir kalıpla doğrula
(`/^[^\s@]+@[^\s@]+\.[^\s@]+$/`), sadece "@ içeriyor mu" ile değil.
- **Chip'ler kaldırılabilirdir** — her chip'te bir `×`, ve **boş input'ta Backspace son chip'i kaldırır**.
Eklerken tekrarı ayıkla (e-postalarda büyük/küçük harf duyarsız).
- **Tartışmaya kapalı değerler için kilitli chip'ler.** Kullanıcının kaldıramayacağı değerler (ör. her zaman
CC'lenen sistem yöneticileri) **kilitli chip** olarak çizilir — önce gösterilir, görsel olarak ayrışır
(soluk, kilit simgesi), `×` yok. Sunucu son sözü söylemeye devam eder: kilitli tabanı yeniden uygular ve
kullanıcının eklediklerini birleştirir (tekrarı ayıklayarak), istemcinin onları koruduğuna asla güvenmez.
- **Kalıcılık listeyi saklar**, birleştirilmiş bir metni değil — ör. `localStorage.setItem(key, JSON.stringify(list))`.
Geri okurken eski tek metin biçimini de tolere et (`,`/`;` ile böl) ki hatırlanan değerler yükseltmeden
sağ çıksın.
- Giriş `<input>`'unda yukarıdaki *Özel Input'larda Tarayıcı Otomatik Doldurması Yok* kuralına göre
**otomatik doldurmayı bastır** (chip kutusu tam olarak o kuralın hedeflediği "tag/token girişi" durumudur)
— her örnek için benzersiz, standart dışı bir `name` dahil, ki tek bir modal'daki iki kutu (To + Cc)
çakışmasın.
- **Zorunlu alan geri bildirimi `design-form-validation.md`'yi izler.** Gönderme denemesinde, **sıfır chip'i**
olan zorunlu bir chip alanı kırmızı halkayı container'ında alır (başka bir yerdeki mesajla değil);
gönder butonu tıklanabilir kalır ve düzeltilene kadar engellenir.
Referans uygulama: Reward Comp Tool → Data Quality → "Domain Review" rapor modal'ı
(`ai_apps/Reward_Comp_Tool/app/data-quality/page.tsx`, `locked` / `invalid` prop'lu `EmailTagInput`)
ve API'si (`app/api/data-quality/report-domain/route.ts`, istemcinin Cc'sini `SUPER_ADMINS` tabanına
birleştirir).
Related elements must react to a click — filter DOWN, highlight UP
If two things on a screen describe the same data at different grains, clicking one must do something to the other.
EN
## Related elements must react to a click — filter DOWN, highlight UP
If two things on a screen describe the same data at different grains, clicking one **must** do
something to the other. A chart, its ranked table and the detail list beneath it are one instrument,
not three decorations; a click that changes nothing reads as a broken control.
**Which action depends on the direction of the relationship:**
| Click direction | Action | Why |
|---|---|---|
| **Big group → small group** (aggregate → detail) | **FILTER** | "Show me only these" — the detail list is long, narrowing it is the point. Clicking a year, a department or a bar filters the rows underneath to that slice. |
| **Small group → big group** (detail → aggregate) | **HIGHLIGHT** | "Where does this sit?" — filtering an aggregate down to one row destroys the comparison the aggregate exists to give. Clicking a person, a row or a single move highlights its bar/segment instead, leaving the rest visible for context. |
Rules that follow:
- **Never filter upwards.** Reducing a distribution chart to the single bar the clicked detail
belongs to throws away the only thing that chart was for.
- **A filter is visibly active and reversible** — the filtered element shows a selected state, the
target shows what it is filtered by, and clicking the same element again clears it. Never make the
user reload to get back.
- **A highlight is transient and never hides data.** Emphasise the related element (colour, outline,
de-emphasised siblings); do not remove rows.
- **Both clear on outside click**, per "Click-Outside Clears Selection" below.
- **State the pairing where it isn't obvious** — a one-line hint ("click a year to filter the moves
below") is cheaper than a user never discovering the control.
TR
## İlişkili öğeler tıklamaya tepki vermeli — AŞAĞI filtrele, YUKARI vurgula
Bir ekranda iki şey aynı veriyi farklı kırılımlarda anlatıyorsa, birine tıklamak diğerine **mutlaka**
bir şey yapmalı. Bir grafik, onun sıralı tablosu ve altındaki detay listesi üç süs değil, tek bir
enstrümandır; hiçbir şeyi değiştirmeyen bir tıklama bozuk bir kontrol gibi okunur.
**Hangi eylemin uygulanacağı ilişkinin yönüne bağlıdır:**
| Tıklama yönü | Eylem | Neden |
|---|---|---|
| **Büyük grup → küçük grup** (toplam → detay) | **FİLTRELE** | "Sadece bunları göster" — detay listesi uzundur, onu daraltmak zaten amaçtır. Bir yıla, bir departmana ya da bir çubuğa tıklamak alttaki satırları o dilime filtreler. |
| **Küçük grup → büyük grup** (detay → toplam) | **VURGULA** | "Bu nerede duruyor?" — bir toplamı tek satıra filtrelemek, o toplamın var olma sebebi olan karşılaştırmayı yok eder. Bir kişiye, bir satıra ya da tek bir harekete tıklamak onun çubuğunu/dilimini vurgular ve gerisini bağlam için görünür bırakır. |
Bundan çıkan kurallar:
- **Asla yukarı doğru filtreleme.** Bir dağılım grafiğini, tıklanan detayın ait olduğu tek çubuğa
indirgemek o grafiğin tek varlık sebebini atar.
- **Filtre görünür biçimde aktif ve geri alınabilirdir** — filtreleyen öğe seçili durumunu gösterir,
hedef neye göre filtrelendiğini söyler ve aynı öğeye tekrar tıklamak temizler. Kullanıcıyı geri
dönmek için sayfayı yenilemeye asla zorlama.
- **Vurgu geçicidir ve asla veri gizlemez.** İlişkili öğeyi öne çıkar (renk, çerçeve, kardeşlerini
soluklaştırma); satır kaldırma.
- **İkisi de dışarı tıklamada temizlenir**, aşağıdaki "Dışarı Tıklamak Seçimi Temizler" kuralına göre.
- **Eşleşme aşikâr değilse söyle** — tek satırlık bir ipucu ("aşağıdaki hareketleri filtrelemek için
bir yıla tıklayın") kullanıcının kontrolü hiç keşfetmemesinden ucuzdur.
A cross-filter names itself on EVERY element it touches
When selecting in one component filters others on the same screen, each affected component must say what it is filtered by —…
EN
## A cross-filter names itself on EVERY element it touches
When selecting in one component filters others on the same screen, each affected component must say
what it is filtered by — **immediately beside its own title, muted**, e.g. `Headcount by Country
· Department: Growth`.
**A banner at the top of the page does not count.** That is the failure this rule exists for: on a
screen of eight cards the reader scrolls, and by the time they are looking at the sixth chart the
banner is off-screen. What they see is one card showing 81 people where it showed 614 a moment ago,
with nothing on it explaining why — so the chart looks broken or, worse, believable.
- **Beside the title, not above the content.** The heading is the one thing a reader is guaranteed to
have in view when they are looking at that card.
- **Muted, and secondary to the title.** It is state, not a name — normal weight, faint colour, never
competing with the heading it qualifies.
- **Name the dimension AND the value** (`Department: Growth`, not `Growth`). Alone, a value is
ambiguous the moment two dimensions share vocabulary.
- **The component you selected IN does not get one.** It keeps its full distribution so you can
change selection; calling it "filtered by" would describe it wrongly. Its selected row/slice is
already marked.
- **A component the filter does not reach must stay silent** — labelling it would imply a narrowing
that did not happen.
- Keep the page-level banner as well if it carries the clear control; the two are not alternatives.
TR
## Çapraz filtre dokunduğu HER öğenin üstünde kendini söyler
Bir bileşendeki seçim aynı ekrandaki diğerlerini filtreliyorsa, etkilenen her bileşen neye göre
filtrelendiğini söylemeli — **kendi başlığının hemen yanında, soluk olarak**, ör. `Headcount by Country
· Department: Growth`.
**Sayfanın en üstündeki bir bant sayılmaz.** Bu kuralın var olma sebebi tam olarak o arıza: sekiz kartlık
bir ekranda okuyucu aşağı kayar ve altıncı grafiğe baktığında bant ekranın dışında kalmıştır. Gördüğü
şey, bir an önce 614 gösterirken şimdi 81 kişi gösteren bir karttır ve üstünde nedenini açıklayan hiçbir
şey yoktur — yani grafik bozuk görünür ya da daha kötüsü, inandırıcı görünür.
- **Başlığın yanında, içeriğin üstünde değil.** Okuyucunun o karta bakarken görüş alanında olduğu kesin
olan tek şey başlıktır.
- **Soluk ve başlığa göre ikincil.** Bu bir durumdur, bir ad değil — normal kalınlık, soluk renk, nitelediği
başlıkla asla yarışmaz.
- **Boyutu VE değeri birlikte söyle** (`Department: Growth`, `Growth` değil). Tek başına bir değer, iki
boyut aynı kelimeyi paylaştığı anda belirsizleşir.
- **Seçim yaptığın bileşen bunu almaz.** O, seçimi değiştirebilmen için tam dağılımını korur; ona "şuna
göre filtrelendi" demek onu yanlış anlatır. Seçili satırı/dilimi zaten işaretlidir.
- **Filtrenin ulaşmadığı bir bileşen sessiz kalmalı** — etiketlemek, gerçekleşmemiş bir daralmayı ima eder.
- Temizleme kontrolünü taşıyorsa sayfa seviyesindeki bandı da koru; ikisi alternatif değil.
Click-Outside Clears Selection
Any interactive selection state in a table or chart (highlighted row, column, cell, filter) must be cleared when the user…
EN
## Click-Outside Clears Selection
Any interactive selection state in a table or chart (highlighted row, column, cell, filter) must be cleared when the user clicks outside the component. Implement with a `mousedown`/`click` listener on `document` and a `ref` on the component container — if the click target is **not inside the ref**, reset the selection state.
- **Decide "outside" by testing the click target against the container ref — NEVER by "clear on every document click and rely on the selecting controls' `stopPropagation()` to be spared."** React's synthetic `stopPropagation()` does **not** reliably stop a native `document.addEventListener` handler (React delegates events at its root, not `document`), so a bare clear-on-any-click also fires on the very click that made the selection — the selection is set and then cleared in the same interaction, so it flashes and vanishes. The target-vs-ref test is the fix; keep `stopPropagation` only as harmless defence-in-depth.
- **A scrollbar click is NOT an outside click.** Dragging the scrollbar fires `mousedown` on the
document with a target outside your container, so a naive test clears the selection the moment the
user tries to scroll down to look at what they just selected — the single most infuriating version
of this bug, because the act of examining the result destroys it. Bail out before the ref test:
```js
// The scrollbar lives outside the client area, so its coordinates fall past these bounds.
if (e.clientX > document.documentElement.clientWidth ||
e.clientY > document.documentElement.clientHeight) return;
```
Put this in ONE shared helper and use it in every click-outside handler — it is exactly the kind of
rule that gets applied to three of four places and then reported as a bug on the fourth.
- **Include any portaled selection controls in the "inside" test.** If chips, a Reset button, or other selecting controls are rendered elsewhere via `createPortal` (e.g. into a top-bar slot), they live OUTSIDE the container ref's DOM subtree — the listener must treat clicks on those slot elements as "inside" too (test the ref **and** the portal target nodes), or clicking them wrongly clears everything.
TR
## Dışarı Tıklamak Seçimi Temizler
Bir tablodaki veya grafikteki her etkileşimli seçim durumu (vurgulu satır, kolon, hücre, filtre) kullanıcı bileşenin dışına tıkladığında temizlenmeli. `document` üzerinde bir `mousedown`/`click` dinleyicisi ve bileşen container'ında bir `ref` ile uygula — tıklama hedefi **ref'in içinde değilse** seçim durumunu sıfırla.
- **"Dışarısı"na, tıklama hedefini container ref'ine karşı sınayarak karar ver — ASLA "her document tıklamasında temizle ve seçim yapan kontrollerin `stopPropagation()` çağrısının kurtaracağına güven" diyerek değil.** React'in sentetik `stopPropagation()` çağrısı, yerel bir `document.addEventListener` işleyicisini güvenilir biçimde **durdurmaz** (React olayları `document` yerine kendi kökünde devreder), yani yalın bir her-tıklamada-temizle, seçimi yapan tıklamada da ateşlenir — seçim aynı etkileşim içinde hem kurulur hem temizlenir, bir an görünüp kaybolur. Çözüm hedef-ve-ref sınamasıdır; `stopPropagation`'ı sadece zararsız bir ikinci katman olarak tut.
- **Kaydırma çubuğuna tıklamak dışarı tıklama DEĞİLDİR.** Kaydırma çubuğunu sürüklemek, hedefi
container'ının dışında olan bir `mousedown` olayını document üzerinde ateşler; yani saf bir sınama,
kullanıcı az önce seçtiği şeye bakmak için aşağı kaydırmaya çalıştığı anda seçimi temizler — bu
hatanın en sinir bozucu hali, çünkü sonucu inceleme eylemi sonucu yok eder. Ref sınamasından önce çık:
```js
// The scrollbar lives outside the client area, so its coordinates fall past these bounds.
if (e.clientX > document.documentElement.clientWidth ||
e.clientY > document.documentElement.clientHeight) return;
```
Bunu TEK bir ortak yardımcıya koy ve her dışarı-tıklama işleyicisinde kullan — tam olarak dört yerin
üçüne uygulanıp dördüncüsünde hata olarak raporlanan kural cinsinden.
- **Portal'la taşınmış seçim kontrollerini "içerisi" sınamasına dahil et.** Chip'ler, bir Reset butonu ya da başka seçim kontrolleri `createPortal` ile başka bir yere çiziliyorsa (ör. üst bardaki bir yuvaya), container ref'inin DOM alt ağacının DIŞINDA yaşarlar — dinleyici o yuva öğelerine yapılan tıklamaları da "içerisi" saymalı (ref **ve** portal hedef düğümlerini sına), yoksa onlara tıklamak her şeyi yanlışlıkla temizler.
Reuse the app's existing control — never hand-roll a second copy of one that exists
Before writing a filter, dropdown, popover, table shell, or any other piece of shared chrome on a NEW screen, find what the…
EN
## Reuse the app's existing control — never hand-roll a second copy of one that exists
Before writing a filter, dropdown, popover, table shell, or any other piece of shared chrome on a NEW screen, find
what the app's existing screens use and import that. A hand-written `<input>`/`<select>` cannot satisfy the specs in
this file — the custom-dropdown rule, the caption-icon rule, the "All" first rule, hover-open, keyboard behaviour —
and every re-implementation is a second source of truth that drifts on the next change to either copy.
The tell that this went wrong: two screens in the same app whose filter bars look different. If a reviewer can see
the inconsistency in a screenshot, the cause is almost always a second implementation, not a missing style.
- **Look for the component first**, then the CSS class, then the doc. Grep the codebase for the control before
writing one: a project with a `FilterBar` almost certainly has a `SingleSelect`/`MultiSelect` behind it.
- **Adding CSS for a control that already has styles is the smell.** If a new screen needs `.my-page-filter` rules to
make a dropdown look right, the dropdown is the wrong dropdown.
- This applies inside panels and modals too — a raw select in a dialog is still a raw select.
(2026-08-06, Revenue Forecasting Tool: an Outlook screen shipped with bare `<select>` elements beside a Comparison
screen using the shared `SingleSelect`. The two bars were visibly different. Fixed by importing the component and
DELETING the bespoke CSS, not by restyling it.)
TR
## Uygulamanın mevcut kontrolünü yeniden kullan — var olanın ikinci bir kopyasını asla elle yazma
YENİ bir ekranda bir filtre, dropdown, popover, tablo iskeleti ya da başka bir ortak arayüz parçası yazmadan önce,
uygulamanın mevcut ekranlarının ne kullandığını bul ve onu içe aktar. Elle yazılmış bir `<input>`/`<select>` bu
dosyadaki şartları karşılayamaz — özel dropdown kuralı, başlık simgesi kuralı, "All" ilk sırada kuralı, hover ile
açılma, klavye davranışı — ve her yeniden uygulama, iki kopyadan birinde yapılacak ilk değişiklikte sapacak ikinci
bir doğruluk kaynağıdır.
Bunun yanlış gittiğinin işareti: aynı uygulamada filtre barları farklı görünen iki ekran. Bir gözden geçiren
tutarsızlığı bir ekran görüntüsünde görebiliyorsa, sebep neredeyse her zaman ikinci bir uygulamadır, eksik bir
stil değil.
- **Önce bileşeni ara**, sonra CSS sınıfını, sonra dokümanı. Bir tane yazmadan önce kod tabanında kontrolü grep'le:
`FilterBar`'ı olan bir projenin arkasında neredeyse kesinlikle bir `SingleSelect`/`MultiSelect` vardır.
- **Zaten stilleri olan bir kontrol için CSS eklemek, kokunun kendisidir.** Yeni bir ekranın bir dropdown'ı doğru
göstermek için `.my-page-filter` kurallarına ihtiyacı varsa, o dropdown yanlış dropdown'dır.
- Bu, panel ve modal'ların içinde de geçerlidir — bir diyalogdaki ham select hâlâ ham select'tir.
(2026-08-06, Revenue Forecasting Tool: bir Outlook ekranı, ortak `SingleSelect` kullanan bir Comparison ekranının
yanında yalın `<select>` öğeleriyle çıktı. İki bar gözle görülür biçimde farklıydı. Bileşen içe aktarılarak ve
özel CSS SİLİNEREK düzeltildi, yeniden stillendirilerek değil.)
An empty screen must name the filter that emptied it — silent fail-closed is still a failure
When a screen can render zero rows, it must distinguish "you are not permitted to see any of this" from "there is genuinely no…
EN
## An empty screen must name the filter that emptied it — silent fail-closed is still a failure
When a screen can render zero rows, it must distinguish **"you are not permitted to see any of this"** from
**"there is genuinely no data"**, and from **"your filters excluded everything"**. All three look identical as a
blank table, and only one of them is the user's to fix.
- **Permission-shaped emptiness names the remedy**: state that the viewer holds no role/scope for this screen, say
what the scoping rule is, and name who grants it ("ask an administrator to assign you X for your clients").
- **Filter-shaped emptiness names the filters**: say how many rows were excluded and by which rule, so the user can
reverse it. If a screen silently drops a fifth of its rows, it is lying by omission — report the count, split by
reason, whether or not anyone asked.
- **Data-shaped emptiness says so plainly** rather than borrowing either of the above.
Learned the hard way (2026-08-06, Revenue Forecasting Tool): client-scoped authorisation was added to a screen whose
grant table had **never been populated**, so the correct answer — "no grants, therefore no rows" — rendered as an
empty table with no explanation, for every user including the app's owner. The authorisation logic was right; the
silence was the bug. Corollary in *Fail-closed authorisation: check the grant table is populated* in
`engineering.md`.
TR
## Boş bir ekran onu boşaltan filtreyi söylemeli — sessizce kapanmak da bir hatadır
Bir ekran sıfır satır gösterebiliyorsa, **"bunların hiçbirini görme yetkiniz yok"** ile **"gerçekten hiç veri yok"**
ve **"filtreleriniz her şeyi dışarıda bıraktı"** durumlarını ayırt etmelidir. Üçü de boş bir tablo olarak birebir aynı
görünür ve sadece birini kullanıcı düzeltebilir.
- **Yetki kaynaklı boşluk çareyi söyler**: bakan kişinin bu ekran için hiçbir rolü/kapsamı olmadığını belirt, kapsam
kuralının ne olduğunu söyle ve bunu kimin verdiğini adlandır ("müşterileriniz için X yetkisini atamasını bir
yöneticiden isteyin").
- **Filtre kaynaklı boşluk filtreleri söyler**: kaç satırın hangi kuralla dışarıda bırakıldığını söyle ki kullanıcı
geri alabilsin. Bir ekran satırlarının beşte birini sessizce düşürüyorsa, eksiklikle yalan söylüyordur — kimse
sormasa da sayıyı, sebebine göre ayırarak raporla.
- **Veri kaynaklı boşluk bunu düpedüz söyler**, yukarıdakilerden birini ödünç almak yerine.
Zor yoldan öğrenildi (2026-08-06, Revenue Forecasting Tool): yetki tablosu **hiç doldurulmamış** bir ekrana müşteri
kapsamlı yetkilendirme eklendi, böylece doğru cevap — "yetki yok, dolayısıyla satır yok" — uygulamanın sahibi dahil
her kullanıcıya açıklamasız boş bir tablo olarak göründü. Yetkilendirme mantığı doğruydu; hata sessizlikti. Karşılığı
`engineering.md` içindeki *Fail-closed authorisation: check the grant table is populated* kuralıdır.
Sortable Table Headers — consistent across every data column
In a data table, every data column must be sortable (show a sort icon and sort on click) — do not leave some columns sortable…
EN
## Sortable Table Headers — consistent across every data column
In a data table, **every data column must be sortable** (show a sort icon and sort on click) — do not
leave some columns sortable and others not. Only a pure **action / display** column (e.g. a per-row
formula-breakdown or a row-action column) may omit sort, because it has no orderable value. Computed
columns (USD conversions, taxes, derived totals) are sortable by their computed value.
**Icon vertical alignment:** the sort icon must always sit on the **first line of its column label**, at
the same height for every column — whether the header is one line or wraps to two/three lines. Achieve
this by aligning the icon to the top of the label (`items-start`) with a small optical nudge
(`mt-[2px]` for a 10px icon), NOT `items-center` (which drops the icon lower on multi-line headers and
makes the row's icons ragged). Right-aligned (numeric) headers put the icon to the **left** of the label;
left-aligned headers put it to the right.
TR
## Sıralanabilir Tablo Başlıkları — her veri kolonunda tutarlı
Bir veri tablosunda **her veri kolonu sıralanabilir olmalı** (sıralama simgesi göster ve tıklayınca sırala) — bazı
kolonları sıralanabilir bırakıp diğerlerini bırakmama. Sadece saf bir **aksiyon / gösterim** kolonu (ör. satır başına
formül kırılımı ya da satır aksiyonları kolonu) sıralamayı atlayabilir, çünkü sıralanabilir bir değeri yoktur. Hesaplanan
kolonlar (USD çevrimleri, vergiler, türetilmiş toplamlar) hesaplanmış değerlerine göre sıralanabilir.
**Simgenin dikey hizası:** sıralama simgesi her zaman **kolon etiketinin ilk satırında**, her kolon için aynı yükseklikte
durmalı — başlık tek satır olsun, iki ya da üç satıra sarsın fark etmez. Bunu simgeyi etiketin üstüne hizalayarak
(`items-start`) ve küçük bir optik kaydırmayla (10px simge için `mt-[2px]`) sağla, `items-center` ile DEĞİL (o, çok satırlı
başlıklarda simgeyi aşağı düşürür ve satırın simgelerini tırtıklı gösterir). Sağa hizalı (sayısal) başlıklar simgeyi
etiketin **soluna**, sola hizalı başlıklar sağına koyar.
Section Spacing
Adjacent dashboard sections (cards, tables, charts) must always have consistent vertical spacing that matches the rest of the…
EN
## Section Spacing
Adjacent dashboard sections (cards, tables, charts) must always have consistent vertical spacing that matches the rest of the page — use `mt-4` (or the page's standard gap) between sibling sections. Never let two bordered cards/tables sit flush against each other with no gap.
TR
## Bölüm Boşlukları
Yan yana duran dashboard bölümleri (kartlar, tablolar, grafikler) her zaman, sayfanın geri kalanıyla uyumlu ve tutarlı bir dikey boşluğa sahip olmalı — kardeş bölümler arasında `mt-4` (ya da sayfanın standart boşluğu) kullan. Kenarlıklı iki kartın/tablonun boşluksuz, birbirine yapışık durmasına asla izin verme.
Afiniti logos — two DISTINCT assets, never interchange them
Two different Afiniti logos live in the shared logos/ folder; each has its own place and they must never be swapped: - Afiniti…
EN
## Afiniti logos — two DISTINCT assets, never interchange them
Two different Afiniti logos live in the shared `logos/` folder; each has its own place and they must **never be swapped**:
- **Afiniti AICC logo ("afinitiaicc")** — canonical source **`logos/afinitiaicc_logo_transparent.png`** (not `afinitiaicc_logo.png`). The AICC team's own mark, and the **default on the sign-in screen** — shown centered above the login/landing card (per `login.md` §6) — as well as anchored top-right in a **dashboard top bar** (per [`design-dashboard.md`](./design-dashboard.md)). ⚠️ It exists **only at 166×44**: draw it at its native **44px** height on login, and never scale it past ~56px, where the edges visibly soften. There is no larger original; ask brand for a vector/2× rather than enlarging it.
- **Afiniti corporate wordmark (black-on-light)** — source `logos/Afiniti_Logo_black.png`, 2158×707 so it scales anywhere. The company logo, for a surface presented as a company-wide system rather than an AICC one. It was the sign-in default until 2026-08-20 and is now the documented **alternative** there — a project that wants it says so explicitly.
Copy whichever file the app needs into that app's own `public/` (self-contained — never hotlink the shared `logos/` folder), and keep the two straight: the corporate wordmark is **not** the afinitibi logo, and vice-versa.
**Excel export buttons use the Excel product logo** — `logos/excel_blue.png` (~18px, logo alone: no
text, no frame; muted/grayscale at rest, full color on hover, stays grey when disabled). The full
rule (every chart AND table card gets one, export content/format) lives in
[`design-dashboard.md`](./design-dashboard.md) → "Export to Excel button" — defined once there, this is the pointer.
**Top-bar logo aligns with the filter bar.** The dashboard top-bar logo (the AICC mark) is **sized to
the filter control row and vertically aligned with it**: constrain by height to roughly the control
row (e.g. `h-10` next to 36px controls — never larger than the filter block), and place its **bottom
edge on the filter block's bottom edge** (caption + control), nudged **up ~3px** for optical balance —
a logo's visual mass reads lower than a hard box edge. Compute `top = filterBlockBottom − logoHeight − 3`
on the absolutely-anchored logo; never pin it flush to the bar's top or let it hang below the
controls. (Full top-bar anchoring mechanics — absolute top-right corner, reserved bar padding —
in [`design-dashboard.md`](./design-dashboard.md) → "Dashboard top bar design".)
TR
## Afiniti logoları — iki AYRI görsel, asla birbirinin yerine kullanılmaz
Ortak `logos/` klasöründe iki farklı Afiniti logosu yaşar; her birinin kendi yeri vardır ve **asla yer değiştirmezler**:
- **Afiniti AICC logosu ("afinitiaicc")** — kanonik kaynak **`logos/afinitiaicc_logo_transparent.png`** (`afinitiaicc_logo.png` değil). AICC ekibinin kendi markası ve **giriş ekranındaki varsayılan** — login/landing kartının üstünde ortalanmış olarak (`login.md` §6'ya göre) ve ayrıca bir **dashboard üst barında** sağ üste sabitlenmiş olarak ([`design-dashboard.md`](./design-dashboard.md)'ye göre). ⚠️ **Sadece 166×44 boyutunda var**: girişte kendi doğal **44px** yüksekliğinde çiz ve kenarların gözle görülür biçimde yumuşadığı ~56px'i asla geçme. Daha büyük bir orijinali yok; büyütmek yerine markadan vektör/2× iste.
- **Afiniti kurumsal kelime markası (açık zemin üzerine siyah)** — kaynak `logos/Afiniti_Logo_black.png`, 2158×707, yani her yere ölçeklenir. Şirket logosudur; AICC'ye değil şirketin tamamına ait bir sistem olarak sunulan yüzeyler için. 2026-08-20'ye kadar giriş ekranının varsayılanıydı, artık orada belgeli **alternatif**; onu isteyen proje bunu açıkça söyler.
Uygulamanın ihtiyaç duyduğu dosyayı o uygulamanın kendi `public/` klasörüne kopyala (kendi kendine yeter olsun — ortak `logos/` klasörüne asla doğrudan bağlanma) ve ikisini karıştırma: kurumsal kelime markası afinitibi logosu **değildir**, tersi de öyle.
**Excel'e aktarma butonları Excel ürün logosunu kullanır** — `logos/excel_blue.png` (~18px, yalnız logo: metin yok,
çerçeve yok; beklemede soluk/gri tonlu, hover'da tam renkli, devre dışıyken gri kalır). Tam kural (her grafik VE tablo
kartı bir tane alır, aktarma içeriği/biçimi)
[`design-dashboard.md`](./design-dashboard.md) → "Export to Excel button" içinde yaşar — orada bir kez tanımlandı, bu onun işaretçisi.
**Üst bar logosu filtre barıyla hizalanır.** Dashboard üst bar logosu (AICC markası) **filtre kontrol satırına göre
boyutlanır ve onunla dikeyde hizalanır**: yüksekliği kabaca kontrol satırına sınırla (ör. 36px kontrollerin yanında
`h-10` — filtre bloğundan asla büyük değil) ve **alt kenarını filtre bloğunun alt kenarına** (başlık + kontrol) yerleştir,
optik denge için **~3px yukarı** kaydırarak — bir logonun görsel ağırlığı sert bir kutu kenarından daha aşağıda okunur.
Mutlak konumlandırılmış logoda `top = filterBlockBottom − logoHeight − 3` hesapla; onu asla barın üstüne yapıştırma ya da
kontrollerin altına sarkıtma. (Tam üst bar sabitleme mekaniği — mutlak sağ üst köşe, ayrılmış bar boşluğu —
[`design-dashboard.md`](./design-dashboard.md) → "Dashboard top bar design" içinde.)
KPI Card Row Alignment
KPI cards sitting side-by-side in the same grid row must keep their internal elements horizontally aligned across cards: title…
EN
## KPI Card Row Alignment
KPI cards sitting side-by-side in the same grid row must keep their internal elements horizontally aligned across cards: title labels at the same height, sub-labels at the same height, primary metrics at the same height, and footers at the same height. Use a 4-zone `flex flex-col` structure on every card: **Zone 1** title (`h-5 flex items-center`), **Zone 2** sub-label (`h-4 flex items-center`), **Zone 3** primary metric (`flex-1 flex items-end`), **Zone 4** footer (`mt-2`). Cards with no sub-label must still include an empty `h-4` spacer so the numbers align with cards that do have one.
TR
## KPI Kart Satırı Hizalaması
Aynı grid satırında yan yana duran KPI kartları, iç öğelerini kartlar arasında yatay olarak hizalı tutmalı: başlık etiketleri aynı yükseklikte, alt etiketler aynı yükseklikte, birincil metrikler aynı yükseklikte ve alt bilgiler aynı yükseklikte. Her kartta 4 bölgeli bir `flex flex-col` yapısı kullan: **Bölge 1** başlık (`h-5 flex items-center`), **Bölge 2** alt etiket (`h-4 flex items-center`), **Bölge 3** birincil metrik (`flex-1 flex items-end`), **Bölge 4** alt bilgi (`mt-2`). Alt etiketi olmayan kartlar bile boş bir `h-4` boşluk taşımalı ki sayılar, alt etiketi olan kartlarla hizalansın.
A heading and the items beneath it share ONE left edge
Within a panel — and across a vertical stack of panels/boxes in the same column — the section heading and every item under it…
EN
## A heading and the items beneath it share ONE left edge
Within a panel — and across a vertical stack of panels/boxes in the same column — the section
heading and **every item under it** (table cells, list rows, sub-boxes, derived rows, a summary bar)
must start at the **same left inset**. A heading indented further than, or less than, its own content
reads as broken, and a column of boxes whose contents each start at a different x looks like several
people built it.
- **Give the whole stack ONE horizontal inset** (pick a single value, e.g. `18px`, and apply it to
every box's content padding — the heading wrapper, the framed sub-boxes, the cost/derive rows, the
summary bar).
- **Table cells must use that same left padding**, not the table's default cell padding. A `<table>`
dropped straight into a `padding:0` panel puts its first column at the table's own `td` inset
(often ~11px) while the heading above sits at the panel inset (e.g. 21px) — the classic mismatch.
Tag the table and set `th,td{padding-left:<inset>}` so the first column's text lines up under the
heading.
- **One named exception: the sign-in screen.** The app name and its single sign-in line are
**centered** under the centered logo (`login.md` §6) — an arrival surface with no
content to align to. It is limited to those two lines; the fields below them are left-aligned with
each other, and no other heading anywhere gets this treatment.
- **Verify the rendered text-left, not the box edges.** A cell's border-box left differs from its
text-left by its padding; measure the heading's text-left and the first item's text-left (e.g. a
`Range` over the text node) and require them **equal**, not merely "close".
(2026-08-17, AICC Executive Pack case detail: the "Efficiency gains" heading sat at 21px, its table
cells at 11px, the cost-avoided box at 18px and the ROI derive rows at 15px — four left edges in one
column. Unified to a single 18px inset; heading text and first cell text now land on the same x.)
### At page scale: one text left edge, and the page title is on it
The same rule, one level up. **Every piece of text on a page starts at the same x** — the page
title, plain paragraphs, section headings, and the text inside every card, callout and command box.
- **Align the text, not the boxes.** Lining up box edges is the trap: each box's own padding then
pushes its text inward, so the page measures as aligned at the border-box and still reads as
ragged. The shared line is the rendered text.
- **The page title sits on that line too.** In a sidebar-plus-content layout the title usually lives
in its own top bar. Its horizontal padding and the content area's padding must come from the same
decision, or the title drifts from everything beneath it. One wrapper owning the horizontal
padding beats two equal values written in two places — equal values drift apart, a single source
cannot.
- **Push the box out, keep the text still.** Since a box's padding indents its text, pull the
container that holds boxes outward by exactly that padding plus its border. The box edge then sits
outside the text column and the text lands on the shared line. This only works if every box's
padding is normalised to one value first; with three different paddings, one offset cannot align
three kinds of box.
(2026-09-24, firatada.com restructure. Two failures in sequence, and the rule above already covered
the second one. First the content wrapper was dropped while rewriting the shell: the title sat at
266px and every view at 236px — one missing element, seven pages wrong, no individual element at
fault. With the wrapper restored the box edges matched, but the text still did not: title 266, card
title 285, command-box text 298 — three lines in one column. Box paddings were normalised to 18px
and the containers pulled out 19px, after which title, lede, section headings, card titles and
excerpts all measured 266 on six pages. The lesson is not a new rule: "verify the rendered
text-left, not the box edges" was already written above, and was simply not applied.)
TR
## Bir başlık ve altındaki öğeler TEK bir sol kenarı paylaşır
Bir panelin içinde — ve aynı kolondaki panel/kutu yığınının tamamında — bölüm başlığı ve **altındaki
her öğe** (tablo hücreleri, liste satırları, alt kutular, türetilmiş satırlar, bir özet barı) **aynı sol
içeriden** başlamalı. Kendi içeriğinden daha içeride ya da daha dışarıda duran bir başlık bozuk okunur
ve içerikleri farklı x'lerden başlayan bir kutu kolonu, birkaç kişinin ayrı ayrı yaptığı gibi görünür.
- **Bütün yığına TEK bir yatay içeri ver** (tek bir değer seç, ör. `18px`, ve her kutunun içerik
boşluğuna uygula — başlık sarmalayıcısı, çerçeveli alt kutular, maliyet/türetme satırları, özet barı).
- **Tablo hücreleri de aynı sol boşluğu kullanmalı**, tablonun varsayılan hücre boşluğunu değil.
`padding:0` olan bir panele doğrudan bırakılan bir `<table>`, ilk kolonunu tablonun kendi `td` içerisine
(çoğunlukla ~11px) koyar, oysa üstündeki başlık panel içerisinde (ör. 21px) durur — klasik uyumsuzluk.
Tabloyu etiketle ve `th,td{padding-left:<inset>}` ayarla ki ilk kolonun metni başlığın altına otursun.
- **Adı konmuş tek istisna: giriş ekranı.** Uygulama adı ve tek satırlık giriş cümlesi, ortalanmış logonun
altında **ortalanır** (`login.md` §6) — hizalanacak içeriği olmayan bir varış yüzeyi. Bu, o iki satırla
sınırlıdır; altlarındaki alanlar birbiriyle sola hizalıdır ve başka hiçbir başlık bu muameleyi görmez.
- **Kutu kenarlarını değil, çizilen metnin solunu doğrula.** Bir hücrenin border-box solu, metin solundan
kendi boşluğu kadar farklıdır; başlığın metin solunu ve ilk öğenin metin solunu ölç (ör. metin düğümü
üzerinde bir `Range`) ve **eşit** olmalarını şart koş, "yakın" olmalarını değil.
(2026-08-17, AICC Executive Pack vaka detayı: "Efficiency gains" başlığı 21px'te, tablo hücreleri 11px'te,
kaçınılan maliyet kutusu 18px'te ve ROI türetme satırları 15px'te duruyordu — tek kolonda dört sol kenar.
Tek bir 18px içeride birleştirildi; başlık metni ve ilk hücre metni artık aynı x'e iniyor.)
### Sayfa ölçeğinde: tek bir metin sol kenarı, ve sayfa başlığı da onun üstünde
Aynı kural, bir seviye yukarıda. **Bir sayfadaki her metin parçası aynı x'ten başlar** — sayfa başlığı,
düz paragraflar, bölüm başlıkları ve her kartın, çağrı kutusunun ve komut kutusunun içindeki metin.
- **Kutuları değil metni hizala.** Kutu kenarlarını hizalamak tuzağın kendisidir: her kutunun kendi boşluğu
metnini içeri iter, yani sayfa border-box'ta hizalı ölçülür ve yine de tırtıklı okunur. Paylaşılan çizgi,
çizilen metindir.
- **Sayfa başlığı da o çizginin üstündedir.** Kenar çubuğu artı içerik düzeninde başlık genelde kendi üst
barında yaşar. Onun yatay boşluğu ile içerik alanının boşluğu aynı karardan gelmeli, yoksa başlık altındaki
her şeyden kayar. Yatay boşluğa sahip çıkan tek bir sarmalayıcı, iki yere yazılmış iki eşit değerden iyidir —
eşit değerler birbirinden uzaklaşır, tek kaynak uzaklaşamaz.
- **Kutuyu dışarı it, metni yerinde tut.** Bir kutunun boşluğu metnini içeri çektiği için, kutuları taşıyan
container'ı tam olarak o boşluk artı kenarlığı kadar dışarı çek. O zaman kutu kenarı metin kolonunun dışında
kalır ve metin paylaşılan çizgiye iner. Bu ancak her kutunun boşluğu önce tek bir değere normalleştirilirse
işe yarar; üç farklı boşlukla tek bir kaydırma üç tür kutuyu hizalayamaz.
(2026-09-24, firatada.com yeniden yapılandırması. Arka arkaya iki hata ve yukarıdaki kural ikincisini zaten
kapsıyordu. Önce kabuk yeniden yazılırken içerik sarmalayıcısı düştü: başlık 266px'te, her görünüm 236px'te —
tek bir eksik öğe, yedi sayfa yanlış, kusurlu tek bir öğe yok. Sarmalayıcı geri gelince kutu kenarları uydu ama
metin hâlâ uymadı: başlık 266, kart başlığı 285, komut kutusu metni 298 — tek kolonda üç çizgi. Kutu boşlukları
18px'e normalleştirildi ve container'lar 19px dışarı çekildi; ardından başlık, giriş metni, bölüm başlıkları, kart
başlıkları ve özetlerin hepsi altı sayfada 266 ölçtü. Ders yeni bir kural değil: "kutu kenarlarını değil çizilen
metnin solunu doğrula" zaten yukarıda yazılıydı ve sadece uygulanmamıştı.)
Multi-column text: the unbreakable unit is a section, not a block
In a multi-column text flow the thing that must never be split is a section: a heading together with the content under it.
EN
## Multi-column text: the unbreakable unit is a section, not a block
In a multi-column text flow the thing that must never be split is a **section**: a heading together
with the content under it. A list separated from its heading has lost what it is a list of.
Two settings look reasonable and both fail:
- **Every block unbreakable.** A long list counts as one indivisible block, jumps to the next
column, and leaves the previous one nearly empty.
- **Every row unbreakable.** The list now flows and the columns fill, but the heading stays behind
in one column while half its rows move to the next.
The fix is structural, not a property value: wrap each heading and its content in one container and
put the no-break rule on that container. Flat markup — a heading and its list as siblings — cannot
express the unit, so the wrapper has to exist before CSS can protect it.
(2026-09-24, firatada.com. The same card broke twice. First `break-inside: avoid` on every block
child pushed a ten-item list into column two and emptied column one. Then moving the rule down to
the rows filled both columns but stranded "Where your work is kept" in the left column with half its
items on the right. Wrapping each heading with its content in a `div.sec` and protecting that
settled it: five sections distributed across two columns, none of them cut.)
TR
## Çok kolonlu metin: bölünmez birim bir bölümdür, bir blok değil
Çok kolonlu bir metin akışında asla bölünmemesi gereken şey bir **bölümdür**: bir başlık ve altındaki
içerik birlikte. Başlığından koparılmış bir liste, neyin listesi olduğunu kaybetmiştir.
İki ayar makul görünür ve ikisi de başarısız olur:
- **Her blok bölünmez.** Uzun bir liste tek bir bölünemez blok sayılır, bir sonraki kolona atlar ve
öncekini neredeyse boş bırakır.
- **Her satır bölünmez.** Liste artık akar ve kolonlar dolar, ama başlık bir kolonda kalırken satırlarının
yarısı diğerine geçer.
Çözüm bir özellik değeri değil, yapısaldır: her başlığı ve içeriğini tek bir container'a sar ve bölünmezlik
kuralını o container'a koy. Düz işaretleme — başlık ve listesi kardeş olarak — birimi ifade edemez, yani CSS
onu koruyabilmeden önce sarmalayıcının var olması gerekir.
(2026-09-24, firatada.com. Aynı kart iki kez bozuldu. Önce her blok çocuğa konan `break-inside: avoid`, on
maddelik bir listeyi ikinci kolona itip birinci kolonu boşalttı. Sonra kuralı satır seviyesine indirmek iki
kolonu da doldurdu ama "Where your work is kept" başlığını sol kolonda, maddelerinin yarısını sağda bıraktı.
Her başlığı içeriğiyle birlikte bir `div.sec` içine sarıp onu korumak meseleyi kapattı: beş bölüm iki kolona
dağıldı, hiçbiri kesilmedi.)
Multi-Select Filter Dropdowns
MOVED to design-filters.md (2026-08-25, owner approval) — design-filters.md owns filter, search and lookup mechanics.
EN
## Multi-Select Filter Dropdowns
MOVED to [`design-filters.md`](./design-filters.md) (2026-08-25, owner approval) — `design-filters.md` owns filter,
search and lookup mechanics. See the section of the same name there.
TR
## Çoklu Seçimli Filtre Dropdown'ları
[`design-filters.md`](./design-filters.md) dosyasına TAŞINDI (2026-08-25, sahip onayı) — filtre, arama ve lookup
mekaniği `design-filters.md` dosyasına aittir. Orada aynı adlı bölüme bak.
Dropdowns Always On Top
Dropdowns, popovers, and any floating overlay must always render above all other page content — including sticky headers,…
EN
## Dropdowns Always On Top
Dropdowns, popovers, and any floating overlay must always render above all other page content — including sticky headers, scrollable table areas, and fixed sidebars. Use `z-[9999]` (not just `z-50`) on the floating panel, and ensure the trigger's parent container establishes a stacking context above any scrollable content beneath it (add `relative z-10` or `isolation: isolate` to the filter/toolbar row).
TR
## Dropdown'lar Her Zaman En Üstte
Dropdown'lar, popover'lar ve her türlü yüzen katman her zaman diğer bütün sayfa içeriğinin üstünde çizilmeli — sticky başlıklar, kaydırılabilir tablo alanları ve sabit kenar çubukları dahil. Yüzen panelde `z-[9999]` kullan (sadece `z-50` değil) ve tetikleyicinin üst container'ının, altındaki kaydırılabilir içeriğin üstünde bir yığın bağlamı kurduğundan emin ol (filtre/araç çubuğu satırına `relative z-10` ya da `isolation: isolate` ekle).
No Browser Popups
Never use native browser dialogs (alert(), confirm(), prompt()) anywhere in any project.
EN
## No Browser Popups
Never use native browser dialogs (`alert()`, `confirm()`, `prompt()`) anywhere in any project. Always use in-app **modals** (custom modal/dialog components) for confirmations, prompts, and alerts.
TR
## Tarayıcı Popup'ları Yok
Hiçbir projede, hiçbir yerde yerel tarayıcı diyalogları (`alert()`, `confirm()`, `prompt()`) kullanma. Onaylar, sorular ve uyarılar için her zaman uygulama içi **modal'lar** (özel modal/diyalog bileşenleri) kullan.
Skeleton Loading
Never use spinner/loader indicators for content loading states.
EN
## Skeleton Loading
Never use spinner/loader indicators for **content loading states**. Always use **skeleton loaders** (placeholder shapes matching the content layout, with a subtle shimmer/pulse) while data is being fetched.
- **Scope = the content area.** This rule governs the region whose data is loading (page body, card grid, table). That region gets a skeleton, never a spinner.
- **Navigation-pending affordance is the exception.** A small spinner **may** sit on the control the user just clicked (e.g. a nav/menu item, a submit button) to confirm the click registered while the next view loads — that is a per-control affordance, not a content-loading indicator. Both coexist: spinner on the clicked control, skeleton in the content area it's loading into.
TR
## Skeleton Loading
**İçerik yükleme durumlarında** asla spinner/loader göstergesi kullanma. Veri getirilirken her zaman **skeleton loader** kullan (içerik yerleşimiyle eşleşen yer tutucu şekiller, hafif bir parıltı/nabız efektiyle).
- **Kapsam = içerik alanı.** Bu kural, verisi yüklenen bölgeyi yönetir (sayfa gövdesi, kart ızgarası, tablo). O bölge skeleton alır, asla spinner değil.
- **Yönlendirme bekleme ipucu istisnadır.** Kullanıcının az önce tıkladığı kontrolün üzerinde küçük bir spinner **durabilir** (ör. bir menü öğesi, bir gönder butonu) ki bir sonraki görünüm yüklenirken tıklamanın kaydedildiği teyit edilsin — bu kontrol başına bir ipucudur, içerik yükleme göstergesi değil. İkisi bir arada yaşar: tıklanan kontrolde spinner, yüklendiği içerik alanında skeleton.
Never render a silently-degraded dataset — say it's wrong, don't draw it
An empty screen announces itself.
EN
## Never render a silently-degraded dataset — say it's wrong, don't draw it
An empty screen announces itself. **A screen drawn from partially-broken data does not** — it looks
deliberate, and a reader will act on it. If a pipeline regression can turn a required field into
all-NULL (a stale ETL deploy, a renamed source column, a failed join), the reading app must **detect
it and say so**, not render a plausible-looking wrong picture.
- **Assert the invariant the screen depends on**, not just "did rows come back". An org chart needs
*exactly one root and almost no missing parent links*; a trend chart needs *dates that reach
today*; a breakdown needs *its categories to be non-empty*. Rows arriving is not the same as data
being usable.
- **On failure, show an explicit banner naming what's wrong** ("supervisor links are missing for 94%
of people — the org chart cannot be trusted, the nightly sync may have failed") and either
suppress the affected view or mark it. Never silently fall back to a shape that reads as intended:
a tree whose parent links are all NULL renders as a *flat list*, which looks like a design choice.
- **Prefer stale-but-correct to fresh-but-broken.** A loader that cannot fill its columns should
refuse and leave yesterday's data in place (`postgre.md` → "Rules for this doc" #7); the screen
then shows a correct older snapshot with its date, which is far better than a confident wrong one.
- **Row counts are not a health check.** The failure that motivated this rule kept every row and
every count intact — only the *values* in 25 columns went blank.
TR
## Sessizce bozulmuş bir veri setini asla çizme — yanlış olduğunu söyle, çizme
Boş bir ekran kendini duyurur. **Kısmen bozuk veriden çizilmiş bir ekran duyurmaz** — kasıtlı görünür ve
okuyan ona göre hareket eder. Bir pipeline gerilemesi zorunlu bir alanı tamamen NULL'a çevirebiliyorsa
(bayat bir ETL deploy'u, adı değişmiş bir kaynak kolonu, başarısız bir join), okuyan uygulama bunu
**tespit edip söylemeli**, makul görünen yanlış bir tablo çizmemeli.
- **Ekranın dayandığı değişmezi doğrula**, sadece "satır geldi mi" diye bakma. Bir organizasyon şeması
*tam olarak bir kök ve neredeyse hiç eksik üst bağlantı olmamasını* ister; bir trend grafiği *bugüne
ulaşan tarihler* ister; bir kırılım *kategorilerinin boş olmamasını* ister. Satırın gelmesi, verinin
kullanılabilir olmasıyla aynı şey değildir.
- **Başarısızlıkta neyin bozuk olduğunu söyleyen açık bir bant göster** ("insanların %94'ü için yönetici
bağlantıları eksik — organizasyon şemasına güvenilemez, gecelik senkronizasyon başarısız olmuş olabilir")
ve etkilenen görünümü ya bastır ya işaretle. Amaçlanmış gibi okunan bir biçime asla sessizce düşme: üst
bağlantılarının hepsi NULL olan bir ağaç *düz liste* olarak çizilir, ki bu bir tasarım tercihi gibi görünür.
- **Bayat-ama-doğruyu taze-ama-bozuğa tercih et.** Kolonlarını dolduramayan bir yükleyici reddetmeli ve dünün
verisini yerinde bırakmalı (`postgre.md` → "Rules for this doc" #7); ekran o zaman tarihiyle birlikte doğru
ve eski bir anlık görüntü gösterir, ki bu kendinden emin yanlış bir görüntüden çok daha iyidir.
- **Satır sayıları bir sağlık kontrolü değildir.** Bu kuralı doğuran arıza her satırı ve her sayımı olduğu gibi
korudu — sadece 25 kolondaki *değerler* boşaldı.
Auth-dependent screens — resolve the session on the SERVER, never flash a wrong state
A page whose content depends on who is asking must never be painted from a "signed out / still loading" default.
EN
## Auth-dependent screens — resolve the session on the SERVER, never flash a wrong state
A page whose content depends on **who is asking** must never be painted from a "signed out / still
loading" default. Resolve the session, and the data that depends on it, **on the server, per
request**, so the first HTML the browser receives is already the final screen.
The failure mode, hit twice in a row in HR Offboarding: an already-signed-in user saw the **login
form** for a moment before it vanished. Hiding the form until the check finished only moved the
problem — they then saw a **spinner** instead. The flash was never the form or the spinner; it was
*rendering before the answer was known*. The route was statically prerendered, so every visitor got
HTML built from the component's initial state (`isLoggedIn = false`) and the truth arrived only after
hydration had fetched `/api/...`.
- **Server-render it.** Next.js App Router: make the route a **server component** with
`export const dynamic = 'force-dynamic'`, read the session cookie there, fetch everything the
screen needs **in parallel**, and hand it to the client component as one `initial` prop. The client
component seeds its state from those props (and starts with its "checked" flag true, its "loading"
flag false — there is nothing left to check).
- **No on-mount refetch of what the server already sent.** It flips the loading flag back on and
reintroduces the very flash you just removed. Keep loaders for things that happen *after* render,
not for startup.
- **Refresh after an in-page login.** Everything seeded from the server is stale the moment a user
signs in without a reload — refetch **all** of it, not part. (Missing this kept the
Dashboard/Settings buttons hidden from a freshly signed-in admin until they reloaded.)
- **Never gate UI on "not yet known".** `!isLoggedIn` also means "we haven't heard back yet". Gate on
a *completed* check (`authChecked && !isLoggedIn`). If something genuinely must resolve in the
browser, show a **skeleton** (see above) — never the signed-out variant of the screen.
- **Such a page must not be cached** — its body depends on the caller's cookie. Expect
`Cache-Control: private, no-cache, no-store`. (A cached shell is "safe" only while it contains no
user data at all, which is exactly why the bug went unnoticed.)
**Verify it objectively — don't eyeball the flash.** Request the page twice, with and without a valid
session cookie:
```bash
curl -sk -H "Cookie: auth-token=<token>" https://app/ > in.html
curl -sk https://app/ > out.html
cmp in.html out.html # must DIFFER
grep -c '>Password<' in.html # signed-in: 0 (no login markup)
grep -c 'animate-spin' in.html out.html # both: 0 (no loading placeholder)
```
The two bodies **must differ** and each must already contain its final screen. Identical bodies mean
the page renders from a default state and **will** flash.
TR
## Oturuma bağlı ekranlar — session'ı SUNUCUDA çöz, asla yanlış bir durumu bir an gösterme
İçeriği **kimin sorduğuna** bağlı olan bir sayfa, asla "çıkış yapılmış / hâlâ yükleniyor" varsayılanından
çizilmemeli. Session'ı ve ona bağlı veriyi **sunucuda, istek başına** çöz ki tarayıcının aldığı ilk HTML
zaten nihai ekran olsun.
Arıza biçimi, HR Offboarding'de arka arkaya iki kez yaşandı: zaten giriş yapmış bir kullanıcı, kaybolmadan
önce bir an **login formunu** gördü. Kontrol bitene kadar formu gizlemek sorunu sadece taşıdı — bu kez
**spinner** gördüler. Parlayan şey hiçbir zaman form ya da spinner değildi; *cevap bilinmeden çizmekti*.
Rota statik olarak önceden çizilmişti, yani her ziyaretçi bileşenin başlangıç durumundan (`isLoggedIn = false`)
kurulmuş HTML alıyordu ve gerçek ancak hidrasyon `/api/...` çağrısını yaptıktan sonra geliyordu.
- **Sunucuda çiz.** Next.js App Router: rotayı `export const dynamic = 'force-dynamic'` ile bir **server
component** yap, session çerezini orada oku, ekranın ihtiyaç duyduğu her şeyi **paralel** getir ve istemci
bileşenine tek bir `initial` prop'u olarak ver. İstemci bileşeni durumunu bu prop'lardan tohumlar (ve
"kontrol edildi" bayrağı true, "yükleniyor" bayrağı false olarak başlar — kontrol edilecek bir şey kalmamıştır).
- **Sunucunun zaten gönderdiğini mount'ta yeniden getirme.** Bu, yükleniyor bayrağını tekrar açar ve az önce
kaldırdığın parlamayı geri getirir. Yükleyicileri çizimden *sonra* olan şeyler için sakla, başlangıç için değil.
- **Sayfa içi girişten sonra tazele.** Sunucudan tohumlanan her şey, bir kullanıcı sayfayı yenilemeden giriş
yaptığı anda bayattır — **hepsini** yeniden getir, bir kısmını değil. (Bunu kaçırmak, yeni giriş yapmış bir
yöneticiden Dashboard/Settings butonlarını sayfayı yenileyene kadar gizli tuttu.)
- **Arayüzü asla "henüz bilinmiyor" üzerine kapatma.** `!isLoggedIn` aynı zamanda "henüz cevap gelmedi" demektir.
*Tamamlanmış* bir kontrole göre kapat (`authChecked && !isLoggedIn`). Bir şey gerçekten tarayıcıda çözülmek
zorundaysa **skeleton** göster (yukarıya bak) — asla ekranın çıkış yapılmış hâlini değil.
- **Böyle bir sayfa önbelleğe alınmamalı** — gövdesi çağıranın çerezine bağlıdır. `Cache-Control: private, no-cache,
no-store` bekle. (Önbelleğe alınmış bir kabuk ancak içinde hiç kullanıcı verisi yokken "güvenlidir"; hatanın
fark edilmemesinin sebebi tam olarak budur.)
**Nesnel doğrula — parlamayı gözle arama.** Sayfayı geçerli bir session çereziyle ve onsuz olmak üzere iki kez iste:
```bash
curl -sk -H "Cookie: auth-token=<token>" https://app/ > in.html
curl -sk https://app/ > out.html
cmp in.html out.html # must DIFFER
grep -c '>Password<' in.html # signed-in: 0 (no login markup)
grep -c 'animate-spin' in.html out.html # both: 0 (no loading placeholder)
```
İki gövde **farklı olmalı** ve her biri zaten kendi nihai ekranını içermeli. Aynı gövdeler, sayfanın bir varsayılan
durumdan çizildiği ve **parlayacağı** anlamına gelir.
Server-rendered dates — format in UTC, never the server's local zone
Once a screen renders on the server, every date must produce the same string on the server and in the browser.
EN
## Server-rendered dates — format in UTC, never the server's local zone
Once a screen renders on the server, every date must produce the **same string** on the server and in
the browser. If it doesn't, React rewrites the text during hydration — a second flash, and usually a
wrong value shown first.
- **Date-only columns (Postgres `DATE`) come back as midnight UTC.** Formatting that instant in a
negative-offset zone rolls it back a day. The Linux box runs `America/New_York` while users sit in
Istanbul, so a last working day of `2026-07-26` rendered as **25 July 2026** server-side and was
silently corrected to **26 July 2026** by hydration.
- **Always pass an explicit `timeZone: 'UTC'`** to `toLocaleDateString` / `Intl.DateTimeFormat` for
any stored date, from **one shared helper** rather than at each call site (HR Offboarding:
`formatDateUTC()` in `components/offboarding/format.ts`; `lib/mail/templates.ts` already did this,
which is why emails were right while the page was wrong).
- **Timestamps need it too** — `22:30 UTC` is a different calendar day in New York than in Istanbul.
- Locally-derived dates are fine **when parsed and formatted in the same zone**: parsing a
`YYYY-MM-DD` value as **local noon** (append `T12:00:00`) and then formatting it locally always
yields the intended day.
- **Test in the server's zone, not yours.** Set `process.env.TZ = 'America/New_York'` before any date
work in the test file, and assert the trap itself (that local formatting gives the wrong day there)
so a simulated zone can't silently stop applying and leave the test passing for the wrong reason.
TR
## Sunucuda çizilen tarihler — UTC ile biçimlendir, asla sunucunun yerel saat dilimiyle
Bir ekran sunucuda çizildiği andan itibaren, her tarih sunucuda ve tarayıcıda **aynı metni** üretmeli.
Üretmiyorsa React hidrasyon sırasında metni yeniden yazar — ikinci bir parlama ve genelde önce yanlış
bir değerin gösterilmesi.
- **Sadece tarih kolonları (Postgres `DATE`) UTC gece yarısı olarak döner.** O anı negatif ofsetli bir saat
diliminde biçimlendirmek onu bir gün geri alır. Linux makinesi `America/New_York` ile çalışırken kullanıcılar
İstanbul'daydı, yani `2026-07-26` olan son çalışma günü sunucuda **25 Temmuz 2026** olarak çizildi ve hidrasyonla
sessizce **26 Temmuz 2026**'ya düzeltildi.
- **Saklanan her tarih için `toLocaleDateString` / `Intl.DateTimeFormat` çağrısına her zaman açık bir
`timeZone: 'UTC'` geç**, her çağrı yerinde değil **tek bir ortak yardımcıdan** (HR Offboarding:
`components/offboarding/format.ts` içindeki `formatDateUTC()`; `lib/mail/templates.ts` bunu zaten yapıyordu,
e-postaların doğru, sayfanın yanlış olmasının sebebi buydu).
- **Zaman damgaları da buna ihtiyaç duyar** — `22:30 UTC`, New York'ta ve İstanbul'da farklı takvim günleridir.
- Yerel olarak türetilmiş tarihler, **aynı saat diliminde ayrıştırılıp biçimlendirildiğinde** sorunsuzdur: bir
`YYYY-MM-DD` değerini **yerel öğlen** olarak ayrıştırmak (`T12:00:00` ekleyerek) ve sonra yerel olarak
biçimlendirmek her zaman amaçlanan günü verir.
- **Kendi saat diliminde değil, sunucunun saat diliminde test et.** Test dosyasında herhangi bir tarih işleminden
önce `process.env.TZ = 'America/New_York'` ayarla ve tuzağın kendisini doğrula (orada yerel biçimlendirmenin
yanlış günü verdiğini) ki taklit edilen saat dilimi sessizce uygulanmayı bırakıp testi yanlış sebeple geçirmesin.
The rubber stamp — a verdict on a record, never a data field
A stamp is the treatment used for the status of a whole record on a document-like surface: rotated a few degrees, uppercase…
EN
## The rubber stamp — a verdict on a record, never a data field
A **stamp** is the treatment used for the status of a whole record on a document-like surface: rotated
a few degrees, uppercase letter-spaced mono, outlined in `currentColor`, and with **no background
fill**. It reads as something a person pressed onto the page. Reference implementation: `.stamp` in
`ai_apps/AICC Security Hub/app/globals.css`, on the finding cards of the InfoSec hub.
**The four things that make it a stamp.** Drop any one of them and it becomes a chip:
1. **A rotation of a few degrees** (`-7deg` there) — the whole point. It is what says "pressed by hand".
2. **A heavy outline in `currentColor`** (`2.5px`), so the frame takes its colour from the text and one
class sets both.
3. **No background fill.** A filled version sits *among* the pills on the card instead of *on* the card.
This is the property that gets lost first when somebody restyles it.
4. **Uppercase mono with real letter-spacing** (`0.1em`), at a small size.
### Use it only when all three of these hold
- **It is a verdict on the WHOLE record**, not a property of one field or row — "Remediated",
"Question back", "Approved", "Superseded", "Retired". If the value belongs to a column, it is a chip.
- **The surface is document-like** — a paper-ish card, a report, a record someone reads top to bottom.
On dashboard chrome it reads as costume; a dashboard uses [`design-dashboard.md`](./design-dashboard.md)'s own
component classes.
- **There is exactly ONE per card**, at its edge. Two stamps on one thing is a costume, not a verdict.
### Do NOT use it for
- **Rows in a table or list.** Rotated elements repeating down a column cannot be scanned and read as
broken rendering. Status in a table is a chip, aligned with its column.
- **A metric or KPI's state.** A number's status is data; nobody stamps a number.
- **Anything clickable.** A stamp is an assertion, not a control — and a control has to look like one
(see *"Clickable affordance"* and *"Interactive cells look interactive"* below). If it filters,
selects or navigates, it is not a stamp.
- **Transient state** — saving, loading, unsaved changes. A stamp implies finality; use the button's own
acknowledgement instead.
- **Severity or priority that came from the data** (`Criticality: High`) — that is a chip, and on the
hub's own cards it deliberately IS one, sitting below the title while the stamp sits above it.
- **A long label.** Uppercase mono with letter-spacing stops reading as a stamp past roughly three
short words. If the verdict needs a sentence, it is not a stamp.
### Mechanics that are not optional
- **Reserve the space it overlaps.** It is absolutely positioned, so the title beside it needs padding
or the two collide at the first awkward width.
- **Flatten it when the column gets narrow.** Under ~620px make it `position: static`, inline-block,
no rotation, and give the reserved padding back. A rotated absolute element in a narrow column
overlaps the text under it.
- **The label carries the meaning, not the angle or the colour.** Colour-blind and zoomed-in readers get
the same word.
- **Write it as real CSS, not utilities.** A rotation, a `currentColor` border and a responsive
flattening are values a utility framework can only express as unreadable arbitrary strings, and the
exact values are the design.
⚠️ It survives restyling badly. On the InfoSec hub the page was rebuilt from its content and the stamp
came back as a filled rounded pill — every word correct, the whole character gone, and it took the
owner noticing to catch it. When you touch one, check the four properties above by name.
TR
## Kaşe — bir kaydın üstündeki hüküm, asla bir veri alanı değil
**Kaşe**, doküman benzeri bir yüzeyde bir kaydın tamamının durumu için kullanılan biçimdir: birkaç derece
döndürülmüş, harf aralığı açılmış büyük harf mono, `currentColor` ile çerçevelenmiş ve **arka plan dolgusu
olmayan**. Sayfaya biri tarafından basılmış gibi okunur. Referans uygulama: `ai_apps/AICC Security Hub/app/globals.css`
içindeki `.stamp`, InfoSec hub'ının bulgu kartlarında.
**Onu kaşe yapan dört şey.** Birini bile düşürürsen chip'e dönüşür:
1. **Birkaç derecelik döndürme** (orada `-7deg`) — bütün mesele bu. "Elle basıldı" diyen şey odur.
2. **`currentColor` ile kalın bir çerçeve** (`2.5px`), böylece çerçeve rengini metinden alır ve tek bir sınıf
ikisini de ayarlar.
3. **Arka plan dolgusu yok.** Dolgulu bir sürüm kartın *üstünde* değil, karttaki hapların *arasında* durur.
Biri yeniden stillendirdiğinde ilk kaybolan özellik budur.
4. **Gerçek harf aralığıyla büyük harf mono** (`0.1em`), küçük bir boyutta.
### Sadece şu üçü birden doğruyken kullan
- **KAYDIN TAMAMI hakkında bir hüküm olmalı**, bir alanın ya da satırın özelliği değil — "Remediated",
"Question back", "Approved", "Superseded", "Retired". Değer bir kolona aitse, o bir chip'tir.
- **Yüzey doküman benzeri olmalı** — kâğıt hissi veren bir kart, bir rapor, birinin baştan sona okuduğu bir kayıt.
Dashboard arayüzünde kostüm gibi okunur; bir dashboard [`design-dashboard.md`](./design-dashboard.md)'nin kendi
bileşen sınıflarını kullanır.
- **Kart başına tam olarak BİR tane olmalı**, kenarında. Tek bir şeyde iki kaşe hüküm değil kostümdür.
### Şunlar için KULLANMA
- **Bir tablodaki ya da listedeki satırlar.** Bir kolonda tekrar eden döndürülmüş öğeler taranamaz ve bozuk
çizim gibi okunur. Tablodaki durum bir chip'tir, kolonuyla hizalı.
- **Bir metriğin ya da KPI'ın durumu.** Bir sayının durumu veridir; kimse sayıya kaşe basmaz.
- **Tıklanabilir hiçbir şey.** Kaşe bir iddiadır, bir kontrol değil — ve bir kontrolün kontrol gibi görünmesi
gerekir (aşağıdaki *"Tıklanabilirlik ipucu"* ve *"Etkileşimli hücreler etkileşimli görünür"* kurallarına bak).
Filtreliyor, seçiyor ya da yönlendiriyorsa kaşe değildir.
- **Geçici durum** — kaydediliyor, yükleniyor, kaydedilmemiş değişiklikler. Kaşe kesinlik ima eder; onun yerine
butonun kendi onayını kullan.
- **Veriden gelen önem ya da öncelik** (`Criticality: High`) — o bir chip'tir ve hub'ın kendi kartlarında bilerek
chip'tir, başlığın altında durur, kaşe ise üstünde.
- **Uzun bir etiket.** Harf aralığı açılmış büyük harf mono, kabaca üç kısa kelimeden sonra kaşe gibi okunmayı
bırakır. Hüküm bir cümle gerektiriyorsa kaşe değildir.
### İsteğe bağlı olmayan mekanikler
- **Üstüne bindiği yeri ayır.** Mutlak konumlandırılmıştır, yani yanındaki başlığın boşluğa ihtiyacı vardır,
yoksa ilk tuhaf genişlikte çakışırlar.
- **Kolon daralınca düzleştir.** ~620px altında onu `position: static`, inline-block, döndürmesiz yap ve
ayrılmış boşluğu geri ver. Dar bir kolondaki döndürülmüş mutlak öğe altındaki metinle çakışır.
- **Anlamı açı ya da renk değil etiket taşır.** Renk körü ve yakınlaştırılmış okuyan aynı kelimeyi alır.
- **Utility değil, gerçek CSS olarak yaz.** Bir döndürme, bir `currentColor` kenarlığı ve duyarlı bir düzleştirme,
bir utility çerçevesinin ancak okunamaz keyfi dizgelerle ifade edebileceği değerlerdir ve tasarımın kendisi tam
olarak o değerlerdir.
⚠️ Yeniden stillendirmeden kötü çıkar. InfoSec hub'ında sayfa içeriğinden yeniden kuruldu ve kaşe dolgulu, yuvarlak
bir hap olarak geri geldi — her kelime doğru, bütün karakter kayıp; ve bunu yakalamak için sahibinin fark etmesi
gerekti. Birine dokunduğunda yukarıdaki dört özelliği tek tek adıyla kontrol et.
Comment threads — see comments.md
A box where people write something onto a record that other people read — a question-back thread, a review note, an approval…
EN
## Comment threads — see `comments.md`
A box where people write something onto a record that other people read — a question-back thread, a
review note, an approval remark — has its own set of rules, and most of them are counter-intuitive
enough to have been learned by getting them wrong:
the typed text must never be nowhere while the post is in flight, the timestamp belongs to the
reader's clock and not the server's, and editing belongs to the author while deleting belongs to the
author or an admin. All of it, with the incidents that produced it, is in
`comments.md`. The rules in this file that it leans on — auto-growing textareas below,
"Every button acknowledges the click", "Side-by-side items share a size", and "Person identifiers —
title-case the dot-separated name" — stay defined here; `comments.md` says how they combine.
TR
## Yorum başlıkları — bkz. `comments.md`
İnsanların bir kaydın üstüne, başkalarının okuyacağı bir şey yazdığı kutu — geri soru başlığı, inceleme notu,
onay şerhi — kendi kural setine sahiptir ve çoğu, yanlış yapılarak öğrenilecek kadar sezgiye aykırıdır:
yazılan metin, gönderi yoldayken asla hiçbir yerde olmamalı, zaman damgası sunucunun değil okuyucunun saatine
aittir ve düzenleme yazara, silme ise yazara ya da bir yöneticiye aittir. Hepsi, onları doğuran olaylarla birlikte
`comments.md` içindedir. Bu dosyanın dayandığı kurallar — aşağıdaki kendiliğinden büyüyen textarea'lar,
"Her buton tıklamayı onaylar", "Yan yana duran öğeler aynı boyutu paylaşır" ve "Kişi tanımlayıcıları — noktayla
ayrılmış adı Title Case yap" — burada tanımlı kalır; `comments.md` bunların nasıl birleştiğini anlatır.
Auto-growing textareas
MOVED to inline-editing.md — it owns editing a value in place, including how the field sizes itself.
EN
## Auto-growing textareas
MOVED to [`inline-editing.md`](./inline-editing.md) — it owns editing a value in place, including how
the field sizes itself.
TR
## Kendiliğinden büyüyen textarea'lar
[`inline-editing.md`](./inline-editing.md) dosyasına TAŞINDI — bir değeri yerinde düzenlemek, alanın kendini
nasıl boyutlandırdığı dahil, o dosyaya aittir.
Inline validation — highlight the field, don't just message
Form validation must use inline, field-level validation, not a single generic error line detached from the inputs.
EN
## Inline validation — highlight the field, don't just message
Form validation must use **inline, field-level validation**, not a single generic error line detached
from the inputs. When a field is invalid: (1) show a **specific inline message** next to that field,
(2) put the field into a visible **error state** — red border / `aria-invalid="true"` — so the eye lands
on it, and (3) **focus (and scroll to) the first invalid field** on submit. Clear a field's error state
as soon as the user corrects it. Never rely on a lone toast or a detached "Please fill X" banner when the
offending field is on screen.
TR
## Satır içi doğrulama — alanı işaretle, sadece mesaj verme
Form doğrulaması, input'lardan kopuk tek bir genel hata satırı değil, **satır içi ve alan bazlı doğrulama**
kullanmalı. Bir alan geçersizse: (1) o alanın yanında **özel bir satır içi mesaj** göster, (2) alanı görünür bir
**hata durumuna** sok — kırmızı kenarlık / `aria-invalid="true"` — ki göz oraya insin, ve (3) gönderme anında
**ilk geçersiz alana odaklan (ve oraya kaydır)**. Kullanıcı düzelttiği anda o alanın hata durumunu temizle. Suçlu
alan ekrandayken tek başına bir toast'a ya da kopuk bir "Lütfen X'i doldurun" bandına asla güvenme.
Constrain inputs at the source — prevent invalid values, don't just validate after
When an input has a knowable valid range or set, constrain the control so an invalid value can't be entered in the first place…
EN
## Constrain inputs at the source — prevent invalid values, don't just validate after
When an input has a knowable valid range or set, **constrain the control so an invalid value can't be entered in the first place** — don't accept a bad value and then show a validation error. Date pickers set `min`/`max` (e.g. a "must be in the future" field sets `min` to **tomorrow** so today and past dates aren't even selectable in the calendar — never ship an after-the-fact "date must be in the future" message as the primary guard); number inputs set `min`/`max`/`step`; choice fields list only valid options. Compute date bounds in **local time** (a `min` built from `toISOString()` is UTC and can be off by a day). The inline validation above is only the **fallback** for values a user can still type manually, not the first line of defense.
TR
## Girdiyi kaynağında sınırla — geçersiz değeri sonradan doğrulama, en baştan engelle
Bir input'un bilinebilir bir geçerli aralığı veya kümesi varsa, **kontrolü öyle sınırla ki geçersiz bir değer en baştan girilemesin** — kötü bir değeri kabul edip sonra doğrulama hatası gösterme. Tarih seçiciler `min`/`max` ayarlar (ör. "gelecekte olmalı" diyen bir alan `min` değerini **yarına** ayarlar ki bugün ve geçmiş tarihler takvimde seçilebilir bile olmasın — birincil koruma olarak asla sonradan gelen bir "tarih gelecekte olmalı" mesajı çıkarma); sayı input'ları `min`/`max`/`step` ayarlar; seçim alanları sadece geçerli seçenekleri listeler. Tarih sınırlarını **yerel saatle** hesapla (`toISOString()` ile kurulmuş bir `min` UTC'dir ve bir gün kayabilir). Yukarıdaki satır içi doğrulama, kullanıcının hâlâ elle yazabildiği değerler için yalnızca **yedek**tir, ilk savunma hattı değil.
Action buttons must self-commit — no hidden second save
When a UI exposes an explicit action button (Add, Remove, Delete, Toggle, Reorder) on a list or collection, that action must…
EN
## Action buttons must self-commit — no hidden second save
When a UI exposes an explicit action button (Add, Remove, Delete, Toggle, Reorder) on a list or
collection, that action must **persist immediately**. Never require the user to press a separate
"Save" afterward to make an already-"committed" action stick — a user who clicks **Add** and sees the
item appear will reasonably assume it is saved and may leave the page, silently losing the change.
Prefer per-action autosave (Add/Remove persist on click; inline text edits persist on blur), with a
brief inline confirmation. If immediate persistence is truly impossible, the item must not appear
committed until saved (e.g. keep it in a pending "draft" affordance), never shown as done-but-unsaved.
TR
## Aksiyon butonları kendi kendini kaydetmeli — gizli ikinci bir kayıt olmaz
Bir arayüz, bir liste veya koleksiyon üzerinde açık bir aksiyon butonu (Ekle, Çıkar, Sil, Değiştir, Sırala)
sunuyorsa, o aksiyon **hemen kalıcı olmalı**. Zaten "kesinleşmiş" bir aksiyonun tutması için kullanıcıdan
sonradan ayrı bir "Kaydet"e basmasını asla isteme — **Ekle**'ye basıp öğenin belirdiğini gören bir kullanıcı
makul olarak kaydedildiğini varsayar, sayfadan ayrılabilir ve değişikliği sessizce kaybeder. Aksiyon başına
otomatik kaydı tercih et (Ekle/Çıkar tıklamada kalıcı olur; satır içi metin düzenlemeleri odak kaybında kalıcı
olur), kısa bir satır içi onayla birlikte. Anında kalıcılık gerçekten imkânsızsa, öğe kaydedilene kadar kesinleşmiş
görünmemeli (ör. bekleyen bir "taslak" ipucunda tut), asla olmuş-ama-kaydedilmemiş olarak gösterilmemeli.
Rich-text / content editors — insert at the caret
In any WYSIWYG or content editor, insert every element (table, image, block, etc.) at the current caret position, never at the…
EN
## Rich-text / content editors — insert at the caret
In any WYSIWYG or content editor, **insert every element (table, image, block, etc.) at the
current caret position**, never at the start or end by default. Preserve the user's selection when
a toolbar action or dialog steals focus (save the `Range`, restore it before inserting; use
`onMouseDown` `preventDefault` on toolbar buttons so clicking them doesn't move the caret).
When an inserted element needs a size or shape, **ask for it upfront in a single step** (e.g. a
table's dimensions as "rows × columns") and draw it to that spec — don't insert a default and make
the user fix it afterward.
TR
## Zengin metin / içerik editörleri — imlecin olduğu yere ekle
Her WYSIWYG veya içerik editöründe, **her öğeyi (tablo, görsel, blok vb.) mevcut imleç konumuna ekle**,
varsayılan olarak asla başa ya da sona değil. Bir araç çubuğu aksiyonu veya diyalog odağı çaldığında kullanıcının
seçimini koru (`Range`'i sakla, eklemeden önce geri yükle; araç çubuğu butonlarında `onMouseDown` üzerinde
`preventDefault` kullan ki onlara tıklamak imleci oynatmasın).
Eklenen bir öğenin boyuta ya da biçime ihtiyacı varsa, **bunu tek adımda baştan sor** (ör. bir tablonun boyutlarını
"satır × kolon" olarak) ve onu o şartnameye göre çiz — varsayılan bir şey ekleyip kullanıcıya sonradan düzelttirme.
Data Entry Tables — Lookup / Reference Screens
These rules apply to any admin screen that manages a lookup/reference table (e.g.
EN
## Data Entry Tables — Lookup / Reference Screens
These rules apply to any admin screen that manages a lookup/reference table (e.g. domain factors, pay ranges, FX rates, CL assignments).
### Inline row editing
MOVED to [`inline-editing.md`](./inline-editing.md) — the row-edit rules live there with the rest of
the in-place editing rules.
### Add new row
- A single **+ Add** button in the page header opens a new blank row at the top of the table (inline, not a modal). The same ✓ / ✗ button pattern applies.
- Auto-focus the first input in the new row on open.
- On successful save, append the new row to the table and reset the add form; on failure, show a toast with the API error message.
### Delete confirmation
- Delete must require a **two-step inline confirmation**: the first click replaces the row's action icons with the confirmation. Never use a modal or browser `confirm()` for row deletion.
- **The confirming button names the action** — "Yes, Delete", not a bare "Yes", which does not say what it agrees to when read on its own. Put the neutral way out (Cancel) first and leave it unstyled; the destructive button carries the danger colour.
### Numeric value constraints
- Define valid ranges for every numeric input at both the API layer and the frontend (`min`/`max` on the input, validation before submit).
- Display values as multipliers (e.g. `1.10x`), never as raw percentages (e.g. `110%`), unless the domain explicitly uses percentages.
- Clearly document the expected format in placeholder text and inline help copy (e.g. "e.g. 1.20 for 120%").
### Paste from Excel (bulk import)
Use this pattern whenever a table contains many rows that users may maintain in Excel:
1. **"Paste from Excel" button** in the page header (secondary style, `ClipboardPaste` icon). Opens a modal.
2. **Modal — paste screen:**
- Header explains: only existing records will be updated (no new rows added), and the expected numeric format.
- A `textarea` (monospace font, `autoFocus`) accepts raw tab-separated clipboard data from Excel.
- As the user types/pastes, **parse in real-time** and show a live preview table below the textarea with columns: identifier, value columns, and a **Status** column.
3. **Status column per row (real-time validation):**
- **Red** — hard error (unknown identifier, value out of range, parse failure): row will be skipped.
- **Yellow** — fuzzy match warning (identifier looks like a known one but isn't exact, e.g. "Did you mean: X?"): row is blocked until corrected.
- **Amber "Override"** — valid row, will update an existing record.
4. **Fuzzy match logic:** for each identifier in the paste, check against the existing list. If no exact match but a fuzzy match (≥ 80% character-sequence score or substring), show the suggestion and block import for that row.
5. **Footer summary:** "N valid row(s), M will be skipped" before the Import button. Disable Import if 0 valid rows.
6. **Import button** shows a spinner while in-flight. On success, switch modal to a **Result screen**:
- Two summary cards: green "Imported" count, red "Skipped" count.
- A table listing each skipped row with its reason.
- A single "Done" button closes the modal.
7. Reload the table data after a successful import (without full page refresh).
### Skeleton loading
Show skeleton rows (matching table column layout) while the initial data fetch is in progress. Never show an empty table or a spinner in isolation.
### Header button layout
Page header: title + description on the left; action buttons (Paste from Excel, Add) stacked or grouped on the right.
TR
## Veri Giriş Tabloları — Lookup / Referans Ekranları
Bu kurallar, bir lookup/referans tablosunu yöneten her yönetim ekranı için geçerlidir (ör. alan katsayıları, ücret bantları, döviz kurları, CL atamaları).
### Satır içi satır düzenleme
[`inline-editing.md`](./inline-editing.md) dosyasına TAŞINDI — satır düzenleme kuralları, yerinde düzenlemeye dair
diğer kurallarla birlikte orada yaşar.
### Yeni satır ekleme
- Sayfa başlığındaki tek bir **+ Add** butonu, tablonun en üstünde yeni ve boş bir satır açar (satır içi, modal değil). Aynı ✓ / ✗ buton kalıbı geçerlidir.
- Açılışta yeni satırdaki ilk input'a otomatik odaklan.
- Başarılı kayıtta yeni satırı tabloya ekle ve ekleme formunu sıfırla; hatada API hata mesajıyla bir toast göster.
### Silme onayı
- Silme **iki adımlı satır içi onay** gerektirmeli: ilk tıklama satırın aksiyon simgelerini onayla değiştirir. Satır silmek için asla modal ya da tarayıcının `confirm()` çağrısını kullanma.
- **Onaylayan buton eylemi adlandırır** — tek başına okunduğunda neyi kabul ettiğini söylemeyen yalın bir "Evet" değil, "Yes, Delete". Nötr çıkışı (Cancel) öne koy ve stilsiz bırak; yıkıcı buton tehlike rengini taşır.
### Sayısal değer sınırları
- Her sayısal input için geçerli aralıkları hem API katmanında hem frontend'de tanımla (input'ta `min`/`max`, göndermeden önce doğrulama).
- Değerleri çarpan olarak göster (ör. `1.10x`), ham yüzde olarak değil (ör. `110%`), alan açıkça yüzde kullanmıyorsa.
- Beklenen biçimi placeholder metninde ve satır içi yardım metninde açıkça belgele (ör. "ör. %120 için 1.20").
### Excel'den yapıştırma (toplu içe aktarma)
Bu kalıbı, kullanıcıların Excel'de tutabileceği çok satırlı her tabloda kullan:
1. Sayfa başlığında **"Paste from Excel" butonu** (ikincil stil, `ClipboardPaste` simgesi). Bir modal açar.
2. **Modal — yapıştırma ekranı:**
- Başlık şunu açıklar: sadece mevcut kayıtlar güncellenecek (yeni satır eklenmeyecek) ve beklenen sayı biçimi.
- Bir `textarea` (mono font, `autoFocus`) Excel'den gelen ham, sekmeyle ayrılmış pano verisini kabul eder.
- Kullanıcı yazdıkça/yapıştırdıkça **gerçek zamanlı ayrıştır** ve textarea'nın altında canlı bir önizleme tablosu göster; kolonlar: tanımlayıcı, değer kolonları ve bir **Status** kolonu.
3. **Satır başına Status kolonu (gerçek zamanlı doğrulama):**
- **Kırmızı** — sert hata (bilinmeyen tanımlayıcı, aralık dışı değer, ayrıştırma hatası): satır atlanacak.
- **Sarı** — bulanık eşleşme uyarısı (tanımlayıcı bilinen birine benziyor ama birebir değil, ör. "Did you mean: X?"): satır düzeltilene kadar engellenir.
- **Kehribar "Override"** — geçerli satır, mevcut bir kaydı güncelleyecek.
4. **Bulanık eşleşme mantığı:** yapıştırmadaki her tanımlayıcıyı mevcut listeye karşı kontrol et. Birebir eşleşme yoksa ama bulanık eşleşme varsa (≥ %80 karakter dizisi skoru ya da alt dizge), öneriyi göster ve o satırın içe aktarımını engelle.
5. **Alt bilgi özeti:** Import butonundan önce "N geçerli satır, M atlanacak". 0 geçerli satır varsa Import'u devre dışı bırak.
6. **Import butonu** istek sürerken spinner gösterir. Başarıda modal'ı bir **Sonuç ekranına** çevir:
- İki özet kartı: yeşil "Imported" sayısı, kırmızı "Skipped" sayısı.
- Atlanan her satırı sebebiyle listeleyen bir tablo.
- Modal'ı kapatan tek bir "Done" butonu.
7. Başarılı bir içe aktarmadan sonra tablo verisini yeniden yükle (sayfayı tümden yenilemeden).
### Skeleton loading
İlk veri getirme sürerken skeleton satırları göster (tablo kolon yerleşimiyle eşleşen). Asla boş bir tablo ya da tek başına bir spinner gösterme.
### Başlık buton yerleşimi
Sayfa başlığı: solda başlık + açıklama; sağda aksiyon butonları (Paste from Excel, Add) üst üste ya da gruplanmış.
Percentage Inputs — Whole-Number Entry Only
Any input that accepts a percentage value (tax rates, growth rates, discount rates, etc.) must: 1.
EN
## Percentage Inputs — Whole-Number Entry Only
Any input that accepts a percentage value (tax rates, growth rates, discount rates, etc.) must:
1. **Accept whole numbers only** — e.g. enter `17` for 17%, never `0.17`.
2. **Detect decimal entry** — if the user types a value `> 0 && < 1`, immediately show a red border on the input and an inline warning: _"Enter as whole number (e.g. 17, not 0.17)"_.
3. **Block save** while any percentage field is in decimal-entry state — disable or no-op the save/confirm action.
4. **Store internally as decimals** — divide by 100 before writing to the API/database; multiply by 100 when displaying back to the user.
5. **Show the `%` unit** in the display value (e.g. `17.0%`) so the expected format is unambiguous.
TR
## Yüzde Input'ları — Sadece Tam Sayı Girişi
Yüzde değeri kabul eden her input (vergi oranı, büyüme oranı, iskonto oranı vb.) şunları yapmalı:
1. **Sadece tam sayı kabul et** — ör. %17 için `17` gir, asla `0.17`.
2. **Ondalık girişi yakala** — kullanıcı `> 0 && < 1` bir değer yazarsa, input'ta hemen kırmızı kenarlık ve satır içi bir uyarı göster: _"Tam sayı olarak girin (ör. 17, 0.17 değil)"_.
3. Herhangi bir yüzde alanı ondalık giriş durumundayken **kaydı engelle** — kaydet/onayla eylemini devre dışı bırak ya da hiçbir şey yaptırma.
4. **İçeride ondalık olarak sakla** — API/veritabanına yazmadan önce 100'e böl; kullanıcıya geri gösterirken 100 ile çarp.
5. Gösterilen değerde **`%` birimini göster** (ör. `17.0%`) ki beklenen biçim belirsiz kalmasın.
Save buttons — confirm, then reset on new edits
A save/submit button must confirm success on the button itself: after a successful save, switch it to a distinct "Saved" state…
EN
## Save buttons — confirm, then reset on new edits
A save/submit button must **confirm success on the button itself**: after a successful save, switch
it to a distinct "Saved" state (e.g. a checkmark + "Saved", success styling, non-interactive) so
the user sees the action landed — don't rely on a toast alone. As soon as the user changes the
underlying content again, **revert the button to its default actionable state** ("Save …"). Keep
the loading state (spinner) during the in-flight request as a third, separate state.
TR
## Kaydet butonları — önce onayla, yeni düzenlemede sıfırla
Bir kaydet/gönder butonu başarıyı **butonun kendisinde teyit etmeli**: başarılı bir kayıttan sonra onu ayrı
bir "Kaydedildi" durumuna geçir (ör. bir onay işareti + "Saved", başarı stili, etkileşimsiz) ki kullanıcı
eylemin tuttuğunu görsün — tek başına bir toast'a güvenme. Kullanıcı alttaki içeriği tekrar değiştirdiği anda
**butonu varsayılan, eyleme hazır durumuna döndür** ("Save …"). İstek sürerken gösterilen yükleme durumunu
(spinner) üçüncü ve ayrı bir durum olarak koru.
Email Sending
Package: nodemailer
EN
## Email Sending
**Package:** `nodemailer`
Create a single reusable helper at `lib/mail/sendMail.ts` — never inline transporter setup in API routes:
```ts
import nodemailer from 'nodemailer';
export async function sendMail(opts: { to: string[]; subject: string; bodyHtml: string }) {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT ?? '25');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const from = process.env.SMTP_FROM;
if (!host) throw new Error('SMTP not configured (SMTP_HOST missing)');
const transporter = nodemailer.createTransport({
host, port, secure: false,
tls: { rejectUnauthorized: false },
...(user && pass ? { auth: { user, pass } } : {}),
} as Parameters<typeof nodemailer.createTransport>[0]);
await transporter.sendMail({ from, to: opts.to.join(', '), subject: opts.subject, html: opts.bodyHtml });
}
```
- SMTP credentials always come from env vars (`SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM`) — see `~/.env` for shared Afiniti relay values.
- `secure: false` + `rejectUnauthorized: false` — Afiniti internal relay does not use TLS.
- No-auth mode: if `SMTP_USER`/`SMTP_PASS` are absent, auth block is omitted entirely (relay accepts unauthenticated).
- API routes that send email must call `sendMail()` — never duplicate transporter setup inline.
- For admin CC: pass `SMTP_ADMIN_EMAIL` env var as an additional recipient in the `to` array or as a separate `cc` field.
TR
## E-posta Gönderimi
**Paket:** `nodemailer`
`lib/mail/sendMail.ts` içinde tek ve yeniden kullanılabilir bir yardımcı oluştur — transporter kurulumunu asla API rotalarının içine yazma:
```ts
import nodemailer from 'nodemailer';
export async function sendMail(opts: { to: string[]; subject: string; bodyHtml: string }) {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT ?? '25');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const from = process.env.SMTP_FROM;
if (!host) throw new Error('SMTP not configured (SMTP_HOST missing)');
const transporter = nodemailer.createTransport({
host, port, secure: false,
tls: { rejectUnauthorized: false },
...(user && pass ? { auth: { user, pass } } : {}),
} as Parameters<typeof nodemailer.createTransport>[0]);
await transporter.sendMail({ from, to: opts.to.join(', '), subject: opts.subject, html: opts.bodyHtml });
}
```
- SMTP kimlik bilgileri her zaman ortam değişkenlerinden gelir (`SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM`) — ortak Afiniti relay değerleri için `~/.env` dosyasına bak.
- `secure: false` + `rejectUnauthorized: false` — Afiniti iç relay'i TLS kullanmıyor.
- Kimlik doğrulamasız mod: `SMTP_USER`/`SMTP_PASS` yoksa auth bloğu tamamen çıkarılır (relay kimlik doğrulamasız kabul eder).
- E-posta gönderen API rotaları `sendMail()` çağırmalı — transporter kurulumunu asla satır içinde tekrarlama.
- Yönetici CC'si için: `SMTP_ADMIN_EMAIL` ortam değişkenini `to` dizisine ek alıcı olarak ya da ayrı bir `cc` alanı olarak geç.
Excel Export
Package: exceljs (always dynamic import on the client — never bundle it statically)
EN
## Excel Export
**Package:** `exceljs` (always dynamic import on the client — never bundle it statically)
Standard client-side export pattern used across all data tables:
```ts
const exportExcel = async () => {
setExporting(true);
const { default: ExcelJS } = await import('exceljs');
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Sheet Name');
// Header row — bold + filled background
ws.addRow(['Col A', 'Col B', 'Col C']);
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE5E7EB' } };
// Data rows
for (const row of data) {
ws.addRow([row.a, row.b, row.c]);
}
// Trigger download
const buf = await wb.xlsx.writeBuffer();
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `Export_${dateStr}.xlsx`; a.click();
URL.revokeObjectURL(url);
setExporting(false);
};
```
- **Dynamic import** (`await import('exceljs')`) — mandatory to keep it out of the client bundle.
- **Filename convention:** `<ScreenName>_<etlDate>.xlsx` — always include the date so users can distinguish files.
- **Export button:** secondary style, `Download` icon (lucide), spinner (`Loader2 animate-spin`) while in-flight, disabled during export. Label: "Export" idle → "Exporting…" in-flight.
- **Header row styling:** bold font + light grey fill (`FFE5E7EB`) — consistent across all exports.
- **Number columns:** set `ws.getColumn(n).numFmt` for currency/percentage cells so Excel renders them correctly.
- Export operates on the **currently filtered/sorted** dataset, not the raw full list.
TR
## Excel'e Aktarma
**Paket:** `exceljs` (istemcide her zaman dinamik import — asla statik olarak paketleme)
Bütün veri tablolarında kullanılan standart istemci tarafı aktarma kalıbı:
```ts
const exportExcel = async () => {
setExporting(true);
const { default: ExcelJS } = await import('exceljs');
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Sheet Name');
// Header row — bold + filled background
ws.addRow(['Col A', 'Col B', 'Col C']);
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE5E7EB' } };
// Data rows
for (const row of data) {
ws.addRow([row.a, row.b, row.c]);
}
// Trigger download
const buf = await wb.xlsx.writeBuffer();
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `Export_${dateStr}.xlsx`; a.click();
URL.revokeObjectURL(url);
setExporting(false);
};
```
- **Dinamik import** (`await import('exceljs')`) — istemci paketinin dışında tutmak için zorunlu.
- **Dosya adı kuralı:** `<ScreenName>_<etlDate>.xlsx` — kullanıcılar dosyaları ayırt edebilsin diye tarihi her zaman ekle.
- **Aktarma butonu:** ikincil stil, `Download` simgesi (lucide), istek sürerken spinner (`Loader2 animate-spin`), aktarma sırasında devre dışı. Etiket: beklemede "Export" → istek sürerken "Exporting…".
- **Başlık satırı stili:** kalın font + açık gri dolgu (`FFE5E7EB`) — bütün aktarmalarda tutarlı.
- **Sayı kolonları:** para/yüzde hücreleri için `ws.getColumn(n).numFmt` ayarla ki Excel onları doğru göstersin.
- Aktarma **o an filtrelenmiş/sıralanmış** veri kümesi üzerinde çalışır, ham tam liste üzerinde değil.
Downloads that open in an external app — show a transient "open with" hint
When a download button serves a file whose real destination is another APPLICATION (a .msg for Outlook, an .ics for the…
EN
## Downloads that open in an external app — show a transient "open with" hint
When a download button serves a file whose real destination is another APPLICATION (a `.msg` for
Outlook, an `.ics` for the calendar, a `.pbix` for Power BI — anything the browser itself cannot
render), the click must also show a **short-lived, generic hint** telling the user what to open it
with — because the browser may interrupt with an "open with / save" question the app never sees,
and a user who picks wrong blames the feature.
Mechanics (reference impl: Security Hub `MailList.tsx` "Download .msg"):
- A small toast, **bottom-center, auto-dismissing after ~6 s** (re-click resets the timer); dark
ground, white text, one sentence: *"Downloading — if your browser asks how to open the file,
choose **Outlook**."* Name the app in bold; keep the wording generic (it must stay true whether
or not the browser actually asks).
- The hint is fired from the anchor's `onClick` **without preventing navigation** — the download is
the anchor's own job and must not depend on any JS succeeding.
- `role="status"` so a screen reader announces it; never a blocking dialog and never a browser
popup (see *No Browser Popups*).
- The top navigation bar must not duplicate links that already appear as cards/sections
on their destination page (e.g. a Home screen that lists the process steps as cards).
Keep the top nav minimal — primary landmarks only (e.g. Home, a reference/docs entry,
Admin) — and let the in-page cards be the entry points for everything else.
- Provide **one global Back button in the top bar** (navigates through history), shown on
every page except the home/root. Do not scatter per-page "Back" links in page bodies —
the single bar Back is the only back affordance.
TR
## Harici bir uygulamada açılan indirmeler — geçici bir "şununla aç" ipucu göster
Bir indirme butonu, gerçek varış noktası başka bir UYGULAMA olan bir dosya veriyorsa (Outlook için `.msg`,
takvim için `.ics`, Power BI için `.pbix` — tarayıcının kendisinin çizemediği her şey), tıklama aynı zamanda
kullanıcıya onu neyle açacağını söyleyen **kısa ömürlü, genel bir ipucu** göstermeli — çünkü tarayıcı,
uygulamanın hiç göremediği bir "şununla aç / kaydet" sorusuyla araya girebilir ve yanlış seçen kullanıcı
suçu özelliğe atar.
Mekanik (referans uygulama: Security Hub `MailList.tsx` "Download .msg"):
- Küçük bir toast, **altta ortada, ~6 sn sonra kendiliğinden kapanan** (tekrar tıklamak süreyi sıfırlar); koyu
zemin, beyaz metin, tek cümle: *"İndiriliyor — tarayıcınız dosyayı nasıl açacağınızı sorarsa **Outlook**'u
seçin."* Uygulama adını kalın yaz; ifadeyi genel tut (tarayıcı sorsa da sormasa da doğru kalmalı).
- İpucu, bağlantının `onClick` olayından **yönlendirmeyi engellemeden** tetiklenir — indirme bağlantının kendi
işidir ve herhangi bir JS'in başarılı olmasına bağlı olmamalıdır.
- Ekran okuyucunun duyurması için `role="status"`; asla engelleyen bir diyalog ve asla bir tarayıcı popup'ı
olmamalı (bkz. *Tarayıcı Popup'ları Yok*).
- Üst gezinme barı, varış sayfalarında zaten kart/bölüm olarak görünen linkleri tekrarlamamalı
(ör. süreç adımlarını kart olarak listeleyen bir Ana Sayfa). Üst gezinmeyi asgaride tut — sadece
ana işaretler (ör. Ana Sayfa, bir referans/doküman girişi, Admin) — ve gerisinin giriş noktası
sayfa içindeki kartlar olsun.
- Üst barda **tek bir global Geri butonu** bulundur (geçmişte gezinir), ana sayfa/kök dışında her sayfada
görünsün. Sayfa gövdelerine sayfa başına "Geri" linkleri serpiştirme — tek bar Geri'si tek geri ipucudur.
Accordion / expandable cards — the whole card is the click target
When a card/box expands and collapses on click, the toggle must fire from every point inside the card's full bounding box,…
EN
## Accordion / expandable cards — the whole card is the click target
When a card/box expands and collapses on click, the toggle must fire from **every point inside
the card's full bounding box, without exception** — padding, edges, and corners included.
Two traps make naive implementations fail:
1. **Handler on an inner element.** Attaching the click handler to only an inner header/title
element excludes the card's own padding — clicks near the borders silently do nothing.
2. **Rounded corners.** With `border-radius`, browser hit-testing clips the rounded corner
area: a click 1–2px inside a corner lands on the *parent* element, not the card. Even a
handler on the card container itself misses these corner clicks.
**Required implementation — coordinate-based document handler:**
```js
document.addEventListener("click", function (e) {
// don't toggle when interacting with expanded content (links, buttons, text)
if (e.target.closest && e.target.closest(".card-body")) return;
// direct hit on the card (or anything inside it)
var card = e.target.closest ? e.target.closest(".card") : null;
// corner/edge fallback: any click inside a card's bounding rect counts
if (!card) {
document.querySelectorAll(".card").forEach(function (c) {
var r = c.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom) card = c;
});
}
if (card) card.classList.toggle("open");
});
```
Rules:
- One **document-level** listener; resolve the card via `closest()` first, then fall back to
a `getBoundingClientRect()` containment check so rounded corners still toggle.
- Clicks originating inside the **expanded content area** must never toggle — exempt it
explicitly (e.g. `.card-body`), so links, copy buttons, and text selection work normally.
- Interactive controls that happen to sit on the card surface (e.g. a bookmark icon) must
`stopPropagation()` or be exempted the same way.
- **Verify with a hit-test sweep** before delivery: for every card, programmatically click the
4 corners (1px inside), 4 edge midpoints, and the center — all 9 must toggle. Corner clicks
are the regression to watch for.
TR
## Akordeon / açılır kartlar — tıklama hedefi kartın tamamıdır
Bir kart/kutu tıklamayla açılıp kapanıyorsa, bu geçiş **kartın tam sınırlayıcı kutusu içindeki her noktadan,
istisnasız** tetiklenmeli — boşluklar, kenarlar ve köşeler dahil.
Saf uygulamaları iki tuzak düşürür:
1. **İşleyicinin iç bir öğede olması.** Tıklama işleyicisini yalnızca iç bir başlık/başlık öğesine bağlamak
kartın kendi boşluğunu dışarıda bırakır — kenarlara yakın tıklamalar sessizce hiçbir şey yapmaz.
2. **Yuvarlak köşeler.** `border-radius` ile tarayıcının isabet testi yuvarlak köşe alanını kırpar: köşenin
1–2px içine yapılan bir tıklama karta değil *üst* öğeye iner. Kart container'ının kendisindeki bir işleyici
bile bu köşe tıklamalarını kaçırır.
**Gereken uygulama — koordinat tabanlı document işleyicisi:**
```js
document.addEventListener("click", function (e) {
// don't toggle when interacting with expanded content (links, buttons, text)
if (e.target.closest && e.target.closest(".card-body")) return;
// direct hit on the card (or anything inside it)
var card = e.target.closest ? e.target.closest(".card") : null;
// corner/edge fallback: any click inside a card's bounding rect counts
if (!card) {
document.querySelectorAll(".card").forEach(function (c) {
var r = c.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom) card = c;
});
}
if (card) card.classList.toggle("open");
});
```
Kurallar:
- Tek bir **document seviyesinde** dinleyici; kartı önce `closest()` ile çöz, sonra bir
`getBoundingClientRect()` kapsama sınamasına düş ki yuvarlak köşeler de açıp kapasın.
- **Açılmış içerik alanının** içinden gelen tıklamalar asla açıp kapamamalı — onu açıkça muaf tut
(ör. `.card-body`), böylece linkler, kopyala butonları ve metin seçimi normal çalışır.
- Kart yüzeyinde duran etkileşimli kontroller (ör. bir yer imi simgesi) `stopPropagation()` çağırmalı ya da
aynı şekilde muaf tutulmalı.
- Teslimden önce **bir isabet testi taramasıyla doğrula**: her kart için 4 köşeye (1px içeriden), 4 kenar
ortasına ve merkeze programatik olarak tıkla — dokuzunun da açıp kapaması gerekir. Dikkat edilecek gerileme
köşe tıklamalarıdır.
Clickable affordance (discoverability)
Any element that navigates or triggers an action on click must look clickable — a user should never have to guess.
EN
## Clickable affordance (discoverability)
Any element that navigates or triggers an action on click **must look clickable** — a user
should never have to guess. Provide at least these cues on interactive rows/cards/tiles:
- `cursor-pointer`,
- a **hover state** (background/border/color change, with a short `transition-colors`), and
- for elements that navigate, a **directional cue** — e.g. a chevron (`ChevronRight`) at the
trailing edge. It may be revealed on hover (`opacity-0 → group-hover:opacity-100`) rather
than shown permanently, but the row itself must still have the pointer + hover cues so the
affordance is discoverable before hover.
Never ship a clickable element whose only affordance is the pointer cursor.
TR
## Tıklanabilirlik ipucu (keşfedilebilirlik)
Tıklandığında yönlendiren veya bir aksiyon tetikleyen her öğe **tıklanabilir görünmeli** — kullanıcı
asla tahmin etmek zorunda kalmamalı. Etkileşimli satır/kart/döşemelerde en azından şu ipuçlarını ver:
- `cursor-pointer`,
- bir **hover durumu** (arka plan/kenarlık/renk değişimi, kısa bir `transition-colors` ile) ve
- yönlendiren öğeler için bir **yön ipucu** — ör. sondaki kenarda bir şevron (`ChevronRight`). Bu,
kalıcı gösterilmek yerine hover'da ortaya çıkabilir (`opacity-0 → group-hover:opacity-100`), ama
satırın kendisi yine de işaretçi ve hover ipuçlarını taşımalı ki ipucu hover'dan önce keşfedilebilsin.
Tek ipucu işaretçi imleci olan tıklanabilir bir öğeyi asla çıkarma.
Copyable command blocks — one paste per box
A box with a copy button holds exactly one thing to paste, and the button copies exactly that.
EN
## Copyable command blocks — one paste per box
A box with a copy button holds exactly **one thing to paste**, and the button copies exactly that.
- **Sequential commands are separate boxes, numbered.** Examples and alternatives are separate boxes too, unnumbered. Never two commands in one box: they get pasted one at a time anyway.
- **Context lives outside the box**, as a caption above it ("in your normal terminal (PowerShell)", "inside Claude Code", "or just ask…"), even when the box holds a single command. Nothing inside a box is a comment or explanation: a trailing `→ note` becomes a caption beside the box, `a / b / c` alternatives become separate boxes.
- **A single prompt is one line**, soft-wrapped on screen, never hard-wrapped with line breaks that would paste as newlines.
- **Exceptions:** a template meant to be pasted whole (a CLAUDE.md rule snippet, a config file) stays one multi-line box and copies whole. A box that is prose or illustration, not something to paste, gets no copy button.
(2026-09-14, firatada.com: `npm install -g ui-ux-pro-max-cli` and `uipro init --ai claude --global` shared one box under a `#` comment line; copying took both commands plus the comment.)
TR
## Kopyalanabilir komut blokları — kutu başına tek yapıştırma
Kopyala düğmesi olan bir kutu tam olarak **tek bir yapıştırılacak şey** taşır ve düğme tam olarak onu kopyalar.
- **Ardışık komutlar ayrı kutulardır, numaralı.** Örnekler ve alternatifler de ayrı kutulardır, numarasız. Bir kutuda asla iki komut olmaz: zaten teker teker yapıştırılırlar.
- **Bağlam kutunun dışında yaşar**, üstünde bir alt yazı olarak ("normal terminalinizde (PowerShell)", "Claude Code içinde", "ya da düz dille isteyin…"), kutu tek bir komut taşısa bile. Kutunun içindeki hiçbir şey yorum ya da açıklama değildir: sondaki bir `→ not` kutunun yanında bir alt yazıya dönüşür, `a / b / c` alternatifleri ayrı kutulara dönüşür.
- **Tek bir prompt tek satırdır**, ekranda yumuşak sarmalı, asla yapıştırınca satır sonu olacak sert satır sonlarıyla bölünmüş değil.
- **İstisnalar:** bütün olarak yapıştırılması amaçlanan bir şablon (bir CLAUDE.md kural parçası, bir konfigürasyon dosyası) tek ve çok satırlı bir kutu olarak kalır ve bütün kopyalanır. Yapıştırılacak bir şey değil, düz metin ya da örnek olan bir kutu kopyala düğmesi almaz.
(2026-09-14, firatada.com: `npm install -g ui-ux-pro-max-cli` ve `uipro init --ai claude --global` tek bir kutuyu bir `#` yorum satırının altında paylaşıyordu; kopyalamak iki komutu ve yorumu birden aldı.)
Interactive cells look interactive (not just the row)
When a table/list row is clickable, the primary status/label cell inside it must itself carry an interactive appearance —…
EN
## Interactive cells look interactive (not just the row)
When a table/list row is clickable, the **primary status/label cell inside it must itself
carry an interactive appearance** — style it as a button/chip (border + surface + subtle
shadow) or a link, and give it a hover reaction (e.g. brand-tinted border/background on
`group-hover`). Plain body text inside a clickable row reads as static even if the row
navigates; the user should be able to tell the cell is actionable without hovering. Pair
this with the row-level cues (pointer, hover background, trailing chevron).
TR
## Etkileşimli hücreler etkileşimli görünür (sadece satır değil)
Bir tablo/liste satırı tıklanabilir olduğunda, **içindeki ana durum/etiket hücresi de kendi başına
etkileşimli bir görünüm taşımalı** — onu bir buton/chip (kenarlık + yüzey + hafif gölge) ya da bir link
gibi biçimlendir ve bir hover tepkisi ver (ör. `group-hover` üzerinde marka rengine çalan kenarlık/arka plan).
Tıklanabilir bir satırın içindeki düz gövde metni, satır yönlendirse bile durağan okunur; kullanıcı hücrenin
eyleme açık olduğunu hover yapmadan anlayabilmeli. Bunu satır seviyesindeki ipuçlarıyla (işaretçi, hover arka
planı, sondaki şevron) birlikte kullan.
No ambiguous directional icons
Do not place a lone directional icon (e.g.
EN
## No ambiguous directional icons
Do not place a lone directional icon (e.g. a trailing chevron) as a click target when it is
unclear where it leads. Either remove it, or pair it with a clear label / make the real
target obviously interactive. An arrow that could mean several destinations is misleading.
TR
## Belirsiz yön simgeleri yok
Nereye götürdüğü belirsizken tek başına bir yön simgesini (ör. sondaki bir şevron) tıklama hedefi olarak
koyma. Ya onu kaldır, ya net bir etiketle eşle, ya da gerçek hedefi açıkça etkileşimli yap. Birkaç varış
noktası anlamına gelebilecek bir ok yanıltıcıdır.
Side-by-side items share a size
Items sitting next to each other (chips, pills, buttons, badges) must be visually proportional — the same height, and…
EN
## Side-by-side items share a size
Items sitting next to each other (chips, pills, buttons, badges) must be visually
proportional — the same height, and harmonized padding/font-size. Never place a large item
beside a small one of the same kind; pick one size and apply it to the whole group.
TR
## Yan yana duran öğeler aynı boyutu paylaşır
Yan yana duran öğeler (chip'ler, hap butonlar, butonlar, badge'ler) görsel olarak orantılı olmalı — aynı
yükseklik ve uyumlu boşluk/font boyutu. Aynı türden küçük bir öğenin yanına asla büyük bir öğe koyma; bir
boyut seç ve onu bütün gruba uygula.
A top bar has one vertical centre
When two headings of different sizes sit on the same row, the smaller one aligns to the vertical middle of the larger one,…
EN
## A top bar has one vertical centre
When two headings of different sizes sit on the same row, the smaller one aligns to the
vertical middle of the larger one, never to a shared baseline — a baseline drops the smaller
one and the row reads as broken. Inside a top bar or header bar, **every** item shares one
vertical centre: headings, wordmark, buttons, switches, badges, no exceptions. Do it by
centring the row's container (`align-items: center`), never by nudging items one by one.
TR
## Bir üst barın tek bir dikey merkezi vardır
Farklı boyutta iki başlık aynı satırda duruyorsa, küçük olan büyüğün dikey ortasına hizalanır, asla ortak
bir taban çizgisine değil — taban çizgisi küçüğü aşağı düşürür ve satır bozuk okunur. Bir üst barın ya da
başlık barının içinde **her** öğe tek bir dikey merkezi paylaşır: başlıklar, kelime markası, butonlar,
anahtarlar, badge'ler, istisnasız. Bunu satırın container'ını ortalayarak yap (`align-items: center`), asla
öğeleri tek tek kaydırarak değil.
Table cells — wrap instead of truncate when content doesn't fit
If a table cell's content is wider than its column and truncating loses meaningful information, remove truncate and allow…
EN
## Table cells — wrap instead of truncate when content doesn't fit
If a table cell's content is wider than its column and truncating loses meaningful information,
remove `truncate` and allow wrapping: use `whitespace-normal break-words leading-[1.3]`.
Never silently hide data with ellipsis when wrapping to two lines is readable and the row
height is already variable (e.g. other cells in the same row already wrap).
Reserve `truncate` only for columns where the full value is truly secondary (e.g. long URLs,
raw IDs) and a tooltip provides the full text on hover.
TR
## Tablo hücreleri — içerik sığmıyorsa kırpma, alta sar
Bir tablo hücresinin içeriği kolonundan genişse ve kırpmak anlamlı bilgiyi yok ediyorsa, `truncate`'i kaldır
ve alta sarmaya izin ver: `whitespace-normal break-words leading-[1.3]` kullan.
İki satıra sarmak okunabilirken ve satır yüksekliği zaten değişkenken (ör. aynı satırdaki diğer hücreler zaten
sarıyorken) veriyi üç noktayla sessizce gizleme.
`truncate`'i yalnızca tam değerin gerçekten ikincil olduğu kolonlar için sakla (ör. uzun URL'ler, ham ID'ler)
ve hover'da tam metni veren bir tooltip sun.
Table column headers — always top-aligned
When any column header in a table wraps to two or more lines, all header cells in that row must be vertically top-aligned…
EN
## Table column headers — always top-aligned
When any column header in a table wraps to two or more lines, **all** header cells in that
row must be vertically top-aligned (`vertical-align: top` / Tailwind `align-top` on the `<tr>`).
Never leave some headers centered vertically while others wrap — the baseline mismatch looks
broken. Apply `align-top` to the thead `<tr>` unconditionally whenever multiline headers exist.
TR
## Tablo kolon başlıkları — her zaman üste hizalı
Bir tablodaki herhangi bir kolon başlığı iki veya daha fazla satıra sarıyorsa, o satırdaki **bütün** başlık
hücreleri dikeyde üste hizalanmalı (`<tr>` üzerinde `vertical-align: top` / Tailwind `align-top`). Bazı
başlıkları dikeyde ortalanmış bırakıp diğerlerinin sarmasına izin verme — taban çizgisi uyumsuzluğu bozuk
görünür. Çok satırlı başlık olan her durumda thead `<tr>` üzerine koşulsuz `align-top` uygula.
Table column headers — dimension columns filter, measure columns sort
MOVED to design-filters.md (2026-08-25, owner approval) — design-filters.md owns filter, search and lookup mechanics.
EN
## Table column headers — dimension columns filter, measure columns sort
MOVED to [`design-filters.md`](./design-filters.md) (2026-08-25, owner approval) — `design-filters.md` owns filter,
search and lookup mechanics. See the section of the same name there.
TR
## Tablo kolon başlıkları — boyut kolonları filtreler, ölçü kolonları sıralar
[`design-filters.md`](./design-filters.md) dosyasına TAŞINDI (2026-08-25, sahip onayı) — filtre, arama ve lookup
mekaniği `design-filters.md` dosyasına aittir. Orada aynı adlı bölüme bak.
Number format control — Compact / Full
A dashboard whose money (or other large-number) surfaces can render both abbreviated and exact carries ONE "number format"…
EN
## Number format control — Compact / Full
A dashboard whose money (or other large-number) surfaces can render both abbreviated and exact
carries ONE **"number format"** control in the top bar: a **SEGMENTED two-button control** (never a
switch, never a dropdown) reading `Compact | Full`, wearing the bar's caption-over-control pair
with the lowercase caption **"number format"** and a small `#` (lucide `Hash`) icon.
- **Compact — the DEFAULT**: abbreviated, max one decimal — `$1.2M`, `$45.7K` (the chart-label
rounding rule applied to page surfaces).
- **Full**: the exact figure with thousands separators — `$1,234,567`. No abbreviation.
- **Anatomy**: container at the bar's shared control height, `p-0.5 rounded-lg border
border-gray-200 bg-gray-100/70`; the ACTIVE side is a white `rounded-md` button with accent-blue
semibold text, a soft shadow and a light accent border; the inactive side is muted gray text on
no fill. Each side carries `aria-pressed` and a title stating its example output.
- **A display preference, not a filter**: it changes no query and no scope, never summons the
Reset button, and lives in the URL (`?nf=full`) so a shared link keeps its formatting.
- **The choice is ALWAYS remembered** (owner rule, 2026-08-26): every click also writes a
preference cookie, and a URL naming NO format restores it (server-side redirect, so the URL
stays the single source of truth the control reads). A URL that names a format wins over the
cookie; nothing remembered means Compact. A fresh visit, a nav-tab click and a Reset therefore
all keep the user's format.
- **Scope**: every KPI, table and summary figure on the view follows it. Chart data labels and
axis ticks keep the chart rules (always compact); an exact reconciliation figure quoted in
prose keeps its exact form regardless.
Reference implementations, kept in agreement:
`ai_apps/AAI Perfornance Summary/src/components/summary/SummaryPage.tsx` (the original) and
`ai_apps/Payroll_Manager/components/NumberFormatToggle.tsx`.
TR
## Sayı biçimi kontrolü — Compact / Full
Para (ya da başka büyük sayı) yüzeylerini hem kısaltılmış hem tam gösterebilen bir dashboard, üst barda TEK bir
**"number format"** kontrolü taşır: `Compact | Full` yazan **SEGMENTLİ iki butonlu bir kontrol** (asla bir anahtar,
asla bir dropdown), barın alt yazı-üstü-kontrol ikilisini giyerek, küçük harfli **"number format"** alt yazısı ve
küçük bir `#` (lucide `Hash`) simgesiyle.
- **Compact — VARSAYILAN**: kısaltılmış, en fazla bir ondalık — `$1.2M`, `$45.7K` (grafik etiketi yuvarlama
kuralının sayfa yüzeylerine uygulanmış hâli).
- **Full**: binlik ayırıcılarla tam rakam — `$1,234,567`. Kısaltma yok.
- **Anatomi**: barın ortak kontrol yüksekliğinde bir container, `p-0.5 rounded-lg border border-gray-200
bg-gray-100/70`; AKTİF taraf, vurgu mavisi yarı kalın metni, yumuşak gölgesi ve açık vurgu kenarlığı olan beyaz
bir `rounded-md` butondur; pasif taraf dolgusuz, soluk gri metindir. Her taraf `aria-pressed` ve örnek çıktısını
söyleyen bir title taşır.
- **Bir gösterim tercihi, filtre değil**: hiçbir sorguyu ve kapsamı değiştirmez, Reset butonunu asla çağırmaz ve
URL'de yaşar (`?nf=full`) ki paylaşılan bir link biçimini korusun.
- **Seçim HER ZAMAN hatırlanır** (sahip kuralı, 2026-08-26): her tıklama aynı zamanda bir tercih çerezi yazar ve
HİÇBİR biçim belirtmeyen bir URL onu geri yükler (sunucu tarafı yönlendirme, böylece kontrolün okuduğu tek
doğruluk kaynağı URL olarak kalır). Bir biçim belirten URL çerezi yener; hatırlanan bir şey yoksa Compact geçerlidir.
Yani yeni bir ziyaret, bir menü sekmesi tıklaması ve bir Reset, kullanıcının biçimini korur.
- **Kapsam**: görünümdeki her KPI, tablo ve özet rakamı onu izler. Grafik veri etiketleri ve eksen işaretleri grafik
kurallarını korur (her zaman kısaltılmış); düz metin içinde alıntılanan tam bir mutabakat rakamı ne olursa olsun
tam hâlini korur.
Anlaşmalı tutulan referans uygulamalar:
`ai_apps/AAI Perfornance Summary/src/components/summary/SummaryPage.tsx` (orijinali) ve
`ai_apps/Payroll_Manager/components/NumberFormatToggle.tsx`.
Excel Export — Full column header names, no abbreviations
Excel export column headers must use the longest, most descriptive name for each field.
EN
## Excel Export — Full column header names, no abbreviations
Excel export column headers must use the **longest, most descriptive name** for each field.
The exported file is consumed outside the app and must be self-explanatory without UI context.
- Write out every word: "Annual Salary (Local Currency)" not "Salary LC", "Career Level" not "CL"
- Include the unit in the header: "(USD)", "(Local Currency)", "%", "(Months)"
- Expand all acronyms: "Position in Range (PIR)" not "PIR", "Variable Compensation % (VC%)" not "VC%"
- Set column widths wide enough to show the full header without truncation in Excel
**A full NAME, never a definition.** The rule above is about abbreviation, and it is easy to
over-apply into a data dictionary glued to the header row:
`Career Level (Paycom pay grade; blank = not recorded)` — that is provenance and a null convention,
not the column's name, and it makes every column three times wider than its own data. The header is
what the field is called plus its unit; where the value came from and what a blank means belong in a
notes sheet or the app, read once, not carried down every filter the reader builds.
`Career Level` and `Tenure (Months)` are correct. Both halves of this matter — do not swing back to
`CL` or `Tnr`.
TR
## Excel'e Aktarma — Tam kolon başlığı adları, kısaltma yok
Excel aktarımındaki kolon başlıkları her alan için **en uzun, en açıklayıcı adı** kullanmalı.
Aktarılan dosya uygulamanın dışında tüketilir ve arayüz bağlamı olmadan kendini anlatmalıdır.
- Her kelimeyi açık yaz: "Salary LC" değil "Annual Salary (Local Currency)", "CL" değil "Career Level"
- Birimi başlığa koy: "(USD)", "(Local Currency)", "%", "(Months)"
- Bütün kısaltmaları aç: "PIR" değil "Position in Range (PIR)", "VC%" değil "Variable Compensation % (VC%)"
- Kolon genişliklerini, Excel'de başlığın kırpılmadan görüneceği kadar geniş ayarla
**Tam bir AD, asla bir tanım.** Yukarıdaki kural kısaltma hakkındadır ve başlık satırına yapıştırılmış bir veri
sözlüğüne dönüşecek kadar aşırı uygulanması kolaydır:
`Career Level (Paycom pay grade; blank = not recorded)` — bu, kolonun adı değil, kaynağı ve boş değer kuralıdır;
üstelik her kolonu kendi verisinden üç kat geniş yapar. Başlık, alanın adı artı birimidir; değerin nereden geldiği
ve boşun ne anlama geldiği bir not sayfasına ya da uygulamaya aittir, bir kez okunur, okuyucunun kurduğu her
filtrede taşınmaz.
`Career Level` ve `Tenure (Months)` doğrudur. Bunun iki yarısı da önemlidir — `CL` ya da `Tnr`'ye geri savrulma.
A label names its scope, in the fewest words that do it
Every label says what the control touches; the reader must never have to guess.
EN
## A label names its scope, in the fewest words that do it
Every label says what the control touches; the reader must never have to guess. "Search" becomes
**"search all transcripts"** — one word tells them the search is not limited to what they are reading.
- **Add the word that removes the ambiguity, and stop there.** Clarity is not length: any word that
does not narrow what the control does comes out. A sentence of explanation belongs in the hint text
under the control, never in its label.
- **One concept, one word, everywhere** — the same action is not "remove" on one screen and "delete"
on the next.
- **Every button carries a tooltip saying what it does**, not only the icon-only ones. The label is
the shortest name for the action; the tooltip is the sentence that does not fit in it, and it says
what will happen when it is pressed ("Speed of playback" on a `1x` button, "Stops now. The lines
already written stay, and you can continue later."). A button whose outcome is not obvious from its
label and has no tooltip is unfinished.
TR
## Bir etiket kapsamını, bunu yapan en az kelimeyle adlandırır
Her etiket kontrolün neye dokunduğunu söyler; okuyan asla tahmin etmek zorunda kalmamalı. "Search",
**"search all transcripts"**e dönüşür — tek kelime, aramanın okuduğu şeyle sınırlı olmadığını söyler.
- **Belirsizliği kaldıran kelimeyi ekle ve orada dur.** Netlik uzunluk değildir: kontrolün ne yaptığını
daraltmayan her kelime çıkar. Bir cümlelik açıklama, kontrolün altındaki ipucu metnine aittir, asla etiketine.
- **Tek kavram, tek kelime, her yerde** — aynı eylem bir ekranda "remove", diğerinde "delete" olmaz.
- **Her buton ne yaptığını söyleyen bir tooltip taşır**, sadece yalnız simgeli olanlar değil. Etiket, eylemin en
kısa adıdır; tooltip ise ona sığmayan cümledir ve basıldığında ne olacağını söyler (`1x` butonunda "Oynatma hızı",
"Şimdi durur. Zaten yazılmış satırlar kalır, sonra devam edebilirsiniz."). Sonucu etiketinden anlaşılmayan ve
tooltip'i olmayan bir buton yarım kalmıştır.
Headings are Title Case — never Sentence case
If a heading's first word is capitalised, every significant word in it is.
EN
## Headings are Title Case — never Sentence case
If a heading's first word is capitalised, **every significant word in it is**. "Career Moves", not
"Career moves"; "Managers & Spans", not "Managers & spans"; "Full Hierarchy", not "Full hierarchy".
Applies to anything that names a thing rather than saying something: tab names, card and panel
titles, table column headers, modal titles, button labels.
Mixed casing is the actual failure mode — a tab row reading `Full hierarchy · Person Card ·
Career moves` looks like three different people built it, and that is exactly how it happens: each
label is written on the day its screen is, with nobody comparing them side by side. Pick the case
once, at the row, and the labels stay consistent as screens get added.
- **Short function words stay lowercase in the middle**: `of`, `by`, `in`, `to`, `for`, `and`, `the`,
`a`, `or`. So "Headcount by Department", "Span of Control", "Where the Organisation Sits" — not
"Headcount By Department". Capitalise them only as the first or last word.
- **Hyphenated compounds capitalise both halves**: "Career-Level Moves by Year".
- **Acronyms and product names keep their own casing**: `PDF`, `EUC`, `SRE`, `PIR`, `Paycom`.
- **Sentences are not headings.** Helper text, hints and empty-state copy are prose and stay in
sentence case with a full stop. The line under a heading is prose; the heading is not.
- **Data is never re-cased.** A department called "Global Delivery - Operations and PMO" prints as it
is stored — the rule governs labels the app writes, never values it read.
TR
## Başlıklar Title Case'dir — asla Sentence case değil
Bir başlığın ilk kelimesi büyük harfle başlıyorsa, **içindeki her anlamlı kelime de öyledir**. "Career moves"
değil "Career Moves"; "Managers & spans" değil "Managers & Spans"; "Full hierarchy" değil "Full Hierarchy".
Bir şey söylemek yerine bir şeyi adlandıran her yerde geçerlidir: sekme adları, kart ve panel başlıkları, tablo
kolon başlıkları, modal başlıkları, buton etiketleri.
Asıl arıza karışık harflemedir — `Full hierarchy · Person Card · Career moves` diye okunan bir sekme satırı, üç
farklı kişinin yaptığı gibi görünür ve tam olarak böyle olur: her etiket kendi ekranının yazıldığı gün yazılır ve
kimse onları yan yana karşılaştırmaz. Harflemeyi bir kez, satırın kendisinde seç; ekranlar eklendikçe etiketler
tutarlı kalır.
- **Kısa işlev kelimeleri ortada küçük kalır**: `of`, `by`, `in`, `to`, `for`, `and`, `the`, `a`, `or`. Yani
"Headcount by Department", "Span of Control", "Where the Organisation Sits" — "Headcount By Department" değil.
Onları yalnızca ilk ya da son kelimeyken büyüt.
- **Tireli bileşikler her iki yarıyı da büyütür**: "Career-Level Moves by Year".
- **Kısaltmalar ve ürün adları kendi harflemelerini korur**: `PDF`, `EUC`, `SRE`, `PIR`, `Paycom`.
- **Cümleler başlık değildir.** Yardım metni, ipuçları ve boş durum metinleri düz yazıdır; cümle harflemesinde ve
noktayla biter. Bir başlığın altındaki satır düz yazıdır; başlık değildir.
- **Veri asla yeniden harflenmez.** "Global Delivery - Operations and PMO" adlı bir departman saklandığı gibi basılır —
kural, uygulamanın yazdığı etiketleri yönetir, okuduğu değerleri asla.
File-type export icons — the product's own mark, greyscale until hovered
An export action is named by the file it produces, so it is drawn as that file type's own icon rather than the word "Export"…
EN
## File-type export icons — the product's own mark, greyscale until hovered
An export action is named by the **file it produces**, so it is drawn as that file type's own icon
rather than the word "Export" or a generic download arrow. People scan a toolbar for the green Excel
sheet and the red PDF badge faster than they read any label, and one download arrow cannot say which
of the two it is.
- **Icons live in `logos/`** — `excel_blue.png`, `PDF_*.png`. **Copy the file into the app's own
`public/`; never hotlink** (same rule as the company wordmark in `login.md` §6).
- **Order: PDF on the LEFT, Excel on the RIGHT.** Fixed, so muscle memory carries between apps.
- **Greyscale at rest, full colour on hover.** `grayscale opacity-60` →
`hover:grayscale-0 hover:opacity-100`. Product logos are saturated brand colours designed to win
attention; at full strength a toolbar of them out-shouts the page's own data, which is the thing
the reader actually came for. Colour on hover both calms the resting state and confirms the
target.
- **Icon-only needs a `title` AND an `aria-label`.** Dropping the visible word is only acceptable if
the accessible name survives; the tooltip should also say what will happen
(*"Export this page to PDF — opens your print dialog"*).
- **Show progress in place.** A build that takes a moment swaps the icon for a spinner in the same
button rather than moving or resizing it — a toolbar that reflows under the cursor loses the click.
### PDF export = `window.print()` plus a print stylesheet
For "export what is on screen", print the page. Do **not** reach for html2canvas/jsPDF: they
re-render into a bitmap, mangle SVG and canvas, and return a blurry screenshot instead of selectable
text — and they are two more dependencies behind the corporate proxy.
The print stylesheet is then the actual export format, so treat it as one:
- **Hide anything you could only click** — buttons, tab bars, filter chrome. A control in a PDF is a
dead pixel. Give them a `print:hidden` class; keep branding and every piece of state (a focus chip,
an active filter) because that is what tells a reader what they are looking at.
- **Force background printing** (`print-color-adjust: exact`) or every chart fill vanishes — the
browser strips backgrounds by default and the data goes with them.
- **`break-inside: avoid` on cards**, or a chart lands half on each page.
- Drop shadows and tinted panels; white page, plain borders.
TR
## Dosya türü aktarma simgeleri — ürünün kendi markası, hover'a kadar gri tonlu
Bir aktarma aksiyonu **ürettiği dosyayla** adlandırılır, bu yüzden "Export" kelimesi ya da genel bir indirme oku
yerine o dosya türünün kendi simgesiyle çizilir. İnsanlar bir araç çubuğunda yeşil Excel sayfasını ve kırmızı PDF
rozetini, herhangi bir etiketi okuduklarından daha hızlı tarar ve tek bir indirme oku bunlardan hangisi olduğunu
söyleyemez.
- **Simgeler `logos/` klasöründe yaşar** — `excel_blue.png`, `PDF_*.png`. **Dosyayı uygulamanın kendi `public/`
klasörüne kopyala; asla doğrudan bağlanma** (`login.md` §6'daki şirket kelime markasıyla aynı kural).
- **Sıra: SOLDA PDF, SAĞDA Excel.** Sabit, böylece kas hafızası uygulamalar arasında taşınır.
- **Beklemede gri tonlu, hover'da tam renkli.** `grayscale opacity-60` →
`hover:grayscale-0 hover:opacity-100`. Ürün logoları dikkat kazanmak için tasarlanmış doygun marka renkleridir;
tam güçteyken bunlardan oluşan bir araç çubuğu, okuyucunun asıl geldiği şey olan sayfanın kendi verisini bastırır.
Hover'da renk, hem bekleme durumunu sakinleştirir hem hedefi teyit eder.
- **Yalnız simge, bir `title` VE bir `aria-label` ister.** Görünür kelimeyi düşürmek ancak erişilebilir ad ayakta
kalırsa kabul edilebilir; tooltip ayrıca ne olacağını söylemeli
(*"Bu sayfayı PDF'e aktar — yazdırma diyalogunuzu açar"*).
- **İlerlemeyi yerinde göster.** Bir an süren bir üretim, butonu oynatmak ya da yeniden boyutlandırmak yerine
aynı butonun içinde simgeyi bir spinner ile değiştirir — imlecin altında yeniden akan bir araç çubuğu tıklamayı
kaybeder.
### PDF aktarımı = `window.print()` artı bir yazdırma stil sayfası
"Ekranda olanı aktar" için sayfayı yazdır. html2canvas/jsPDF'e **uzanma**: bunlar bir bitmap'e yeniden çizer,
SVG ve canvas'ı bozar ve seçilebilir metin yerine bulanık bir ekran görüntüsü döner — üstelik kurumsal vekil
sunucunun arkasında iki bağımlılık daha demektir.
O zaman yazdırma stil sayfası aktarma biçiminin kendisidir, öyle davran:
- **Yalnızca tıklayabileceğin her şeyi gizle** — butonlar, sekme barları, filtre arayüzü. Bir PDF'teki kontrol ölü
pikseldir. Onlara bir `print:hidden` sınıfı ver; markalamayı ve her durum parçasını (odak chip'i, aktif bir
filtre) koru, çünkü okuyucuya neye baktığını söyleyen şey odur.
- **Arka plan basımını zorla** (`print-color-adjust: exact`), yoksa her grafik dolgusu kaybolur — tarayıcı
varsayılan olarak arka planları söker ve veri de onunla gider.
- **Kartlarda `break-inside: avoid`**, yoksa bir grafik yarısı bir sayfada yarısı diğerinde kalır.
- Gölgeleri ve renk tonlu panelleri düşür; beyaz sayfa, düz kenarlıklar.
Design user flows for zero effort ("armut piş ağzıma düş")
Bring every required action to where the user already is, in the fewest steps, prefilled and automatic wherever we can.
EN
## Design user flows for zero effort ("armut piş ağzıma düş")
Bring every required action to where the user already is, in the fewest steps, prefilled and automatic wherever we can. Never make them hunt for a page, leave the product, copy-paste, or press a button we could press for them.
Count the steps the person takes, not the steps the system takes. A flow can be correct and still push work onto the reader: fetching a value, opening a second tab, copying something across. Each of those is a step the product could have taken instead.
(The global `CLAUDE.md` → UI rules carries the one-line trigger; this is its home.)
TR
## Kullanıcı akışlarını sıfır çaba için tasarla ("armut piş ağzıma düş")
Gereken her eylemi kullanıcının zaten olduğu yere, en az adımda, mümkün olan her yerde önceden doldurulmuş ve
otomatik olarak getir. Onu asla bir sayfa aramaya, üründen çıkmaya, kopyala-yapıştır yapmaya ya da onun yerine
basabileceğimiz bir butona basmaya zorlama.
Kişinin attığı adımları say, sistemin attıklarını değil. Bir akış doğru olabilir ve yine de işi okuyucuya yıkabilir:
bir değeri getirmek, ikinci bir sekme açmak, bir şeyi oradan oraya kopyalamak. Bunların her biri, ürünün onun yerine
atabileceği bir adımdır.
(Global `CLAUDE.md` → UI rules tek satırlık tetikleyiciyi taşır; burası onun evi.)
Do not make the user assemble your pipeline
Two models, two passes, two endpoints — that is your architecture, not the user's task.
EN
## Do not make the user assemble your pipeline
Two models, two passes, two endpoints — that is your architecture, not the user's task. The main
action runs the whole job and asks for everything it needs in **one panel**; the person clicking it
does not know, and must not need to know, that transcription and speaker labelling are separate
things.
- **Keep the expert control, demote it.** The button that re-runs only the second step stays for the
case where someone wants exactly that, clearly labelled, beside the thing it affects. It is the
shortcut, never the normal path.
- **One button, one result.** If finishing the job takes a second click somewhere else, the first
button lied about being finished.
TR
## Kullanıcıya senin pipeline'ını kurdurma
İki model, iki geçiş, iki uç nokta — bu senin mimarin, kullanıcının görevi değil. Ana aksiyon işin tamamını
çalıştırır ve ihtiyaç duyduğu her şeyi **tek bir panelde** ister; ona tıklayan kişi, transkripsiyon ile konuşmacı
etiketlemenin ayrı şeyler olduğunu bilmez ve bilmek zorunda olmamalıdır.
- **Uzman kontrolünü koru, ama geri plana at.** Sadece ikinci adımı yeniden çalıştıran buton, tam olarak bunu
isteyen biri için kalır; açıkça etiketlenmiş ve etkilediği şeyin yanında. O kısayoldur, asla normal yol değil.
- **Tek buton, tek sonuç.** İşi bitirmek başka bir yerde ikinci bir tıklama gerektiriyorsa, ilk buton bittiği
konusunda yalan söylemiştir.
Let the container decide the columns
Lay items out with repeat(auto-fit, minmax(<readable width>, 1fr)) and let the container answer how many fit: side by side…
EN
## Let the container decide the columns
Lay items out with `repeat(auto-fit, minmax(<readable width>, 1fr))` and let the container answer how
many fit: side by side when there is room, stacked when there is not. Picking the column count by
hand means picking it wrong at every width you did not test.
- **No hard minimum on a block inside a flexible parent.** A `min-width` that the parent cannot honour
does not shrink the block, it widens the page: a horizontal scrollbar on the whole document.
- **No arbitrary maximum either.** A cap chosen for a narrow case squeezes the content into a strip
when the same component is later used in a wide one.
- **Judge it at both ends** — the narrowest container the component appears in, and the widest.
(2026-09, Voice to Text: the same quality picker first lived under a `min-width: 17rem` that pinned it
to the left half of a wide panel, then under a `minmax(20rem, 1fr)` that pushed the page into
horizontal scroll. `auto-fit` with no hard bounds solved both.)
TR
## Kolon sayısına container karar versin
Öğeleri `repeat(auto-fit, minmax(<okunabilir genişlik>, 1fr))` ile yerleştir ve kaçının sığdığını container
cevaplasın: yer varken yan yana, yokken alt alta. Kolon sayısını elle seçmek, test etmediğin her genişlikte
yanlış seçmek demektir.
- **Esnek bir ebeveynin içindeki bir bloğa sert alt sınır koyma.** Ebeveynin karşılayamadığı bir `min-width`
bloğu küçültmez, sayfayı genişletir: bütün dokümanda yatay kaydırma çubuğu.
- **Keyfi bir üst sınır da koyma.** Dar bir durum için seçilmiş bir tavan, aynı bileşen sonradan geniş bir yerde
kullanıldığında içeriği bir şeride sıkıştırır.
- **İki uçtan da değerlendir** — bileşenin göründüğü en dar container ve en geniş olanı.
(2026-09, Voice to Text: aynı kalite seçicisi önce onu geniş bir panelin sol yarısına çivileyen bir
`min-width: 17rem` altında yaşadı, sonra sayfayı yatay kaydırmaya iten bir `minmax(20rem, 1fr)` altında.
Sert sınırsız `auto-fit` ikisini de çözdü.)
A panel opens where the control that opened it is
An inline panel raised by a button must appear next to that button, not at the bottom of the container it belongs to, and it…
EN
## A panel opens where the control that opened it is
An inline panel raised by a button must appear **next to that button**, not at the bottom of the
container it belongs to, and it scrolls itself into view when it opens. Rendered under two thousand
lines of content, the panel is off screen, the click looks like nothing happened, and the button gets
reported as broken — it was working the whole time.
- **Judge it at the worst length**, not on the short test record: a panel placed after the content is
fine with ten rows and invisible with two thousand.
- The reverse move is just as wrong: a panel that opens far **above** the click steals the reader's
place. Adjacent means adjacent.
### The pointer never travels for the next step
The rule above is one case of a general one: **whatever a click raises appears where that click
happened.** A panel, a confirmation, a menu, a follow-up question, an error — the second step of an
interaction belongs beside the control that started it, so the hand that is already there can finish
without crossing the screen and coming back.
- **A confirmation is not exempt because it is modal.** Modal means the rest of the page waits, not
that the question has to sit in the middle of the screen. Anchor the card to the control that
raised it, keep the dimmed backdrop if you want the click-outside and the waiting, and the reader
answers within a few pixels of where they pressed. A centred confirm makes every destructive
action a round trip across the window.
- **Anchor it, then clamp it to the viewport.** Prefer just below the control with its near edge
aligned to the control's; flip above when the page runs out underneath. A card pushed off screen
by the anchoring is worse than a centred one.
- **Measure again when the page moves.** The position was taken against the page as it stood, so a
scroll or a resize either re-measures it or closes it. Leaving it where it was points the reader
at the wrong thing.
- **Watch the self-closing trap.** Focusing the button inside the raised card scrolls it into view,
which fires the same scroll handler that repositions or closes the card — it shuts in the frame
it opened. Take focus with `preventScroll`.
TR
## Bir panel, onu açan kontrolün olduğu yerde açılır
Bir butonun açtığı satır içi panel, ait olduğu container'ın en altında değil **o butonun yanında** görünmeli ve
açıldığında kendini görüş alanına kaydırmalı. İki bin satırlık içeriğin altına çizildiğinde panel ekran dışında
kalır, tıklama hiçbir şey olmamış gibi görünür ve buton bozuk diye raporlanır — oysa baştan beri çalışıyordu.
- **En kötü uzunlukta değerlendir**, kısa test kaydında değil: içerikten sonra yerleştirilmiş bir panel on satırla
sorunsuz, iki bin satırla görünmezdir.
- Ters hamle de aynı ölçüde yanlıştır: tıklamanın çok **üstünde** açılan bir panel okuyucunun yerini çalar.
Bitişik, bitişik demektir.
### İmleç sonraki adım için yolculuk yapmaz
Yukarıdaki kural, genel bir kuralın tek bir hâlidir: **bir tıklamanın açtığı her şey, o tıklamanın olduğu yerde
belirir.** Bir panel, bir onay, bir menü, bir takip sorusu, bir hata — bir etkileşimin ikinci adımı, onu başlatan
kontrolün yanına aittir ki zaten orada olan el, ekranı geçip geri dönmeden bitirebilsin.
- **Bir onay, modal olduğu için muaf değildir.** Modal, sayfanın geri kalanının beklediği anlamına gelir; sorunun
ekranın ortasında durması gerektiği anlamına değil. Kartı onu açan kontrole sabitle, dışarı tıklamayı ve beklemeyi
istiyorsan karartılmış zemini koru; okuyan, bastığı yerden birkaç piksel ötede cevap verir. Ortalanmış bir onay,
her yıkıcı aksiyonu pencere boyunca gidiş-dönüşe çevirir.
- **Sabitle, sonra görüş alanına kıstır.** Tercihen kontrolün hemen altında, yakın kenarı kontrolünkiyle hizalı;
altta yer kalmadığında üste çevir. Sabitleme yüzünden ekran dışına itilmiş bir kart, ortalanmış olandan kötüdür.
- **Sayfa oynadığında yeniden ölç.** Konum, sayfanın o anki hâline göre alındı; yani bir kaydırma ya da yeniden
boyutlandırma ya yeniden ölçer ya kapatır. Onu olduğu yerde bırakmak okuyucuyu yanlış şeye işaret eder.
- **Kendi kendini kapatma tuzağına dikkat et.** Açılan kartın içindeki butona odaklanmak onu görüş alanına kaydırır,
bu da kartı yeniden konumlandıran ya da kapatan aynı kaydırma işleyicisini ateşler — kart açıldığı karede kapanır.
Odağı `preventScroll` ile al.
Reserve the scrollbar's place
Give the root scrollbar-gutter: stable.
EN
## Reserve the scrollbar's place
Give the root `scrollbar-gutter: stable`. The bar's width stays reserved whether or not it is showing,
so the page never jumps sideways when the content's height crosses the viewport: opening a section,
collapsing a list, filtering rows, showing a panel. Without it every one of those shifts the whole
layout by the bar's width, twice — once out, once back.
- **Not `html { overflow-y: scroll }`.** The old trick shows a dead track on pages that do not scroll.
- **Not a `100vw` width.** `100vw` includes the scrollbar, so it produces the opposite bug: a
horizontal overflow of exactly the bar's width.
- **The same applies to an inner scroll box** whose content grows and shrinks; give it the same
property rather than letting its own bar push the content around.
- Where scrollbars overlay the content (macOS, touch), the rule costs nothing: there is no gutter to
reserve.
TR
## Kaydırma çubuğunun yerini ayır
Köke `scrollbar-gutter: stable` ver. Çubuğun genişliği görünsün görünmesin ayrılmış kalır, böylece içeriğin
yüksekliği görüş alanını geçtiğinde sayfa asla yana zıplamaz: bir bölüm açmak, bir listeyi kapatmak, satır
filtrelemek, bir panel göstermek. Onsuz bunların her biri bütün yerleşimi çubuğun genişliği kadar, iki kez
kaydırır — bir gidiş, bir dönüş.
- **`html { overflow-y: scroll }` değil.** Eski numara, kaydırmayan sayfalarda ölü bir ray gösterir.
- **`100vw` genişlik de değil.** `100vw` kaydırma çubuğunu içerir, yani tam tersi hatayı üretir: tam olarak
çubuğun genişliği kadar yatay taşma.
- **Aynısı, içeriği büyüyüp küçülen bir iç kaydırma kutusu için de geçerlidir**; kendi çubuğunun içeriği itmesine
izin vermek yerine ona da aynı özelliği ver.
- Kaydırma çubuklarının içeriğin üstüne bindiği yerlerde (macOS, dokunmatik) kural hiçbir şeye mal olmaz:
ayrılacak bir oluk yoktur.
One row, one shape
Every interactive element on a screen shares one height and one corner radius.
EN
## One row, one shape
Every interactive element on a screen shares **one height and one corner radius**. A badge, a chip, an
icon-only button, a search box and a labelled button standing in the same row are all the same box:
different heights and mixed roundings in one line read as a broken layout, not as a hierarchy.
- **The height comes from `min-height`, never a fixed height**, so a long label can still wrap (see
*Never give a button a fixed height*).
- **The radius is a token, not a per-element choice.** Do not mix a 999 px pill with a rounded
rectangle in the same row.
- **A non-clickable badge takes the same box too.** What separates it from a button is its colour and
its cursor, not a smaller size.
- **One box model, or the rule cannot hold.** Set `*, *::before, *::after { box-sizing: border-box }`.
Without it a `<button>` is border-box while an `<a>` or a `<span>` is content-box, so the *same*
`min-height` produces different boxes. (2026-09, Voice to Text: one `min-height: 36px` gave a
47.6 px Export link, 38 px chips and 36 px buttons, side by side in one row.)
- **Compliance is measured, never read.** Collect every control's *rendered* height and corner radius
and check that the set has exactly one member. Reading the CSS is not evidence: all three heights
above came from a single, correct-looking declaration.
- **It applies to controls**, not to surfaces: buttons, icon buttons, chips, badges, input fields.
A card, a drop zone or a panel carries its own size, but its radius still comes from the same tokens.
- **An exception is written down, or it is a violation.** A control that genuinely cannot take the
shape — a native element wrapped in your own bar, for instance — says so in the doc, with the
reason. An exception decided silently reads as an oversight to everyone who meets it later,
including the person who decided it.
- The value itself is per project; what the rule fixes is that there is exactly one of it.
(2026-09, Voice to Text: a 26 px pill status chip and 29 px speaker chips sat beside 36 px buttons
with a 14 px radius, and the action row looked misaligned at every width. A later audit of the same
screen found two more: the audio player bar, left as a 999 px pill because it wraps the browser's own
control panel — an exception nobody had written down — and the 26 px icon buttons.)
TR
## Tek satır, tek biçim
Bir ekrandaki her etkileşimli öğe **tek bir yüksekliği ve tek bir köşe yarıçapını** paylaşır. Aynı satırda duran
bir badge, bir chip, yalnız simgeli bir buton, bir arama kutusu ve etiketli bir buton aynı kutudur: tek satırda
farklı yükseklikler ve karışık yuvarlamalar hiyerarşi değil, bozuk yerleşim olarak okunur.
- **Yükseklik `min-height`'tan gelir, asla sabit bir yükseklikten**, ki uzun bir etiket hâlâ alta sarabilsin
(bkz. *Bir butona asla sabit yükseklik verme*).
- **Yarıçap bir token'dır, öğe başına bir tercih değil.** Aynı satırda 999 px'lik bir hapla yuvarlatılmış bir
dikdörtgeni karıştırma.
- **Tıklanamayan bir badge de aynı kutuyu alır.** Onu bir butondan ayıran şey rengi ve imlecidir, daha küçük bir
boyut değil.
- **Tek kutu modeli, yoksa kural tutmaz.** `*, *::before, *::after { box-sizing: border-box }` ayarla. Onsuz bir
`<button>` border-box iken bir `<a>` ya da `<span>` content-box'tır, yani *aynı* `min-height` farklı kutular üretir.
(2026-09, Voice to Text: tek bir `min-height: 36px`, yan yana tek satırda 47,6 px'lik bir Export linki, 38 px
chip'ler ve 36 px butonlar verdi.)
- **Uyum okunmaz, ölçülür.** Her kontrolün *çizilmiş* yüksekliğini ve köşe yarıçapını topla ve kümenin tam olarak
bir üyesi olduğunu doğrula. CSS'i okumak kanıt değildir: yukarıdaki üç yükseklik de tek ve doğru görünen bir
bildirimden geliyordu.
- **Kontroller için geçerlidir**, yüzeyler için değil: butonlar, simge butonları, chip'ler, badge'ler, input alanları.
Bir kart, bir bırakma alanı ya da bir panel kendi boyutunu taşır ama yarıçapı yine aynı token'lardan gelir.
- **İstisna yazılıdır, yoksa ihlaldir.** Biçimi gerçekten alamayan bir kontrol — örneğin kendi barına sarılmış yerel
bir öğe — bunu dokümanda, gerekçesiyle söyler. Sessizce verilmiş bir istisna, sonradan karşılaşan herkese, onu
veren kişi dahil, bir gözden kaçma olarak okunur.
- Değerin kendisi projeye göredir; kuralın sabitlediği şey, ondan tam olarak bir tane olmasıdır.
(2026-09, Voice to Text: 26 px'lik hap biçiminde bir durum chip'i ve 29 px'lik konuşmacı chip'leri, 14 px yarıçaplı
36 px butonların yanında duruyordu ve aksiyon satırı her genişlikte hizasız görünüyordu. Aynı ekranın sonraki bir
denetimi iki tane daha buldu: tarayıcının kendi kontrol panelini sardığı için 999 px hap olarak bırakılmış ses
oynatıcı barı — kimsenin yazmadığı bir istisna — ve 26 px'lik simge butonları.)
Never give a button a fixed height
Use min-height and let a long label wrap inside the button.
EN
## Never give a button a fixed height
Use `min-height` and let a long label wrap inside the button. A fixed height plus a label that grew
(translation, a longer state name) spills text outside the shape, and it looks like a bug because it
is one.
- **Keep the label short instead of stretching the button.** A requirement the user has to satisfy
("pick a number first") goes in the hint line next to the control, not inside the label — the
button stays "Start" and looks unavailable (see *A blocked action must point at what is blocking
it* for why it must still answer a press).
TR
## Bir butona asla sabit yükseklik verme
`min-height` kullan ve uzun bir etiketin butonun içinde alta sarmasına izin ver. Sabit bir yükseklik artı büyümüş
bir etiket (çeviri, daha uzun bir durum adı) metni biçimin dışına taşırır ve hata gibi görünür, çünkü hatadır.
- **Butonu esnetmek yerine etiketi kısa tut.** Kullanıcının karşılaması gereken bir şart ("önce bir sayı seçin")
etiketin içine değil, kontrolün yanındaki ipucu satırına girer — buton "Başlat" olarak kalır ve kullanılamaz
görünür (basıldığında neden yine de cevap vermesi gerektiği için bkz. *Engellenmiş bir aksiyon, onu neyin
engellediğini göstermeli*).
A blocked action must point at what is blocking it
A greyed-out button says "not yet" and nothing more.
EN
## A blocked action must point at what is blocking it
A greyed-out button says "not yet" and nothing more. The reader is left hunting for the answer they
skipped, and on a long panel they may never find it. So a button blocked by a missing answer is
**never `disabled`**: a disabled button swallows the click, gives no feedback at all, and screen
readers skip past it. It looks unavailable, carries `aria-disabled`, and stays clickable — pressing
it is how the reader finds out what is missing.
Pressing it does all of this at once:
- **Bring the unanswered control into view** and give it the keyboard.
- **Say it is unanswered, twice over**: one short movement (a single brief shake) to catch the eye,
plus a warning outline that *stays* until the answer is given. Motion alone is missed by whoever
looked away; a static mark alone is missed on a long page.
- **Announce the reason** in the hint next to that control, as a live region (`role="alert"`), so it
reaches a reader who cannot see the outline.
Two boundaries keep it from turning into nagging:
- **Nothing is marked before the first press.** A panel that opens already scolding the reader for
not having answered yet is wrong.
- **The mark clears the moment the answer arrives**, and with several missing answers all are
marked, while the scroll and the focus go to the first.
Under `prefers-reduced-motion: reduce` the shake is replaced by a single highlight, never kept as
motion. (2026-09, Voice to Text: Start sat greyed out under a speaker question the reader had not
noticed.)
TR
## Engellenmiş bir aksiyon, onu neyin engellediğini göstermeli
Soluklaştırılmış bir buton "henüz değil" der, fazlasını değil. Okuyan, atladığı cevabı aramaya bırakılır ve uzun
bir panelde onu hiç bulamayabilir. Bu yüzden eksik bir cevapla engellenmiş bir buton **asla `disabled` olmaz**:
devre dışı bir buton tıklamayı yutar, hiçbir geri bildirim vermez ve ekran okuyucular onu atlar. Kullanılamaz
görünür, `aria-disabled` taşır ve tıklanabilir kalır — ona basmak, okuyanın neyin eksik olduğunu öğrenme yoludur.
Ona basmak şunların hepsini aynı anda yapar:
- **Cevaplanmamış kontrolü görüş alanına getirir** ve klavyeyi ona verir.
- **Cevaplanmadığını iki kez söyler**: gözü yakalamak için kısa bir hareket (tek ve kısa bir sallanma) ve cevap
verilene kadar *kalan* bir uyarı çerçevesi. Tek başına hareketi, başka yere bakan kaçırır; tek başına durağan bir
işareti, uzun bir sayfada kaçıran olur.
- **Sebebi duyurur**, o kontrolün yanındaki ipucunda, canlı bölge olarak (`role="alert"`), böylece çerçeveyi
göremeyen okuyucuya da ulaşır.
İki sınır, bunun dırdıra dönüşmesini engeller:
- **İlk basıştan önce hiçbir şey işaretlenmez.** Daha cevaplamadığı için okuyucuyu baştan azarlayarak açılan bir
panel yanlıştır.
- **Cevap gelir gelmez işaret kalkar** ve birden fazla eksik cevap varsa hepsi işaretlenir, kaydırma ve odak ise
ilkine gider.
`prefers-reduced-motion: reduce` altında sallanma tek bir vurguyla değiştirilir, asla hareket olarak korunmaz.
(2026-09, Voice to Text: Başlat butonu, okuyucunun fark etmediği bir konuşmacı sorusunun altında soluk duruyordu.)
A partial result says what it is missing
A half-finished result looks exactly like a finished one.
EN
## A partial result says what it is missing
A half-finished result looks exactly like a finished one. Nothing in a transcript that stops at
minute 12, or in a table whose last column was never computed, says so — the reader takes what is
there as the whole thing, and the silence is what misleads them.
- **Name the missing part where the result is shown**, in words, not as an absence: "the first 12:59
of 1:01:53 is kept", "speaker names were not detected for it".
- **Every export carries the same sentence.** A file leaves the screen that explained it; mark it
partial in its own header, with the point it reached.
- **Put the action that completes it next to the sentence**, already filled in with what the user
chose earlier. Telling someone what is missing without offering to finish it is only half the job.
- **Only a gap at the END counts as missing.** Gaps in the middle or at the start are often normal
output (a line no speaker turn overlapped), and reporting them as failures teaches the reader to
ignore the notice.
(2026-09, Voice to Text: a transcript stopped early carried no speaker labels at all, and nothing on
the card said so — it read as "this recording has one unnamed speaker".)
TR
## Kısmi bir sonuç neyin eksik olduğunu söyler
Yarım kalmış bir sonuç, tamamlanmış olandan birebir aynı görünür. 12. dakikada duran bir transkriptte ya da son
kolonu hiç hesaplanmamış bir tabloda bunu söyleyen hiçbir şey yoktur — okuyan orada olanı bütün sanır ve onu
yanıltan şey sessizliktir.
- **Eksik parçayı sonucun gösterildiği yerde adlandır**, yokluk olarak değil kelimelerle: "1:01:53'ün ilk 12:59'u
tutuldu", "onun için konuşmacı adları tespit edilemedi".
- **Her aktarma aynı cümleyi taşır.** Bir dosya, onu açıklayan ekrandan ayrılır; kendi başlığında, ulaştığı noktayla
birlikte kısmi olduğunu işaretle.
- **Onu tamamlayan aksiyonu cümlenin yanına koy**, kullanıcının daha önce seçtikleriyle önceden doldurulmuş olarak.
Birine neyin eksik olduğunu söyleyip bitirmeyi önermemek işin sadece yarısıdır.
- **Sadece SONDAKİ boşluk eksik sayılır.** Ortadaki ya da baştaki boşluklar çoğu zaman normal çıktıdır (hiçbir
konuşmacı sırasının çakışmadığı bir satır) ve onları hata diye raporlamak okuyucuya bildirimi yok saymayı öğretir.
(2026-09, Voice to Text: erken duran bir transkript hiç konuşmacı etiketi taşımıyordu ve kartta bunu söyleyen bir
şey yoktu — "bu kaydın adsız tek bir konuşmacısı var" gibi okundu.)
Name what the user sees by its outcome, and show its real cost
Technical names leak the implementation and leave the user guessing: "Small / Medium / Large v3 Turbo" tells a person nothing…
EN
## Name what the user sees by its outcome, and show its real cost
Technical names leak the implementation and leave the user guessing: "Small / Medium / Large v3
Turbo" tells a person nothing about which one their recording needs. Name each option for the
**situation it is for**, and put the price beside it.
- **Outcome, not mechanism.** This covers **every name the user sees** — options, status badges,
progress lines, toasts, empty states — not only the items in a picker. Name what the person is
living through, never the step the code is on: *Quick draft — one clear voice in a quiet room* ·
*Best quality — noisy room, accents, technical words*; a job that is warming up reads **Working on
It**, not *Loading Model*. A status label is never a one-to-one translation of an internal state
(`loading`, `processing`): those names stay in the code and never reach the screen.
- **Quantify the cost from measurement, in the unit the user feels** — minutes for this file, not a
parameter count, and computed for the actual input when it is known.
- **Drop the option that is worse on every axis.** If one choice is both slower and less accurate, it
is not a trade-off, it is a wrong answer kept for old data.
- **The same input gives the same number everywhere.** When a factor in the estimate has not been
chosen yet, assume the path that will actually run, not zero: an unanswered question is not a free
step. (2026-09: one panel quoted 46 minutes and another 53 for the same file, because the first had
not yet been told how many speakers there were and silently priced the second pass at nothing.)
TR
## Kullanıcının gördüğü şeyi sonucuyla adlandır ve gerçek maliyetini göster
Teknik adlar uygulamayı sızdırır ve kullanıcıyı tahmine bırakır: "Small / Medium / Large v3 Turbo" bir insana
kaydının hangisine ihtiyacı olduğu konusunda hiçbir şey söylemez. Her seçeneği **ne için olduğu duruma göre**
adlandır ve yanına fiyatını koy.
- **Mekanizma değil sonuç.** Bu, yalnızca bir seçicideki maddeleri değil **kullanıcının gördüğü her adı** kapsar —
seçenekler, durum badge'leri, ilerleme satırları, toast'lar, boş durumlar. Kodun hangi adımda olduğunu değil,
kişinin neyi yaşadığını adlandır: *Hızlı taslak — sessiz bir odada tek net ses* · *En iyi kalite — gürültülü oda,
aksanlar, teknik kelimeler*; ısınmakta olan bir iş *Model Yükleniyor* değil **Üzerinde Çalışıyoruz** diye okunur.
Bir durum etiketi, dahilî bir durumun birebir çevirisi asla değildir (`loading`, `processing`): o adlar kodda kalır
ve ekrana hiç çıkmaz.
- **Maliyeti ölçümden, kullanıcının hissettiği birimle nicelendir** — parametre sayısı değil, bu dosya için dakika;
ve bilindiğinde gerçek girdi için hesaplanmış olarak.
- **Her eksende daha kötü olan seçeneği kaldır.** Bir seçim hem yavaş hem daha az doğruysa, o bir denge değil, eski
veri için tutulmuş yanlış bir cevaptır.
- **Aynı girdi her yerde aynı sayıyı verir.** Tahmindeki bir etken henüz seçilmemişse, sıfırı değil gerçekten
çalışacak yolu varsay: cevaplanmamış bir soru bedava bir adım değildir. (2026-09: aynı dosya için bir panel 46,
diğeri 53 dakika söylüyordu, çünkü ilkine kaç konuşmacı olduğu henüz söylenmemişti ve ikinci geçişi sessizce
sıfır fiyatlamıştı.)
A warning that is not checked against reality becomes a lie
"Downloads 1.6 GB the first time" is correct once and wrong forever after.
EN
## A warning that is not checked against reality becomes a lie
"Downloads 1.6 GB the first time" is correct once and wrong forever after. Any warning about a
one-off cost has to be **conditional on the current state** — check whether the download already
happened and say nothing when it did. A UI that keeps warning about things that cannot happen trains
the reader to skip every warning, including the ones that matter.
TR
## Gerçeğe karşı kontrol edilmeyen bir uyarı yalana dönüşür
"İlk seferde 1,6 GB indirir" bir kez doğrudur ve sonsuza kadar yanlış. Tek seferlik bir maliyetle ilgili her uyarı
**mevcut duruma bağlı** olmalı — indirmenin zaten olup olmadığını kontrol et ve olduysa hiçbir şey söyleme.
Olamayacak şeyler hakkında uyarmaya devam eden bir arayüz, okuyucuya önemli olanlar dahil her uyarıyı atlamayı
öğretir.
Two instances of the same control share one shell
The same job in two places — search inside this record, search across all of them — is one component with two scopes, not two…
EN
## Two instances of the same control share one shell
The same job in two places — search inside this record, search across all of them — is one component
with two scopes, not two designs. Same height, same radius, same border, same shadow; only the label
names the difference. When the two drift apart, a reader sees two different things and stops
believing the smaller one does what the bigger one does.
TR
## Aynı kontrolün iki örneği tek bir kabuğu paylaşır
Aynı iş iki yerde — bu kaydın içinde ara, hepsinin içinde ara — iki tasarım değil, iki kapsamı olan tek bir
bileşendir. Aynı yükseklik, aynı yarıçap, aynı kenarlık, aynı gölge; farkı yalnızca etiket adlandırır. İkisi
birbirinden uzaklaştığında okuyan iki farklı şey görür ve küçük olanın büyük olanın yaptığını yaptığına inanmayı
bırakır.
The control that starts playback stops it
MOVED to audio-player.md — it owns every player rule.
EN
## The control that starts playback stops it
MOVED to [`audio-player.md`](./audio-player.md) — it owns every player rule. See *A transcript beside a
player is one instrument* there.
TR
## Oynatmayı başlatan kontrol onu durdurur
[`audio-player.md`](./audio-player.md) dosyasına TAŞINDI — bütün oynatıcı kuralları o dosyaya aittir. Orada
*A transcript beside a player is one instrument* bölümüne bak.
A translucent surface invalidates measured contrast
Contrast is a property of the composite, not of the two tokens you chose.
EN
## A translucent surface invalidates measured contrast
Contrast is a property of the **composite**, not of the two tokens you chose. Over glass, blur or any
semi-transparent fill, text sits on whatever shows through, so a colour that passes on the nominal
background can fail on the real one. Measure the rendered result; if you cannot, treat the effective
background as the worst case the surface can produce.
(2026-09: a 12px timestamp colour that measured 4.5:1 on white measured **4.2:1** over a 58%-opaque
card, under the floor. Fixed by darkening the token, not by removing the effect.)
TR
## Yarı saydam bir yüzey ölçülmüş kontrastı geçersiz kılar
Kontrast, seçtiğin iki token'ın değil, **bileşiğin** bir özelliğidir. Cam, bulanıklık ya da yarı saydam herhangi
bir dolgunun üstünde metin, arkasından görünen şeyin üstüne oturur; yani nominal arka planda geçen bir renk gerçek
olanda kalabilir. Çizilen sonucu ölç; ölçemiyorsan, etkin arka planı yüzeyin üretebileceği en kötü durum olarak kabul et.
(2026-09: beyaz üzerinde 4,5:1 ölçen 12px'lik bir zaman damgası rengi, %58 opak bir kartın üstünde **4,2:1** ölçtü,
yani tabanın altında. Efekt kaldırılarak değil, token koyulaştırılarak düzeltildi.)
A theme is a token swap, not a rewrite
If every colour, radius, shadow and surface decision lives in one token block, changing the whole visual language is editing…
EN
## A theme is a token swap, not a rewrite
If every colour, radius, shadow and surface decision lives in one token block, changing the whole
visual language is editing that block plus a handful of surface rules. If changing the theme means
touching components, the tokens were never doing their job.
(2026-09, Voice to Text: glassmorphism to claymorphism was one token block, one surface section and
the two fonts — the layout, the components and the behaviour were untouched.)
TR
## Tema bir token takasıdır, yeniden yazım değil
Her renk, yarıçap, gölge ve yüzey kararı tek bir token bloğunda yaşıyorsa, görsel dilin tamamını değiştirmek o bloğu
artı bir avuç yüzey kuralını düzenlemektir. Temayı değiştirmek bileşenlere dokunmak demekse, token'lar zaten işini
hiç yapmıyordu.
(2026-09, Voice to Text: glassmorphism'den claymorphism'e geçiş tek bir token bloğu, tek bir yüzey bölümü ve iki
fonttu — yerleşim, bileşenler ve davranış hiç ellenmedi.)
A range selection draws its own outline
When a user drags to select a range of items — rows, lines, cells, days on a calendar — draw a rounded rectangle from the…
EN
## A range selection draws its own outline
When a user drags to select a range of items — rows, lines, cells, days on a calendar — draw a
**rounded rectangle from the point they pressed, following the pointer**, and light up the items it
covers. Without it they are guessing where the range starts and ends, and they only find out what
they picked after the destructive action.
- **The rectangle starts where the pointer went down and tracks it in every direction.** It is the
user's own gesture drawn back to them, so it must not be snapped to item boundaries or redrawn
somewhere tidier; the moment of the press is when it has to appear.
- **It may start anywhere**, including empty space beside the list. Requiring the drag to begin on an
item makes the gesture fail exactly when the user aims at the gap between two of them.
- **Highlight the covered items live, not just the box.** The rectangle says where the gesture is;
the highlight says what it takes, and an item counts as soon as the box reaches any part of it.
- **Translucent fill, no pointer events.** It frames the content, it must not hide it or swallow
clicks on what it covers.
- **Both stay while the action is offered.** If releasing the drag raises a control ("3 selected ·
Hide"), the outline and the highlight remain until the selection is cleared, so the control and
what is marked always describe the same set. `Esc`, a click outside, or anything that moves the
content under a viewport-anchored box clears all of it together.
- **Where dragging already means something — selecting text — put the box behind a modifier**
(`Shift` + drag) rather than taking the plain drag away. Losing text selection to gain a marquee is
a bad trade; both fit if one of them is qualified.
TR
## Aralık seçimi kendi çerçevesini çizer
Kullanıcı bir öğe aralığını sürükleyerek seçtiğinde — satırlar, çizgiler, hücreler, takvimdeki günler —
**bastığı noktadan başlayan, imleci takip eden yuvarlak köşeli bir dikdörtgen** çiz ve kapsadığı öğeleri yak.
Onsuz, aralığın nerede başlayıp nerede bittiğini tahmin ederler ve ne seçtiklerini ancak yıkıcı aksiyondan sonra
öğrenirler.
- **Dikdörtgen imlecin indiği yerde başlar ve onu her yöne takip eder.** Bu, kullanıcının kendi hareketinin ona geri
çizilmiş hâlidir, yani öğe sınırlarına kilitlenmemeli ya da daha derli toplu bir yere yeniden çizilmemeli;
görünmesi gereken an, basma anıdır.
- **Her yerden başlayabilir**, listenin yanındaki boşluk dahil. Sürüklemenin bir öğenin üstünde başlamasını şart
koşmak, kullanıcı tam da ikisinin arasındaki boşluğu hedeflediğinde hareketi bozar.
- **Kapsanan öğeleri canlı vurgula, sadece kutuyu değil.** Dikdörtgen hareketin nerede olduğunu söyler; vurgu neyi
aldığını söyler ve kutu bir öğenin herhangi bir yerine değdiği anda o öğe sayılır.
- **Yarı saydam dolgu, imleç olayı yok.** İçeriği çerçeveler; onu gizlememeli ya da kapsadığı şeye yapılan
tıklamaları yutmamalı.
- **Aksiyon sunulduğu sürece ikisi de kalır.** Sürüklemeyi bırakmak bir kontrol açıyorsa ("3 seçili · Gizle"),
seçim temizlenene kadar çerçeve ve vurgu kalır, böylece kontrol ile işaretli olan her zaman aynı kümeyi anlatır.
`Esc`, dışarı bir tıklama ya da görüş alanına sabitlenmiş bir kutunun altındaki içeriği oynatan her şey hepsini
birlikte temizler.
- **Sürüklemenin zaten bir anlamı olduğu yerde — metin seçmek — kutuyu bir değiştiriciye bağla** (`Shift` + sürükle),
düz sürüklemeyi elinden almak yerine. Bir seçim dikdörtgeni kazanmak için metin seçimini kaybetmek kötü bir
takastır; biri şarta bağlanırsa ikisi de sığar.
A notice about one row belongs on that row
When an action applies to a single row, card or cell, its result — and the undo that goes with it — renders inside or…
EN
## A notice about one row belongs on that row
When an action applies to a single row, card or cell, its result — and the undo that goes with it —
renders **inside or immediately beside that element**, not in the screen-corner toast. The user's eyes
are on the thing they just changed; a message at the bottom of the window is outside their attention
and gets missed, and the undo inside it is missed with it.
- **The bottom toast keeps the page-level messages** — saved, deleted, exported, copied, a rename that
affects the whole record. Both coexist; they are not alternatives.
- **The notice carries its action**, and while it does it stays longer than a plain confirmation
(an undo nobody can reach is not an undo), and it always has an explicit dismiss control.
- **One at a time.** A second notice replaces the first, so the page never accumulates stale bubbles.
- **Float it over the content, anchored to the row; never insert it into the flow.** A notice pushed
in between rows shifts everything below it, so the list jumps twice — once when it appears and again
when it goes — and the reader loses the line they were looking at. Render it as a popover just under
the element with a caret pointing at it, over the neighbouring rows. Position it inside the scrolling
content so it travels with its row, and outside any clipped container so it is never cut off.
- **Dismiss it like any popover:** a click anywhere outside it, or `Esc`. Clicks inside it must keep
working, or the undo it exists for becomes unreachable. Anything that changes the layout it was
measured against (expanding, collapsing, a reflow) closes it too, since its position is now a lie.
TR
## Tek bir satırla ilgili bildirim o satırın üstüne aittir
Bir aksiyon tek bir satıra, karta ya da hücreye uygulandığında, sonucu — ve yanındaki geri alma — ekranın
köşesindeki toast'ta değil, **o öğenin içinde ya da hemen yanında** çizilir. Kullanıcının gözü az önce
değiştirdiği şeyin üstündedir; pencerenin altındaki bir mesaj onun dikkatinin dışındadır ve kaçırılır, içindeki
geri alma da onunla birlikte kaçırılır.
- **Alttaki toast, sayfa seviyesindeki mesajları tutar** — kaydedildi, silindi, aktarıldı, kopyalandı, kaydın
tamamını etkileyen bir yeniden adlandırma. İkisi bir arada yaşar; alternatif değildirler.
- **Bildirim kendi aksiyonunu taşır** ve taşıdığı sürece düz bir onaydan uzun kalır (kimsenin ulaşamadığı bir geri
alma, geri alma değildir) ve her zaman açık bir kapatma kontrolü bulunur.
- **Aynı anda bir tane.** İkinci bir bildirim birincinin yerini alır, böylece sayfa asla bayat balonlar biriktirmez.
- **İçeriğin üstünde, satıra sabitlenmiş olarak yüzdür; akışa asla sokma.** Satırların arasına sokulan bir bildirim
altındaki her şeyi kaydırır, yani liste iki kez zıplar — belirdiğinde ve gittiğinde — ve okuyan baktığı satırı
kaybeder. Onu öğenin hemen altında, ona işaret eden bir okla, komşu satırların üstünde bir popover olarak çiz.
Satırıyla birlikte hareket etsin diye kaydırılan içeriğin içine, asla kırpılmasın diye kırpılmış her container'ın
dışına konumlandır.
- **Her popover gibi kapat:** dışarıda herhangi bir yere tıklama ya da `Esc`. İçine yapılan tıklamalar çalışmaya
devam etmeli, yoksa var olma sebebi olan geri alma ulaşılamaz hâle gelir. Ölçüldüğü yerleşimi değiştiren her şey
(açılma, kapanma, yeniden akış) onu da kapatır, çünkü konumu artık bir yalandır.
Delete is the trash can, not the word
A control that deletes a record, a row or a card is drawn as the trash icon alone — no "Delete" label beside it.
EN
## Delete is the trash can, not the word
A control that deletes a record, a row or a card is drawn as the **trash icon alone** — no "Delete"
label beside it. The icon is the one destructive mark every user already knows, and a worded button
sitting among labelled actions (Export, Copy, Re-run) reads as just another one of them.
- **Icon only, and it must still have a name**: `title` **and** `aria-label` saying what is deleted
("Delete recording"), per the icon-only rule in *File-type export icons* above.
- **Carry the danger in the colour**, not in extra words: the icon takes the error colour, and its
hover state tints the button.
- **The confirmation keeps its words.** The second step spells out what is about to be lost
("Delete this recording and its transcript?") with a worded **Yes, Delete** — that is the moment a
reader must be told, and an icon cannot say it. The two-step inline confirm itself (never a modal,
never `confirm()`) is specified in *Data Entry Tables → Delete confirmation*.
- This is about the **control**, not the menu: a text item inside an overflow/context menu still
reads "Delete".
TR
## Silme çöp kutusudur, kelime değil
Bir kaydı, satırı ya da kartı silen kontrol **sadece çöp kutusu simgesiyle** çizilir — yanında "Sil" etiketi
olmaz. Simge, her kullanıcının zaten bildiği tek yıkıcı işarettir; etiketli aksiyonların (Export, Copy, Re-run)
arasında duran kelimeli bir buton ise onlardan biri gibi okunur.
- **Yalnız simge, ama yine de bir adı olmalı**: neyin silindiğini söyleyen `title` **ve** `aria-label`
("Kaydı sil"), yukarıdaki *Dosya türü aktarma simgeleri* bölümündeki yalnız-simge kuralına göre.
- **Tehlikeyi renkte taşı**, fazladan kelimede değil: simge hata rengini alır ve hover durumu butonu renklendirir.
- **Onay kelimelerini korur.** İkinci adım neyin kaybedilmek üzere olduğunu açık açık yazar ("Bu kaydı ve
transkriptini sil?") ve kelimeli bir **Yes, Delete** taşır — okuyana söylenmesi gereken an odur ve bir simge bunu
söyleyemez. İki adımlı satır içi onayın kendisi (asla modal, asla `confirm()`) *Veri Giriş Tabloları → Silme onayı*
bölümünde tanımlıdır.
- Bu, **kontrol** hakkındadır, menü hakkında değil: bir taşma/bağlam menüsündeki metin öğesi yine "Delete" diye okunur.
A record's own actions ride in its frozen header
On a screen built around one long record — a transcript, a document, a log — that record's actions (export, copy, re-run,…
EN
## A record's own actions ride in its frozen header
On a screen built around one long record — a transcript, a document, a log — that record's actions
(export, copy, re-run, delete) belong in the **header that freezes under the top bar**, beside its
title and status. Actions parked at the bottom of the card are reachable only by scrolling past the
whole body, which is exactly what the frozen header exists to prevent.
- **The frozen header holds what you need while reading**: title and identity line, the record's own
controls (a player, a search box), and its actions. Everything else stays in the body.
- **Keep it short.** It is a header, not a panel: roughly three rows. If it needs a fourth, something
in it belongs to the body.
- **The title never gets squeezed to fit the buttons.** Let the row wrap so the actions drop to their
own line (give the title block a flex-basis of about 20rem); a title breaking one letter per line
is the tell that the actions took the space.
- **Opaque background, and the separator appears only once stuck** — a transparent sticky header lets
the body bleed through it. Detect the stuck state with a 1px sentinel above the header plus an
`IntersectionObserver`, then add the border/shadow.
- **That opaque header also covers the card's own side lines, so it draws them itself**
(`inset 1px 0 0 <border>` and `inset -1px 0 0 <border>`), **in both states, stuck and not.** The
rule is general: any opaque layer spanning a container's full width covers that container's edge
and has to continue it. It is unavoidable when the card's outline is an inset shadow rather than a
real border, which is how a pinned header gets to own the rounded top corners. Check it by
following the card's left edge from the top of the card to the bottom, once at rest and once
scrolled: it is one unbroken line or it is wrong.
- Data tables freeze only their column header instead; see *Data-table header freezes on page scroll*.
TR
## Bir kaydın kendi aksiyonları donmuş başlığında durur
Tek bir uzun kaydın etrafında kurulmuş bir ekranda — bir transkript, bir doküman, bir log — o kaydın aksiyonları
(aktar, kopyala, yeniden çalıştır, sil) **üst barın altında donan başlığa**, başlığının ve durumunun yanına aittir.
Kartın altına park edilmiş aksiyonlara ancak bütün gövdeyi geçerek kaydırıp ulaşılır, ki donmuş başlığın var olma
sebebi tam olarak bunu engellemektir.
- **Donmuş başlık, okurken ihtiyacın olanı tutar**: başlık ve kimlik satırı, kaydın kendi kontrolleri (bir oynatıcı,
bir arama kutusu) ve aksiyonları. Diğer her şey gövdede kalır.
- **Kısa tut.** O bir başlıktır, panel değil: kabaca üç satır. Dördüncüye ihtiyaç duyuyorsa, içindeki bir şey gövdeye
aittir.
- **Başlık, butonlara yer açmak için asla sıkıştırılmaz.** Satırın sarmasına izin ver ki aksiyonlar kendi satırlarına
insin (başlık bloğuna yaklaşık 20rem'lik bir flex-basis ver); satır başına bir harf düşecek şekilde kırılan bir
başlık, aksiyonların yeri aldığının işaretidir.
- **Opak arka plan, ve ayırıcı ancak yapıştığında belirir** — saydam bir sticky başlık gövdenin içinden geçmesine
izin verir. Yapışmış durumu başlığın üstündeki 1px'lik bir gözcü ve bir `IntersectionObserver` ile tespit et,
sonra kenarlığı/gölgeyi ekle.
- **O opak başlık kartın kendi yan çizgilerini de kapatır, bu yüzden onları kendisi çizer**
(`inset 1px 0 0 <border>` ve `inset -1px 0 0 <border>`), **her iki durumda da, yapışmışken ve değilken.** Kural
geneldir: bir container'ın tam genişliğini kaplayan her opak katman o container'ın kenarını kapatır ve onu
sürdürmek zorundadır. Kartın çerçevesi gerçek bir kenarlık yerine bir iç gölgeyse bu kaçınılmazdır, ki sabitlenmiş
bir başlığın yuvarlak üst köşelere sahip olma yolu da budur. Kartın sol kenarını kartın üstünden altına kadar,
biri beklemede biri kaydırılmış olmak üzere iki kez takip ederek kontrol et: ya tek ve kesintisiz bir çizgidir ya
da yanlıştır.
- Veri tabloları bunun yerine sadece kolon başlığını dondurur; bkz. *Veri tablosu başlığı sayfa kaydırılırken donar*.
Date columns sort DESCENDING — newest first, always
Any table with a date/time column (a date, a year, a month, a period) shows the newest row first.
EN
## Date columns sort DESCENDING — newest first, always
Any table with a date/time column (a date, a year, a month, a period) shows the **newest row first**.
Nobody opens a table of dates to read the oldest one; the current period is what the reader came for,
and an ascending list buries it at the bottom, off-screen on a long table.
- **Default sort:** if a date column is the table's natural ordering, it is the default sort and it
is **descending**. This outranks the "sort by the primary measure descending" default — a date
column wins over a measure when both are present and the table is a chronology.
- **First click on a date header sorts descending too** (then ascending on the second click), like a
measure and unlike a text column, which starts A–Z.
- **Year and month columns count as dates.** `2026, 2025, 2024…`, not `2022, 2023, 2024…`.
- **The paired CHART stays chronological** (oldest → newest, left to right). A time series read
right-to-left is unreadable, so the table and its chart deliberately run in opposite directions —
that is correct, not an inconsistency to "fix".
- Exception: a table that is explicitly a forward-looking schedule (upcoming deadlines, planned
releases) sorts **ascending**, because there the nearest future date is the one that matters.
TR
## Tarih kolonları AZALAN sıralanır — her zaman en yeni önce
Tarih/saat kolonu olan her tablo **en yeni satırı en üstte** gösterir. Kimse bir tarih tablosunu en eskisini
okumak için açmaz; okuyanın geldiği şey mevcut dönemdir ve artan bir liste onu en dibe gömer, uzun bir tabloda
ekranın dışına.
- **Varsayılan sıralama:** bir tarih kolonu tablonun doğal sıralamasıysa, varsayılan sıralama odur ve **azalandır**.
Bu, "birincil ölçüye göre azalan sırala" varsayılanını yener — ikisi de varsa ve tablo bir kronolojiyse tarih
kolonu ölçüyü yener.
- **Bir tarih başlığına ilk tıklama da azalan sıralar** (ikinci tıklamada artan), bir ölçü gibi ve A–Z ile başlayan
bir metin kolonunun aksine.
- **Yıl ve ay kolonları tarih sayılır.** `2026, 2025, 2024…`, `2022, 2023, 2024…` değil.
- **Eşlenik GRAFİK kronolojik kalır** (eskiden yeniye, soldan sağa). Sağdan sola okunan bir zaman serisi okunamaz,
bu yüzden tablo ve grafiği bilerek ters yönlerde akar — bu doğrudur, "düzeltilecek" bir tutarsızlık değil.
- İstisna: açıkça ileriye dönük bir takvim olan bir tablo (yaklaşan son tarihler, planlanmış sürümler) **artan**
sıralanır, çünkü orada önemli olan en yakın gelecek tarihtir.
Numeric table columns — always right-aligned
Any table column whose content is a number (currency, percentage, count, ratio, score, etc.) must be right-aligned in both the…
EN
## Numeric table columns — always right-aligned
Any table column whose content is a number (currency, percentage, count, ratio, score, etc.)
must be **right-aligned in both the header and every data cell**. Never use `text-center` for
a numeric column header while the cells are `text-right` — header and data must match.
- Header: `text-right` (e.g. `<th className="... text-right">` or `align="right"` on SortTh)
- Cell: `text-right font-mono` (monospace keeps digit widths consistent for scanning)
- Left-aligned columns: text labels only (names, descriptions, categories)
- Exception: a lone icon or badge that isn't a scannable number may be centered
TR
## Sayısal tablo kolonları — her zaman sağa hizalı
İçeriği sayı olan her tablo kolonu (para, yüzde, adet, oran, skor vb.) **hem başlıkta hem her veri hücresinde
sağa hizalanmalı**. Hücreler `text-right` iken sayısal bir kolon başlığında asla `text-center` kullanma —
başlık ve veri uyuşmalı.
- Başlık: `text-right` (ör. `<th className="... text-right">` ya da SortTh üzerinde `align="right"`)
- Hücre: `text-right font-mono` (mono, taramak için rakam genişliklerini tutarlı tutar)
- Sola hizalı kolonlar: sadece metin etiketleri (adlar, açıklamalar, kategoriler)
- İstisna: taranabilir bir sayı olmayan tek bir simge ya da badge ortalanabilir
Stacked multi-field table cells
When a table column contains multiple related fields for the same entity, stack them vertically inside a single <td> instead…
EN
## Stacked multi-field table cells
When a table column contains multiple related fields for the same entity, stack them
vertically inside a single `<td>` instead of creating a separate column per field.
This frees horizontal space for more important data columns.
**When to apply:**
- Person/employee columns: name + email + title + code + country in one cell
- Org-unit columns: department + sub-department + team in one cell
- Any set of ≥ 2 tightly-related fields that belong to the same entity
**Implementation pattern (Tailwind):**
```tsx
<td className="px-2 py-1.5">
<div className="flex flex-col gap-px">
{/* Primary field */}
<span className="text-[11px] font-medium text-text-primary truncate leading-[1.3]">{primary}</span>
{/* Secondary fields */}
<span className="text-[9px] text-text-muted font-mono truncate leading-[1.3]">{secondary}</span>
{/* Inline grouped items on the same row (e.g. code + country) */}
<div className="flex items-center gap-1.5 leading-[1.3]">
<span className="text-[9px] text-text-muted font-mono">{code}</span>
<span className="text-[9px] text-text-muted font-mono">{country}</span>
</div>
</div>
</td>
```
**Typography scale within a stacked cell:**
- Primary field: `text-[11px] font-medium text-text-primary`
- Secondary fields: `text-[9px] text-text-muted` (add `font-mono` for codes/IDs)
- Line-height: `leading-[1.3]` throughout to keep rows compact
- Spacing between lines: `gap-px` in the flex column container
**Sticky + opaque background:** if the stacked cell is `position: sticky`, its background
must be fully opaque (`bg-bg-surface group-hover:bg-bg-elevated`, no `/opacity` suffix)
to prevent scrolled content from bleeding through.
TR
## Çok alanlı, üst üste tablo hücreleri
Bir tablo kolonu aynı varlığa ait birden fazla ilişkili alan içeriyorsa, alan başına ayrı bir kolon açmak yerine
onları tek bir `<td>` içinde dikey olarak üst üste diz. Bu, daha önemli veri kolonlarına yatay yer açar.
**Ne zaman uygulanır:**
- Kişi/çalışan kolonları: tek hücrede ad + e-posta + unvan + kod + ülke
- Organizasyon birimi kolonları: tek hücrede departman + alt departman + ekip
- Aynı varlığa ait, sıkıca ilişkili ≥ 2 alandan oluşan her küme
**Uygulama kalıbı (Tailwind):**
```tsx
<td className="px-2 py-1.5">
<div className="flex flex-col gap-px">
{/* Primary field */}
<span className="text-[11px] font-medium text-text-primary truncate leading-[1.3]">{primary}</span>
{/* Secondary fields */}
<span className="text-[9px] text-text-muted font-mono truncate leading-[1.3]">{secondary}</span>
{/* Inline grouped items on the same row (e.g. code + country) */}
<div className="flex items-center gap-1.5 leading-[1.3]">
<span className="text-[9px] text-text-muted font-mono">{code}</span>
<span className="text-[9px] text-text-muted font-mono">{country}</span>
</div>
</div>
</td>
```
**Üst üste hücre içindeki tipografi ölçeği:**
- Birincil alan: `text-[11px] font-medium text-text-primary`
- İkincil alanlar: `text-[9px] text-text-muted` (kod/ID'ler için `font-mono` ekle)
- Satır yüksekliği: satırları sıkı tutmak için baştan sona `leading-[1.3]`
- Satır arası boşluk: flex kolon container'ında `gap-px`
**Sticky + opak arka plan:** üst üste dizilmiş hücre `position: sticky` ise, arka planı tamamen opak olmalı
(`bg-bg-surface group-hover:bg-bg-elevated`, `/opacity` eki yok) ki kaydırılan içerik içinden geçmesin.
Employee/people search boxes (typeahead)
MOVED to design-filters.md (2026-08-25, owner approval) — design-filters.md owns filter, search and lookup mechanics.
EN
## Employee/people search boxes (typeahead)
MOVED to [`design-filters.md`](./design-filters.md) (2026-08-25, owner approval) — `design-filters.md` owns filter,
search and lookup mechanics. See the section of the same name there.
TR
## Çalışan/kişi arama kutuları (typeahead)
[`design-filters.md`](./design-filters.md) dosyasına TAŞINDI (2026-08-25, sahip onayı) — filtre, arama ve lookup
mekaniği `design-filters.md` dosyasına aittir. Orada aynı adlı bölüme bak.
Person identifiers — title-case the dot-separated name
When a person is shown by a dot-separated identifier — a full email, a username, or a dotted name — title-case only the name…
EN
## Person identifiers — title-case the dot-separated name
When a person is shown by a dot-separated identifier — a full **email**, a **username**, or a dotted
**name** — title-case **only the name part** (the local part before `@`), capitalizing the first
letter of each dot-separated segment and **keeping the dots and the whole `@domain` intact**:
`cevat.erkibas@afiniti.com` → `Cevat.Erkibas@afiniti.com`, `mehmet.ali.yilmaz@afiniti.com` →
`Mehmet.Ali.Yilmaz@afiniti.com`, a bare `qa.bot` → `Qa.Bot`. It generalizes to any number of
segments — title-case the first letter of every token split on `.` in the name part.
- **Do NOT strip the `@domain`** — show the full email; only the name before the `@` is
title-cased, the domain is left exactly as-is.
- **Display-only.** Never mutate the underlying value used as a key, filter value, join key, or
export cell — those keep the raw lowercased identifier (the Excel-export raw-value rule still
wins: exports carry the whole raw email/username).
- Apply wherever a person's identity is surfaced from such an identifier: table cells, filter
option lists, chart labels/legends, chips, tooltips.
- Only title-case; do not otherwise rewrite the token (don't replace dots with spaces, don't
expand/abbreviate) unless a specific screen's rule says so.
TR
## Kişi tanımlayıcıları — noktayla ayrılmış adı Title Case yap
Bir kişi noktayla ayrılmış bir tanımlayıcıyla gösterildiğinde — tam bir **e-posta**, bir **kullanıcı adı** ya da
noktalı bir **ad** — **sadece ad kısmını** (`@` öncesindeki yerel kısmı) Title Case yap; noktayla ayrılmış her
parçanın ilk harfini büyüt ve **noktaları ve bütün `@domain` kısmını olduğu gibi koru**:
`cevat.erkibas@afiniti.com` → `Cevat.Erkibas@afiniti.com`, `mehmet.ali.yilmaz@afiniti.com` →
`Mehmet.Ali.Yilmaz@afiniti.com`, yalın bir `qa.bot` → `Qa.Bot`. Kaç parça olursa olsun genelleşir — ad kısmında
`.` ile bölünmüş her parçanın ilk harfini büyüt.
- **`@domain` kısmını SÖKME** — tam e-postayı göster; sadece `@` öncesindeki ad Title Case yapılır, alan adı
tam olarak olduğu gibi bırakılır.
- **Sadece gösterim için.** Anahtar, filtre değeri, birleştirme anahtarı ya da aktarma hücresi olarak kullanılan
asıl değeri asla değiştirme — onlar ham, küçük harfli tanımlayıcıyı korur (Excel aktarımının ham değer kuralı
hâlâ kazanır: aktarmalar tam ham e-postayı/kullanıcı adını taşır).
- Bir kişinin kimliğinin böyle bir tanımlayıcıdan yüzeye çıktığı her yerde uygula: tablo hücreleri, filtre seçenek
listeleri, grafik etiketleri/açıklamaları, chip'ler, tooltip'ler.
- Sadece Title Case yap; belirli bir ekranın kuralı öyle demiyorsa token'ı başka türlü yeniden yazma (noktaları
boşlukla değiştirme, açma/kısaltma yapma).
Design for the CSS viewport, not the monitor
Desktop density only; phone and touch width belong to mobile-design.md.
EN
## Design for the CSS viewport, not the monitor
*Desktop density only; phone and touch width belong to [`mobile-design.md`](./mobile-design.md).*
**Windows display scaling at 125% is the OEM default on most laptops, and it turns a 1920px monitor
into a 1536px CSS viewport** (`devicePixelRatio` 1.25). A layout sized for ~1900 then fills that
viewport edge to edge with everything drawn 25% larger — it reads as a magnified desktop rather than
a page designed for the screen. 1366 and 1440px laptops at 100% land in the same place, so this is
most of our users, not an edge case.
**Step density down at `width <= 1600px`. Do not reach for `zoom`.** The two are not
interchangeable, and picking the wrong one is the usual mistake:
| Tool | What it gives back | Use it when |
|---|---|---|
| Root `font-size` | **Density** — every rem-based size steps down; the viewport stays 1536 | The layout is fluid and may keep margins at the edges. **Almost everything we build.** |
| `zoom` on the root | **Viewport width** — 0.8 makes the page lay out as if it had 1920 CSS px again | The layout has a fixed width that MUST fit the screen horizontally. Dashboards only — see [`design-dashboard.md`](./design-dashboard.md) → "Windows-scaling normalizer". |
Density is what a fluid layout is short of; width is what a fixed one is short of. Reaching for
`zoom` in a fluid app also drags the 10–13px labels below the readability floor, which the density
step deliberately leaves alone.
**Whichever you use, never touch `dpr >= 2.0`.** A 4K/Retina user runs 150–200% precisely so the UI is
readable, and their CSS viewport is already wide — shrinking them is pure loss.
**Size the step to the gap, and settle it by looking, not by arithmetic.** 125% needs `0.8` to match
the 100% layout exactly. A timid step is worse than none: it costs a media query and the page still
reads as magnified. Write the ratio you land on into the stylesheet, or the next person re-tunes it
blind.
Expect to close nearly the whole gap. The instinct to hold a few percent back "because the reader
chose a larger UI" does not survive a side-by-side — they chose a larger *rendering*, which they
still get; the layout was never the thing they asked to be bigger. (2026-08-11, Organisation
Explorer: 16 → 15px = `0.94` moved almost nothing and left every px display size behind; 16 → 13.5px
= `0.84` was better but still read loose beside the 100% plate; **16 → 13px = `0.81`** is what
shipped.)
- **Drive it from ONE place — the root `font-size`, and nothing else.** Tailwind's whole spacing
scale is rem-based, so one declaration steps every gap and padding at once. The media query should
end up a single line; if it is growing a list of per-element overrides, those elements are in px
and belong in the next bullet instead.
- **Convert EVERY px value the layout depends on to rem — starting with the shell's `max-width`.**
Not a list of the usual suspects: grep the project for `px` and decide each one, because whatever
you skip silently opts out of the whole system. The **page shell's max-width is the single most
important one** and the easiest to forget, since it is written once in an app-shell file rather
than in the components you are looking at. Then the display sizes (page titles, KPI numbers, chart
heights) and any layout `min-width`.
**Why the shell outranks the type:** a fixed `1500px` shell is 78% of a 1920 viewport and reads as
designed; the same 1500px is **98%** of the 1536 viewport a 125% display reports, so the content
runs to both edges. Every font and gap inside it can shrink correctly and the page still reads as
too big — worse, cards get *wider*, because the same column count divides a proportionally wider
shell. What a reader perceives is the share of the screen the content takes, not its type scale.
(2026-08-11, Organisation Explorer: shipped exactly this. The type was converted, the shell was
not, and the user's verdict was "still too big and it fills the screen" — correct, against a
passing test suite.)
- **Do not shrink small text.** 10–13px labels and body copy are already at the readability floor —
leave them in px deliberately. The excess is in whitespace and display sizes, not in body copy, and
at 125% an 11px label is still rendering at ~14 physical px.
- **Assert the RATIO, not the knob.** The test that matters measures the content shell's width as a
**share of the viewport** and requires the same share at both scales (~0.78 either way, a band of
roughly 0.72–0.86). That is the only assertion no px value can satisfy by accident, and it is the
thing the reader actually perceives.
Asserting the computed root `font-size` is worth keeping — every rem on the page hangs off it — but
understand what it proves: that the knob was **turned**, not that anything downstream **listened**.
A suite asserting only the root font-size and no horizontal overflow passes green against a layout
a user can see is wrong, which is exactly how the shell-width bug above shipped.
- **Emulate with BOTH numbers.** 1920×1080 @1 and 1536×864 @1.25 — `deviceScaleFactor` alone changes
nothing CSS can see, and the viewport alone misses the DPI. Screenshots on top, for the judgement a
test can't make.
TR
## Monitöre değil, CSS viewport'una göre tasarla
*Sadece masaüstü yoğunluğu; telefon ve dokunma genişliği [`mobile-design.md`](./mobile-design.md) dosyasına aittir.*
**Windows'ta %125 ekran ölçeklemesi çoğu dizüstünde OEM varsayılanıdır ve 1920px'lik bir monitörü 1536px'lik bir
CSS viewport'una çevirir** (`devicePixelRatio` 1,25). ~1900'e göre boyutlanmış bir yerleşim o viewport'u kenardan
kenara doldurur ve her şey %25 büyük çizilir — ekran için tasarlanmış bir sayfa değil, büyütülmüş bir masaüstü gibi
okunur. %100'deki 1366 ve 1440px'lik dizüstüler de aynı yere düşer, yani bu uç bir durum değil, kullanıcılarımızın
çoğu.
**`width <= 1600px` noktasında yoğunluğu bir kademe düşür. `zoom`'a uzanma.** İkisi birbirinin yerine geçmez ve
yanlışını seçmek alışılmış hatadır:
| Araç | Ne geri verir | Ne zaman kullanılır |
|---|---|---|
| Kök `font-size` | **Yoğunluk** — rem tabanlı her boyut bir kademe iner; viewport 1536 kalır | Yerleşim akışkansa ve kenarlarda boşluk kalabiliyorsa. **Kurduğumuz neredeyse her şey.** |
| Kökte `zoom` | **Viewport genişliği** — 0,8, sayfayı yine 1920 CSS px'i varmış gibi yerleştirir | Yerleşimin, ekrana yatay olarak sığMASI ZORUNLU sabit bir genişliği varsa. Sadece dashboard'lar — bkz. [`design-dashboard.md`](./design-dashboard.md) → "Windows-scaling normalizer". |
Akışkan bir yerleşimin eksiği yoğunluktur; sabit olanınki genişliktir. Akışkan bir uygulamada `zoom`'a uzanmak ayrıca
10–13px'lik etiketleri okunabilirlik tabanının altına çeker, ki yoğunluk kademesi onlara bilerek dokunmaz.
**Hangisini kullanırsan kullan, `dpr >= 2.0`'a asla dokunma.** Bir 4K/Retina kullanıcısı arayüz okunabilir olsun diye
tam da %150–200 çalıştırır ve CSS viewport'u zaten geniştir — onları küçültmek saf kayıptır.
**Kademeyi farka göre boyutlandır ve aritmetikle değil bakarak karara bağla.** %125, %100 yerleşimine birebir uymak
için `0,8` ister. Çekingen bir kademe, hiç olmamasından kötüdür: bir medya sorgusuna mal olur ve sayfa yine
büyütülmüş okunur. Vardığın oranı stil sayfasına yaz, yoksa bir sonraki kişi onu körlemesine yeniden ayarlar.
Farkın neredeyse tamamını kapatmayı bekle. "Okuyucu daha büyük bir arayüz seçti" diye birkaç puan geride tutma
içgüdüsü yan yana karşılaştırmadan sağ çıkmaz — onlar daha büyük bir *çizim* seçtiler ve onu almaya devam ediyorlar;
büyütülmesini istedikleri şey yerleşim hiç değildi. (2026-08-11, Organisation Explorer: 16 → 15px = `0,94` neredeyse
hiçbir şeyi oynatmadı ve her px gösterim boyutunu geride bıraktı; 16 → 13,5px = `0,84` daha iyiydi ama %100'lük
plakanın yanında hâlâ gevşek okunuyordu; çıkan sürüm **16 → 13px = `0,81`** oldu.)
- **Bunu TEK bir yerden sür — kök `font-size`, başka hiçbir şey.** Tailwind'in bütün boşluk ölçeği rem tabanlıdır,
yani tek bir bildirim her boşluğu ve dolguyu aynı anda bir kademe indirir. Medya sorgusu tek satırda bitmeli;
öğe başına geçersiz kılmalardan oluşan bir listeye dönüşüyorsa, o öğeler px cinsindendir ve sıradaki maddeye aittir.
- **Yerleşimin dayandığı HER px değerini rem'e çevir — kabuğun `max-width` değerinden başlayarak.** Alışılmış
şüphelilerin listesi değil: projede `px` ara ve her birine ayrı karar ver, çünkü atladığın her ne varsa sistemin
tamamından sessizce çıkar. **Sayfa kabuğunun max-width değeri bunların en önemlisidir** ve unutulması en kolayıdır,
çünkü baktığın bileşenlerde değil, bir uygulama kabuğu dosyasında bir kez yazılıdır. Sonra gösterim boyutları
(sayfa başlıkları, KPI sayıları, grafik yükseklikleri) ve yerleşimdeki her `min-width`.
**Kabuğun tipografiyi neden yendiği:** sabit bir `1500px` kabuk, 1920'lik bir viewport'un %78'idir ve tasarlanmış
gibi okunur; aynı 1500px, %125'lik bir ekranın bildirdiği 1536'lık viewport'un **%98'idir**, yani içerik iki kenara
da dayanır. İçindeki her font ve boşluk doğru küçülebilir ve sayfa yine de fazla büyük okunur — daha kötüsü,
kartlar *genişler*, çünkü aynı kolon sayısı orantılı olarak daha geniş bir kabuğu böler. Okuyanın algıladığı şey,
içeriğin kapladığı ekran payıdır, tipografi ölçeği değil.
(2026-08-11, Organisation Explorer: tam olarak bu çıktı. Tipografi çevrildi, kabuk çevrilmedi ve kullanıcının
hükmü "hâlâ çok büyük ve ekranı dolduruyor" oldu — geçen bir test paketine rağmen doğru.)
- **Küçük metni küçültme.** 10–13px'lik etiketler ve gövde metni zaten okunabilirlik tabanındadır — onları bilerek
px cinsinde bırak. Fazlalık gövde metninde değil, boşlukta ve gösterim boyutlarındadır; üstelik %125'te 11px'lik
bir etiket hâlâ ~14 fiziksel px çizilir.
- **Düğmeyi değil, ORANI doğrula.** Önemli olan test, içerik kabuğunun genişliğini **viewport'un bir payı olarak**
ölçer ve her iki ölçekte de aynı payı şart koşar (iki yönde de ~0,78, kabaca 0,72–0,86 bandı). Hiçbir px değerinin
kazara karşılayamayacağı tek iddia budur ve okuyanın gerçekten algıladığı şey de odur.
Hesaplanmış kök `font-size` değerini doğrulamak korunmaya değer — sayfadaki her rem ona asılıdır — ama neyi
kanıtladığını anla: düğmenin **çevrildiğini**, aşağıdaki hiçbir şeyin **dinlediğini** değil. Sadece kök font
boyutunu ve yatay taşma olmadığını doğrulayan bir paket, kullanıcının yanlış olduğunu görebildiği bir yerleşime
karşı yeşil geçer; yukarıdaki kabuk genişliği hatası tam olarak böyle çıktı.
- **HER İKİ sayıyla da emüle et.** 1920×1080 @1 ve 1536×864 @1,25 — tek başına `deviceScaleFactor` CSS'in görebileceği
hiçbir şeyi değiştirmez, tek başına viewport ise DPI'ı kaçırır. Üstüne ekran görüntüleri, bir testin veremeyeceği
hüküm için.
Consistent page width across a project
Every top-level page in a project shares the same content width — the app shell defines one max-width container (e.g.
EN
## Consistent page width across a project
Every top-level page in a project shares the **same content width** — the app shell defines
one max-width container (e.g. `max-w-[1400px]` centered) and pages render into it at full
width. Do NOT narrow individual pages with their own `max-w-2xl` / `max-w-3xl` wrappers; a
page that opens noticeably narrower than the others reads as broken. Keep list, form, detail,
and result pages the same width unless the user explicitly asks for a narrower reading column
on a specific page. (Exception: true overlays/modals may cap their own width — that is a
dialog, not a page.) If a form looks lost at full width, constrain individual fields/cards,
not the whole page.
TR
## Proje genelinde tutarlı sayfa genişliği
Bir projedeki her üst seviye sayfa **aynı içerik genişliğini** paylaşır — uygulama kabuğu tek bir max-width
container tanımlar (ör. ortalanmış `max-w-[1400px]`) ve sayfalar onun içine tam genişlikte çizilir. Sayfaları
kendi `max-w-2xl` / `max-w-3xl` sarmalayıcılarıyla tek tek daraltma; diğerlerinden gözle görülür biçimde dar
açılan bir sayfa bozuk okunur. Kullanıcı belirli bir sayfada açıkça daha dar bir okuma kolonu istemedikçe liste,
form, detay ve sonuç sayfalarını aynı genişlikte tut. (İstisna: gerçek katmanlar/modal'lar kendi genişliklerini
sınırlayabilir — o bir diyalogdur, sayfa değil.) Bir form tam genişlikte kaybolmuş görünüyorsa, bütün sayfayı
değil tek tek alanları/kartları sınırla.
Hover dropdowns: no dead-zone, forgiving close
Any menu/panel that opens on hover (dropdowns, flyouts, mega-menus) must never close while the user travels from the trigger…
EN
## Hover dropdowns: no dead-zone, forgiving close
Any menu/panel that opens on hover (dropdowns, flyouts, mega-menus) must never close while the
user travels from the trigger to the panel:
- **No hover dead-zone**: if there is a visual gap between trigger and panel, bridge it with an
invisible hover area — but the bridge MUST live on an element that stays in the DOM, i.e. the
**trigger or its wrapper**, never a `::before`/`::after` on the panel itself. A panel that is
`display:none` when closed takes its own pseudo-element with it, so a bridge placed on the panel
vanishes exactly when the cursor is in the gap (the classic "menu disappears mid-travel" bug).
Reliable pattern: put `padding-bottom:Npx` on the trigger's wrapper (a positioned ancestor of both
trigger and panel) and anchor the panel at `top:100%` — the padding is always present, hoverable,
and keeps `:hover` alive across the gap. The visual gap stays; the hover target is continuous.
- **Close grace period (~200ms)**: closing gets a short delay (`transition-delay` on the hidden
state, `0s` on the open state) so brief diagonal cursor excursions don't snap the menu shut;
opening stays instant.
Losing the menu in that gap is one of the most user-hostile UI bugs — never ship a hover menu
without both protections.
TR
## Hover dropdown'ları: ölü bölge yok, bağışlayıcı kapanma
Hover ile açılan her menü/panel (dropdown, flyout, mega menü), kullanıcı tetikleyiciden panele giderken asla
kapanmamalı:
- **Hover ölü bölgesi yok**: tetikleyici ile panel arasında görsel bir boşluk varsa, onu görünmez bir hover
alanıyla köprüle — ama köprü, DOM'da kalan bir öğede, yani **tetikleyicide ya da sarmalayıcısında** yaşamalı,
asla panelin kendisindeki bir `::before`/`::after` üzerinde değil. Kapalıyken `display:none` olan bir panel
kendi sözde öğesini de yanında götürür, yani panele konmuş bir köprü tam olarak imleç boşluktayken kaybolur
(klasik "menü yol ortasında kayboluyor" hatası). Güvenilir kalıp: tetikleyicinin sarmalayıcısına (hem
tetikleyicinin hem panelin konumlandırılmış atası) `padding-bottom:Npx` koy ve paneli `top:100%` konumuna
sabitle — boşluk her zaman vardır, hover edilebilir ve `:hover` durumunu aradaki boşluk boyunca canlı tutar.
Görsel boşluk yerinde kalır; hover hedefi kesintisizdir.
- **Kapanma müsamaha süresi (~200ms)**: kapanmaya kısa bir gecikme verilir (gizli durumda `transition-delay`,
açık durumda `0s`) ki imlecin kısa çapraz sapmaları menüyü aniden kapatmasın; açılma anlık kalır.
Menüyü o boşlukta kaybetmek en kullanıcı düşmanı arayüz hatalarından biridir — her iki koruma olmadan bir hover
menüsünü asla çıkarma.
Card hover-lift: never let the lift un-hover itself
A card/tile "lift on hover" (transform: translateY(-Npx) on :hover) is a sibling instability to the dropdown dead-zone above:…
EN
## Card hover-lift: never let the lift un-hover itself
A card/tile "lift on hover" (`transform: translateY(-Npx)` on `:hover`) is a sibling instability to
the dropdown dead-zone above: if the cursor is resting in the card's bottom few pixels, the lift
moves the card away from the cursor, `:hover` drops, the card falls back under the cursor,
`:hover` re-fires — a visible, continuous jitter for as long as the pointer sits there (found on
an AI First dashboard: any card whose bottom edge the mouse happened to rest on shook indefinitely).
- **The move itself must not gate on live `:hover`.** Keep `:hover` for shadow/border feedback
(those don't change geometry, so they're safe), but drive the transform from a separate class
(e.g. `.lifted`) toggled by JS with **hysteresis**: once lifted, stay lifted while the pointer is
anywhere inside the card's LIFTED bounding box or the strip it just vacated (a few px margin
below/around); only drop `.lifted` when the pointer truly leaves that region. One document-level
`mouseover`/`mousemove` pair tracking a single "currently lifted" element is enough — no per-card
listeners needed.
- Removing the lift entirely (shadow/border-only feedback) is an acceptable fallback if hysteresis
isn't worth the code, but a genuinely "dead" hover with no lift on hover-capable devices reads as
less polished — try hysteresis first before giving up the motion.
TR
## Kart hover kalkışı: kalkış kendi hover'ını asla bozmasın
Bir kart/döşeme "hover'da kalkışı" (`:hover` üzerinde `transform: translateY(-Npx)`), yukarıdaki dropdown ölü
bölgesinin kardeş kararsızlığıdır: imleç kartın alt birkaç pikselinde duruyorsa, kalkış kartı imleçten uzaklaştırır,
`:hover` düşer, kart imlecin altına geri iner, `:hover` yeniden ateşlenir — işaretçi orada durduğu sürece görünür ve
kesintisiz bir titreme (bir AI First dashboard'unda bulundu: farenin alt kenarında durduğu her kart sonsuza kadar
titriyordu).
- **Hareketin kendisi canlı `:hover` durumuna bağlanmamalı.** `:hover`'ı gölge/kenarlık geri bildirimi için koru
(bunlar geometriyi değiştirmez, yani güvenlidir), ama dönüşümü JS ile **histerezisli** olarak açılıp kapanan ayrı
bir sınıftan (ör. `.lifted`) sür: bir kez kalktıysa, işaretçi kartın KALKMIŞ sınırlayıcı kutusunun ya da az önce
boşalttığı şeridin herhangi bir yerindeyken kalkmış kalsın (altında/etrafında birkaç px pay); `.lifted` ancak
işaretçi o bölgeden gerçekten çıktığında düşsün. Tek bir "şu an kalkmış" öğeyi izleyen, document seviyesinde tek
bir `mouseover`/`mousemove` çifti yeter — kart başına dinleyici gerekmez.
- Histerezis koda değmiyorsa kalkışı tamamen kaldırmak (sadece gölge/kenarlık geri bildirimi) kabul edilebilir bir
yedektir, ama hover yapabilen cihazlarda kalkışsız, gerçekten "ölü" bir hover daha az özenli okunur — hareketten
vazgeçmeden önce histerezisi dene.
Popover / tooltip content — fully visible, no inner scroll
A hover/click popover or tooltip must show its content in full, with no scrollbar of its own — on neither axis.
EN
## Popover / tooltip content — fully visible, no inner scroll
A hover/click popover or tooltip must show its content **in full, with no scrollbar of its own** — on
neither axis. Apply both fixes together:
- **No vertical clip:** do not cap it with a fixed `max-height` + `overflow:auto` that hides content
behind an inner scrollbar. Let the panel grow to its content (size it by widening the panel, not by
clipping its height). Fall back to an inner scroll only for genuinely unbounded content.
- **No horizontal scroll:** long unbreakable strings (file paths, URLs) must wrap, not trigger a
sideways scrollbar. Use `white-space:pre-wrap` (keeps intentional line breaks, wraps the rest) plus
`overflow-wrap:anywhere` so a long token can break mid-string. Widen the panel a little to reduce
ugly wraps before relying on the break.
The reader should take in the whole panel at a glance; an inner scrollbar inside a small floating
panel is the anti-pattern to avoid.
TR
## Popover / tooltip içeriği — tamamen görünür, iç kaydırma yok
Hover/tıklama ile açılan bir popover veya tooltip içeriğini **tam olarak, kendi kaydırma çubuğu olmadan**
göstermeli — hiçbir eksende. İki düzeltmeyi birlikte uygula:
- **Dikey kırpma yok:** onu, içeriği bir iç kaydırma çubuğunun arkasına gizleyen sabit bir `max-height` +
`overflow:auto` ile sınırlama. Panelin içeriğine göre büyümesine izin ver (yüksekliğini kırparak değil, paneli
genişleterek boyutlandır). İç kaydırmaya ancak gerçekten sınırsız içerik için düş.
- **Yatay kaydırma yok:** uzun ve bölünemez dizgeler (dosya yolları, URL'ler) yana kaydırma çubuğu tetiklemek yerine
alta sarmalı. `white-space:pre-wrap` (kasıtlı satır sonlarını korur, gerisini sarar) artı `overflow-wrap:anywhere`
kullan ki uzun bir token dizge ortasından bölünebilsin. Bölmeye güvenmeden önce çirkin sarmaları azaltmak için
paneli biraz genişlet.
Okuyan bütün paneli bir bakışta almalı; küçük ve yüzen bir panelin içindeki kaydırma çubuğu kaçınılacak anti-kalıptır.
Time Range dropdown — group ordering
MOVED to design-filters.md (2026-08-25, owner approval) — design-filters.md owns filter, search and lookup mechanics.
EN
## Time Range dropdown — group ordering
MOVED to [`design-filters.md`](./design-filters.md) (2026-08-25, owner approval) — `design-filters.md` owns filter,
search and lookup mechanics. See the section of the same name there.
TR
## Zaman Aralığı dropdown'ı — grup sıralaması
[`design-filters.md`](./design-filters.md) dosyasına TAŞINDI (2026-08-25, sahip onayı) — filtre, arama ve lookup
mekaniği `design-filters.md` dosyasına aittir. Orada aynı adlı bölüme bak.
Column value-filter popover (funnel on the header)
MOVED to design-filters.md (2026-08-25, owner approval) — design-filters.md owns filter, search and lookup mechanics.
EN
## Column value-filter popover (funnel on the header)
MOVED to [`design-filters.md`](./design-filters.md) (2026-08-25, owner approval) — `design-filters.md` owns filter,
search and lookup mechanics. See the section of the same name there.
TR
## Kolon değer filtresi popover'ı (başlıktaki huni)
[`design-filters.md`](./design-filters.md) dosyasına TAŞINDI (2026-08-25, sahip onayı) — filtre, arama ve lookup
mekaniği `design-filters.md` dosyasına aittir. Orada aynı adlı bölüme bak.
Line charts — data-point labels
Every line chart shows its data-point value labels (small, bold, coloured to the line, aligned above the point; auto-hide…
EN
## Line charts — data-point labels
Every line chart shows its data-point value labels (small, bold, coloured to the line, aligned above
the point; auto-hide overlapping labels on dense series). Reserve a little top padding so the topmost
label isn't clipped. Bar charts keep their own value/percentage labels; composition doughnuts keep
theirs — the rule is specifically about **line** charts.
TR
## Çizgi grafikler — veri noktası etiketleri
Her çizgi grafik veri noktası değer etiketlerini gösterir (küçük, kalın, çizginin rengiyle, noktanın üstüne hizalı;
yoğun serilerde çakışan etiketleri otomatik gizle). En üstteki etiket kırpılmasın diye biraz üst boşluk ayır. Çubuk
grafikler kendi değer/yüzde etiketlerini korur; bileşim halkaları da kendilerininkini — kural özellikle **çizgi**
grafikler hakkındadır.
Weekly vs monthly chart height parity
When a dashboard shows both weekly-trend mini charts and monthly charts, size them to the same rendered height so the panels…
EN
## Weekly vs monthly chart height parity
When a dashboard shows both weekly-trend mini charts and monthly charts, size them to the **same
rendered height** so the panels line up. If the monthly chart height changes, update the weekly minis
to match.
TR
## Haftalık ve aylık grafik yüksekliği eşitliği
Bir dashboard hem haftalık trend mini grafiklerini hem aylık grafikleri gösteriyorsa, ikisini **aynı çizilmiş
yüksekliğe** getir ki paneller hizalansın. Aylık grafiğin yüksekliği değişirse, haftalık minileri de ona göre güncelle.
Data-table header freezes on page scroll
Every data table's column header stays frozen (sticky) while the PAGE scrolls — the user must never lose the headers while…
EN
## Data-table header freezes on page scroll
Every data table's column header stays **frozen (sticky) while the PAGE scrolls** — the user must
never lose the headers while reading rows further down. Do NOT achieve this with a fixed-height
inner scroll box around the table: that creates a second scrollbar inside the page (double-scroll),
which reads as broken. The table scrolls with the page; only the header pins.
Implementation (verified on AAI Performance Summary):
- `th { position: sticky; top: 0 }` on **every header cell**, each with its own **opaque background**
(e.g. `bg-gray-50`) — row content bleeds through a transparent sticky header while scrolling.
- Draw the header's bottom border as an **inset box-shadow** on the `th`
(`shadow-[inset_0_-1px_0_#e5e7eb]`) — a normal `border-bottom` does not travel with sticky cells.
- **Gotcha — sticky cannot escape an `overflow-x: auto` ancestor** (the wrapper becomes the scroll
root and the header only sticks inside it, i.e. never against the page). The wide-table wrapper
must use **`overflow-x: clip`** while the table fits its container (`clip` does not create a
scroll container, so page-stickiness keeps working), and fall back to `overflow-x: auto` only when
the table genuinely overflows (a `ResizeObserver` fits-check toggling the style) — in that fallback
the freeze is off, since the two can't coexist. Corollary: keep the table fitting the viewport
width (dashboard.md fit rule — trim paddings/min-widths first) so the frozen state is the normal one.
TR
## Veri tablosu başlığı sayfa kaydırılırken donar
Her veri tablosunun kolon başlığı **SAYFA kaydırılırken donmuş (sticky) kalır** — kullanıcı aşağıdaki satırları
okurken başlıkları asla kaybetmemeli. Bunu tablonun etrafında sabit yükseklikli bir iç kaydırma kutusuyla YAPMA:
o, sayfanın içinde ikinci bir kaydırma çubuğu yaratır (çift kaydırma) ve bozuk okunur. Tablo sayfayla birlikte
kayar; sadece başlık sabitlenir.
Uygulama (AAI Performance Summary'de doğrulandı):
- **Her başlık hücresinde** `th { position: sticky; top: 0 }`, her birinin kendi **opak arka planıyla**
(ör. `bg-gray-50`) — kaydırma sırasında satır içeriği saydam bir sticky başlığın içinden geçer.
- Başlığın alt kenarlığını `th` üzerinde bir **iç box-shadow** olarak çiz
(`shadow-[inset_0_-1px_0_#e5e7eb]`) — normal bir `border-bottom` sticky hücrelerle birlikte hareket etmez.
- **Tuzak — sticky, bir `overflow-x: auto` atasından kaçamaz** (sarmalayıcı kaydırma kökü olur ve başlık yalnızca
onun içinde sabitlenir, yani sayfaya karşı hiç). Geniş tablo sarmalayıcısı, tablo container'ına sığdığı sürece
**`overflow-x: clip`** kullanmalı (`clip` bir kaydırma container'ı yaratmaz, yani sayfa sabitlemesi çalışmaya
devam eder) ve yalnızca tablo gerçekten taştığında `overflow-x: auto` yedeğine düşmeli (stili değiştiren bir
`ResizeObserver` sığma kontrolü) — o yedekte dondurma kapalıdır, çünkü ikisi bir arada olamaz. Sonuç olarak:
tabloyu görüş alanı genişliğine sığar tut (dashboard.md sığdırma kuralı — önce boşlukları/min genişlikleri kıs)
ki donmuş durum normal durum olsun.
All page parameters load at once with defaults selected — never a visible cascade
MOVED to design-filters.md (2026-08-25, owner approval) — design-filters.md owns filter, search and lookup mechanics.
EN
## All page parameters load at once with defaults selected — never a visible cascade
MOVED to [`design-filters.md`](./design-filters.md) (2026-08-25, owner approval) — `design-filters.md` owns filter,
search and lookup mechanics. See the section of the same name there.
TR
## Bütün sayfa parametreleri varsayılanları seçili olarak tek seferde yüklenir — asla görünür bir zincir değil
[`design-filters.md`](./design-filters.md) dosyasına TAŞINDI (2026-08-25, sahip onayı) — filtre, arama ve lookup
mekaniği `design-filters.md` dosyasına aittir. Orada aynı adlı bölüme bak.
Data tables with more than ~10 rows get a search option
MOVED to design-filters.md (2026-08-25, owner approval) — design-filters.md owns filter, search and lookup mechanics.
EN
## Data tables with more than ~10 rows get a search option
MOVED to [`design-filters.md`](./design-filters.md) (2026-08-25, owner approval) — `design-filters.md` owns filter,
search and lookup mechanics. See the section of the same name there.
TR
## ~10 satırdan fazla veri tabloları bir arama seçeneği alır
[`design-filters.md`](./design-filters.md) dosyasına TAŞINDI (2026-08-25, sahip onayı) — filtre, arama ve lookup
mekaniği `design-filters.md` dosyasına aittir. Orada aynı adlı bölüme bak.
Filter labels — see design-filters.md
Filter caption/label rules (including the owner's 2026-08-25 decision that captions carry NO icon) live in design-filters.md →…
EN
## Filter labels — see `design-filters.md`
Filter caption/label rules (including the owner's 2026-08-25 decision that captions carry **NO
icon**) live in [`design-filters.md`](./design-filters.md) → *Bar (view-wide) filter controls*. The earlier rule in
this spot mandated a ~12px blue icon per caption; the owner reversed it and the rule MOVED.
TR
## Filtre etiketleri — bkz. `design-filters.md`
Filtre başlığı/etiketi kuralları (sahibin 2026-08-25 tarihli, başlıkların **HİÇBİR simge** taşımadığı kararı dahil)
[`design-filters.md`](./design-filters.md) → *Bar (view-wide) filter controls* içinde yaşar. Bu noktadaki önceki
kural, başlık başına ~12px mavi bir simge şart koşuyordu; sahip bunu tersine çevirdi ve kural TAŞINDI.