feat(ux): listeden silme geri alınabilir; better-ui skill'i eklendi

- Çekmecede "Listeyi temizle" ve satır çıkarma (düğme ve kaydırma)
  anında siliyordu, geri dönüş yoktu. Artık 8 sn "Geri al" bildirimi;
  çıkanlar eski sıralarına döner, arada eklenenler korunur
- Çekmece kaydırması sayfaya taşmıyor (overscroll-contain)
- .claude/skills/better-ui: Jakub Krehel'in UI cilası skill'i (ui-skills.com);
  başına DESIGN.md önceliği ve çakışan iki değer notu eklendi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-24 01:29:51 +03:00
parent 169080df9d
commit aef4adc90b
2 changed files with 149 additions and 4 deletions

View File

@@ -0,0 +1,106 @@
---
name: better-ui
description: Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, micro-interactions, enter/exit animations, or any visual detail work. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, image outlines, box shadows.
---
> **KolayTercih notu (25 Eyl 2026, Bilal'in isteğiyle eklendi).** Kaynak: Jakub Krehel,
> https://github.com/jakubkrehel/skills (ui-skills.com üzerinden okundu, elle kopyalandı).
> Bu projede `docs/tasarim/DESIGN.md` önceliklidir. Çakışan iki nokta:
> basınca küçülme bizde `scale-[0.97]` (geniş satır `0.99`, çarpı `0.96`), buradaki `0.96`
> değil; kart ve satırlarda yapıyı `border-slate-200` verir, gölge yalnız §4.4'teki kadar.
> Görsel ayar yaparken önce DESIGN.md, sonra bu dosya.
# UI polish
Polish comes from a pile of small details that compound. This skill is the reference for which are worth having and what values they take.
When reviewing, slow the interface down. What feels off at 10% speed is what is subtly wrong at full speed.
Keep the project's component library, tokens and density, and match its motion language except where a rule below prescribes an exact interaction.
Every duration, curve, scale and blur below is a specific value, not a range to approximate. cubic-bezier(0.2, 0, 0, 1) is not cubic-bezier(0.4, 0, 0.2, 1), and 0.96 is not 0.95. Use what is written.
## Concentric border radius
Outer radius = inner radius + padding. Mismatched radii on nested elements is the most common thing that makes an interface feel off.
## Optical over geometric alignment
When geometric centering looks off, align optically. Buttons with icons, play triangles and asymmetric icons all need a manual nudge.
## Shadows for elevation, borders for structure
Where a border exists only to create depth, prefer layered transparent box-shadow values. Keep borders that communicate structure or state: dividers, separators and selected or focus states.
## Interruptible animations
Use CSS transitions for interactive state changes, because they can be interrupted mid-animation. Reserve keyframes for staged sequences that run once.
## Split and stagger enter animations
For an infrequent staged entrance where sequence communicates hierarchy, break the content into semantic chunks and stagger them by ~100ms. Animating one container gets you less for the same cost. Leave high-frequency interactions unstaggered.
## Subtle exit animations
Use a small fixed translateY rather than full height. Exits should be softer than enters. Use ease-out for both directions.
## Contextual icon animations
Animate icons with opacity, scale and blur rather than toggling visibility. Use exactly these values: scale 0.25 to 1, opacity 0 to 1, blur 4px to 0px.
With a motion library (motion or framer-motion in package.json), match that package's import path. Use transition: { type: "spring", duration: 0.3, bounce: 0 }. Bounce is always 0.
Without one, keep both icons in the DOM with one absolutely positioned, and cross-fade with cubic-bezier(0.2, 0, 0, 1).
## Image outlines
Give images a 1px outline at low opacity for consistent depth. Pure black in light mode (oklch(0 0 0 / 0.1)), pure white in dark (oklch(1 0 0 / 0.1)). Never a near-black like slate or zinc and never a tinted neutral.
## Scale on press
A scale on click gives a button tactile feedback (upstream value 0.96; this project uses 0.97, see note above). Add a static prop to switch it off where motion would distract.
## Skip animation on page load
Use initial={false} on AnimatePresence to keep enter animations off the first render. Check that it leaves intentional page entrances intact.
## Suppress transitions on theme switch
A theme flip changes color, background, border and shadow on nearly every element at once. Inject *,*::before,*::after{transition:none !important}, force a reflow, then remove it on the next frame.
## Transition only what changes
Always name the exact properties: transition-property: scale, opacity. Tailwind's transition-transform covers transform, translate, scale, rotate.
## Use will-change sparingly
Only for transform, opacity and filter. Never will-change: all. Add it when you see first-frame stutter, not before.
## Match icon stroke to text weight
1.5px stroke beside regular (400) text, 2px beside semibold (600). One stroke weight per icon set and one icon library per surface.
## One SVG, recolored per state
Icons use currentColor and take hover, selected and disabled states from CSS color and opacity. Outline is the default variant; fill marks the active state.
## Motion restraint
Give high-frequency interactions instant feedback, or a transition of 150ms or less on opacity and color. Every animated state change also needs a static cue: color, an icon, or a label. Motion is never the only feedback channel.
## Before you finish
| Mistake | Fix |
|---|---|
| Icons look off-center | Nudge optically with padding, or fix the SVG |
| Jarring staged entrance or exit | Stagger infrequent entrances; keep exits subtle |
| Theme toggle crossfades the whole page | Disable transitions for the swap, force a reflow, restore on the next frame |
| transition: all on elements | Specify exact properties |
| First-frame animation stutter | Add will-change: transform (sparingly) |
| Hairline icon beside bold text | Match the stroke width to the text weight |
## Reporting
Severity. HIGH breaks an interaction, makes motion unusable, or leaves a state change visible only while the animation runs. MEDIUM is a visible inconsistency in surfaces, icons, or motion. LOW is isolated polish.
Format: group findings under the principle each violates, ordered by severity, one row per root cause: Severity | Location | Before | After | Why. End with Block when any HIGH remains, Approve otherwise. Report every check you could not run as Not verified.

View File

@@ -12,6 +12,7 @@
// sadeleşir; X butonu her durumda kalır.
import { useState } from "react";
import { toast } from "sonner";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { PixelField } from "@/components/pixel-decor";
@@ -60,6 +61,7 @@ import {
cekmeceAc,
cekmeceKapat,
cikar,
listeOku,
temizle,
useCekmeceAcik,
useManuelListe,
@@ -79,6 +81,36 @@ function uniHref(ad: string): string {
return `/universite/${turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim())}`;
}
/**
* Geri alınabilir silme (Vercel arayüz ilkeleri: yıkıcı işlem ya onay ya
* geri al penceresi ister; Apple: beklenen işlemde uyarı penceresi açma).
* Çıkan tercihler eski sıralarına geri konur; arada eklenenler kaybolmaz.
*/
function geriAlinabilirCikar(ids: string[], baslik: string) {
const onceki = listeOku();
const cikanlar = onceki
.map((t, sira) => ({ t, sira }))
.filter(({ t }) => ids.includes(t.id));
if (cikanlar.length === 0) return;
if (ids.length === onceki.length) temizle();
else for (const id of ids) cikar(id);
toast(baslik, {
duration: 8000,
action: {
label: "Geri al",
onClick: () => {
const simdi = listeOku().filter(
(t) => !cikanlar.some((c) => c.t.id === t.id),
);
for (const { t, sira } of cikanlar) {
simdi.splice(Math.min(sira, simdi.length), 0, t);
}
yenidenSirala(simdi.slice(0, MANUEL_LISTE_MAX));
},
},
});
}
export function ListeCekmecesi() {
const router = useRouter();
const acik = useCekmeceAcik();
@@ -132,7 +164,12 @@ export function ListeCekmecesi() {
{liste.length > 0 ? (
<button
type="button"
onClick={temizle}
onClick={() =>
geriAlinabilirCikar(
liste.map((t) => t.id),
`Listen temizlendi · ${liste.length} program çıkarıldı`,
)
}
title="Listeyi temizle"
className="absolute top-3 left-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
@@ -201,7 +238,7 @@ export function ListeCekmecesi() {
</DrawerHeader>
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-2"
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-2"
>
{liste.length === 0 ? (
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
@@ -361,7 +398,7 @@ function CekmeceSatiri({
info.offset.x > SIL_ESIK ||
(info.offset.x > 32 && info.velocity.x > 500)
) {
cikar(tercih.id);
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
}
};
@@ -450,7 +487,9 @@ function CekmeceSatiri({
</span>
<button
type="button"
onClick={() => cikar(tercih.id)}
onClick={() =>
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`)
}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className="inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.92]"
>