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:
106
.claude/skills/better-ui/SKILL.md
Normal file
106
.claude/skills/better-ui/SKILL.md
Normal 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.
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// sadeleşir; X butonu her durumda kalır.
|
// sadeleşir; X butonu her durumda kalır.
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PixelField } from "@/components/pixel-decor";
|
import { PixelField } from "@/components/pixel-decor";
|
||||||
@@ -60,6 +61,7 @@ import {
|
|||||||
cekmeceAc,
|
cekmeceAc,
|
||||||
cekmeceKapat,
|
cekmeceKapat,
|
||||||
cikar,
|
cikar,
|
||||||
|
listeOku,
|
||||||
temizle,
|
temizle,
|
||||||
useCekmeceAcik,
|
useCekmeceAcik,
|
||||||
useManuelListe,
|
useManuelListe,
|
||||||
@@ -79,6 +81,36 @@ function uniHref(ad: string): string {
|
|||||||
return `/universite/${turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim())}`;
|
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() {
|
export function ListeCekmecesi() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const acik = useCekmeceAcik();
|
const acik = useCekmeceAcik();
|
||||||
@@ -132,7 +164,12 @@ export function ListeCekmecesi() {
|
|||||||
{liste.length > 0 ? (
|
{liste.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={temizle}
|
onClick={() =>
|
||||||
|
geriAlinabilirCikar(
|
||||||
|
liste.map((t) => t.id),
|
||||||
|
`Listen temizlendi · ${liste.length} program çıkarıldı`,
|
||||||
|
)
|
||||||
|
}
|
||||||
title="Listeyi temizle"
|
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]"
|
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>
|
</DrawerHeader>
|
||||||
|
|
||||||
<div
|
<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 ? (
|
{liste.length === 0 ? (
|
||||||
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
|
<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 > SIL_ESIK ||
|
||||||
(info.offset.x > 32 && info.velocity.x > 500)
|
(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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => cikar(tercih.id)}
|
onClick={() =>
|
||||||
|
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`)
|
||||||
|
}
|
||||||
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
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]"
|
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]"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user