<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>PlayerBerry — Blog</title>
    <link>https://playerberry.com/blog</link>
    <atom:link href="https://playerberry.com/rss-en.xml" rel="self" type="application/rss+xml" />
    <description>Architecture decisions, design notes and lessons from the field.</description>
    <language>en</language>
    <lastBuildDate>Thu, 16 Jul 2026 15:41:23 GMT</lastBuildDate>
    <item>
      <title>Swift Concurrency: Goodbye Callback Hell with async/await</title>
      <link>https://playerberry.com/blog/swift-concurrency</link>
      <guid isPermaLink="true">https://playerberry.com/blog/swift-concurrency</guid>
      <pubDate>Tue, 14 Jul 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[In an app we inherited for maintenance, we once counted six levels of nested closures. async/await reads the same flow like a straight story — here's how.]]></description>
      <content:encoded><![CDATA[<p>A few years back we inherited an iOS app for maintenance and sat down to count: six levels of nested completion handlers in a single function. Nobody could tell which error belonged to which layer in the innermost closure; people were so afraid to touch it that someone had left a comment at the top saying 'do not enter'. Not a joke.</p>
<h2>How the Ladder Gets Built</h2>
<p>The interesting part: whoever built that ladder wasn't a bad developer. In the old world, even the most innocent flow — fetch a user, then their avatar — inevitably ended up looking like this:</p>
<pre><code>func loadProfile(id: String,
                 completion: @escaping (Result&lt;Profile, Error&gt;) -&gt; Void) {
    fetchUser(id) { result in
        switch result {
        case .success(let user):
            fetchAvatar(user.avatarURL) { avatarResult in
                switch avatarResult {
                case .success(let avatar):
                    completion(.success(Profile(user: user, avatar: avatar)))
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}</code></pre>
<p>Twenty lines for two requests. The actual logic is three words — get user, get avatar, combine — yet it's nowhere to be seen. Every layer carries its own error handling, and if you forget to call <code>completion</code> on one branch, the app silently hangs. No compiler will ever tell you.</p>
<h2>await: Wait, but Don't Block</h2>
<p>The same function with async/await:</p>
<pre><code>func loadProfile(id: String) async throws -&gt; Profile {
    let user = try await fetchUser(id)
    let avatar = try await fetchAvatar(user.avatarURL)
    return Profile(user: user, avatar: avatar)
}</code></pre>
<p>Wherever you see <code>await</code>, the function pauses without blocking the thread; the system runs other work in the meantime. Error handling moved from closures to <code>throws</code>: no more unwrapping <code>Result</code> by hand, no more forgotten <code>completion</code>. If a branch is missing, you find out at compile time, not in production.</p>
<h2>Don't Queue Up Independent Work</h2>
<p>In the code above the avatar depends on the user, so waiting in order is fair. But awaiting three independent requests one after another is plain waste. <code>async let</code> starts them all at once:</p>
<pre><code>func loadDashboard(for id: String) async throws -&gt; Dashboard {
    async let profile = fetchProfile(id)
    async let feed = fetchFeed(id)
    async let badges = fetchBadges(id)

    return try await Dashboard(profile: profile,
                               feed: feed,
                               badges: badges)
}</code></pre>
<p>Total time is now the slowest request, not the sum of all three. In that inherited project, this one change took the home screen from 1.8 seconds to 700 milliseconds. We wouldn't have believed it without measuring.</p>
<h2>Shared State: Enter the Actor</h2>
<p>The part of concurrency that actually burns you is data races: two tasks writing to the same dictionary at the same time — sometimes a crash, sometimes silently corrupted data. Swift's answer is the <code>actor</code>: only one task can touch its state at a time, and the compiler guarantees it:</p>
<pre><code>actor ImageCache {
    private var store: [URL: Image] = [:]

    func image(for url: URL) -&gt; Image? {
        store[url]
    }

    func insert(_ image: Image, for url: URL) {
        store[url] = image
    }
}

// Access always goes through await:
let cache = ImageCache()
await cache.insert(avatar, for: url)
let hit = await cache.image(for: url)</code></pre>
<p>We used to build this with locks on a <code>DispatchQueue</code> and hope everyone used them correctly. Hope is not a concurrency strategy.</p>
<blockquote><p>async/await doesn't make code shorter; it makes it readable. The win isn't in the line count — it's on the face of whoever opens that file six months from now.</p></blockquote>
<p>That 'do not enter' function is thirty lines today, and every new hire understands it on first read. We haven't met anyone who misses the callback ladder.</p>]]></content:encoded>
      <category>Swift</category>
      <category>Concurrency</category>
    </item>
    <item>
      <title>Modern C++: Stop Managing Memory by Hand</title>
      <link>https://playerberry.com/blog/modern-cpp-bellek</link>
      <guid isPermaLink="true">https://playerberry.com/blog/modern-cpp-bellek</guid>
      <pubDate>Sun, 12 Jul 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[Hunting a leak through valgrind output at 2 a.m. is certainly an experience; we don't recommend it. RAII and smart pointers retire most of those hunts for good.]]></description>
      <content:encoded><![CDATA[<p>If you've ever hunted a memory leak through valgrind output at two in the morning on a game server, you already know what this post is about. The culprit was almost always the same: a <code>new</code> somewhere, and one of the return paths skipping the <code>delete</code>. C++'s raw power over memory collects its toll exactly like this.</p>
<h2>How a Leak Is Born</h2>
<p>Look at this function — it could be from a textbook:</p>
<pre><code>void process() {
    Widget* w = new Widget();

    if (!w-&gt;init()) {
        return;            // leak: delete is never reached
    }

    w-&gt;run();
    delete w;
}</code></pre>
<p>If <code>init()</code> fails, <code>delete</code> never runs and the <code>Widget</code> is orphaned in memory. If <code>run()</code> throws, same story. The problem isn't carelessness — it's entrusting 'clean up on every exit path' to a human. Humans forget. The bigger the codebase, the more they forget.</p>
<h2>RAII: Let the Object Do the Cleanup</h2>
<p>C++'s answer has a bureaucratic-sounding name — Resource Acquisition Is Initialization — but the idea is one sentence: tie the resource to an object's lifetime. When the object leaves scope, its destructor runs and releases the resource. How you leave doesn't matter — normal flow, early <code>return</code>, an exception... the destructor always runs:</p>
<pre><code>class FileHandle {
public:
    explicit FileHandle(const char* path)
        : file_(std::fopen(path, &quot;r&quot;)) {}

    ~FileHandle() {
        if (file_) std::fclose(file_);
    }

    // Copying is disabled so two objects never close the same file.
    FileHandle(const FileHandle&amp;) = delete;
    FileHandle&amp; operator=(const FileHandle&amp;) = delete;

    std::FILE* get() const { return file_; }

private:
    std::FILE* file_;
};</code></pre>
<p>There's no 'remember to close the file' task anymore; the moment a <code>FileHandle</code> leaves scope, the file closes. <code>std::lock_guard</code>, <code>std::vector</code>, <code>std::string</code> — the standard library is built on this same pattern. You've probably been using RAII for years without naming it.</p>
<h2>Smart Pointers: Life Without new/delete</h2>
<p>The same pattern comes prepackaged for pointers: <code>unique_ptr</code> for single ownership, <code>shared_ptr</code> for shared ownership.</p>
<pre><code>#include &lt;memory&gt;

auto w = std::make_unique&lt;Widget&gt;();
if (!w-&gt;init()) {
    return;               // no leak; w cleans up after itself
}
w-&gt;run();

// Shared ownership: the object is freed when the last owner goes.
auto config = std::make_shared&lt;Config&gt;();
useConfig(config);</code></pre>
<p>The entire bug class from the original <code>process()</code> — the forgotten <code>delete</code>, the double <code>delete</code>, the leak on the exception path — disappeared with a one-line change. The performance cost? Zero for <code>unique_ptr</code>; the compiled code is identical to a raw pointer.</p>
<p>One honest warning: <code>shared_ptr</code> is not a cure-all. Its reference count is atomic, so it isn't free, and cyclic references — A holds B, B holds A — never let the count reach zero. Make <code>unique_ptr</code> your default; reach for <code>shared_ptr</code> only when ownership genuinely is shared, and use <code>weak_ptr</code> on the edge that risks a cycle.</p>
<blockquote><p>If you see naked new and delete in modern C++ code, either something very special is going on or a bug is waiting. Experience says: usually the latter.</p></blockquote>
<p>Back to that 2 a.m. valgrind session: we fixed that leak by hand, because the codebase wasn't ready for smart pointers. The next month we made the rule — new code doesn't write naked <code>new</code>. We haven't been on a leak hunt since.</p>]]></content:encoded>
      <category>C++</category>
      <category>Memory</category>
    </item>
    <item>
      <title>Pointers in C: Feared, but Misunderstood</title>
      <link>https://playerberry.com/blog/c-isaretciler</link>
      <guid isPermaLink="true">https://playerberry.com/blog/c-isaretciler</guid>
      <pubDate>Sat, 11 Jul 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[A pointer boils down to a single idea: store the address, not the value. Everything else — arrays, pass-by-reference, const — follows from it.]]></description>
      <content:encoded><![CDATA[<p>Everyone learning C hits the wall in the same week: pointer week. Asterisks, ampersands, cryptic compiler warnings... Yet behind the curtain there's a single idea: store not a variable's value, but where that value lives in memory. Everything else is a consequence.</p>
<h2>&amp; Gives the Address, * Follows It</h2>
<pre><code>int x = 42;
int *p = &amp;x;          // p holds x's address

printf(&quot;%d\n&quot;, *p);   // 42 — the value p points at
*p = 7;               // x is now 7; p points at it</code></pre>
<p><code>&amp;x</code> answers 'where does x live'; <code>*p</code> answers 'what's at the place p points to'. They're inverses. When you write <code>*p = 7</code>, you're changing <code>x</code> directly, because both look at the same address.</p>
<p>The first real payoff of this idea is functions that can modify outside variables. Every argument in C is passed by copy — but pass an address and the function reaches the real data:</p>
<pre><code>void swap(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

int i = 1, j = 2;
swap(&amp;i, &amp;j);         // i == 2, j == 1</code></pre>
<p>If you've ever wondered why <code>scanf</code> needs <code>&amp;num</code>: that's the answer. You hand it the address so it can write the value it reads into your variable.</p>
<h2>Arrays: Closer Kin Than They Look</h2>
<p>The fact that surprises C beginners the most: an array name, in most contexts, turns into the address of its first element. This is called 'decay':</p>
<pre><code>int nums[3] = {10, 20, 30};
int *p = nums;              // same as &amp;nums[0]

printf(&quot;%d\n&quot;, *(p + 2));   // 30
printf(&quot;%d\n&quot;, p[2]);       // 30 — exactly the same thing</code></pre>
<p><code>p[2]</code> and <code>*(p + 2)</code> are two spellings of the same expression; the brackets are just shorthand. The subtlety people miss: <code>p + 2</code> advances two <code>int</code>s, not two bytes. Pointer arithmetic accounts for the element size on your behalf.</p>
<h2>const: A Promise to the Compiler</h2>
<p>Every function that takes a pointer also receives the power to modify the data. <code>const</code> is how you hand that power back:</p>
<pre><code>void printName(const char *name) {
    // *name = 'X';  -&gt; compile error
    printf(&quot;Hello, %s\n&quot;, name);
}</code></pre>
<p>One tiny keyword doing two jobs: it guarantees the caller you won't touch their data, and it gives you a compile error if you try by accident. Free insurance — be generous with it in your signatures.</p>
<p>The bad reputation of pointers comes, I think, from bad teaching. Diving into syntax before the address concept settles — without drawing boxes and arrows — turns the symbols into meaningless magic. Once the address idea clicks, though, arrays, strings, dynamic memory and linked lists all stack neatly on the same foundation. Nothing to fear; this is the heart of C.</p>]]></content:encoded>
      <category>C</category>
      <category>Systems</category>
    </item>
    <item>
      <title>Adding an LLM to Your Product: the Gap Between Demo and Production</title>
      <link>https://playerberry.com/blog/llm-urun-entegrasyonu</link>
      <guid isPermaLink="true">https://playerberry.com/blog/llm-urun-entegrasyonu</guid>
      <pubDate>Fri, 10 Jul 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[Friday's demo got applause; on Monday the first real user cornered the system in three minutes. Four disciplines that close that gap.]]></description>
      <content:encoded><![CDATA[<p>We showed the demo on Friday afternoon and the room applauded. On Monday morning the first real user uploaded a screenshot instead of an invoice, refreshed the page mid-response, and surfaced four distinct failures we'd never seen in the demo — in three minutes. Productizing an LLM is, in short, the distance between those two days.</p>
<h2>Same Question, Two Different Answers</h2>
<p>The software we're used to produces the same output for the same input. A language model doesn't — and instead of fighting that, you build the architecture around it. Rule one: never trust the model's output raw. If you expect structured data, validate against a schema and retry on mismatch:</p>
<pre><code>const Invoice = z.object({
  vendor: z.string(),
  total: z.number().positive(),
  currency: z.enum([&quot;TRY&quot;, &quot;USD&quot;, &quot;EUR&quot;]),
});

async function extractInvoice(text: string) {
  for (let attempt = 1; attempt &lt;= 3; attempt++) {
    const raw = await model.complete(prompt(text));
    const parsed = Invoice.safeParse(tryJson(raw));
    if (parsed.success) return parsed.data;
  }
  throw new ExtractionFailed();
}</code></pre>
<p>When three attempts still don't fit the schema, the right move is an honest error to the user — not silently accepting bad data. A system that can say 'I'm not sure' earns far more trust than one that speaks confidently without being sure.</p>
<h2>Context Beats the Model</h2>
<p>The sentence we hear most on projects: 'Would a stronger model fix this?' Usually no — because what determines answer quality is mostly what you show the model. In a RAG setup the real engineering lives in the retrieval layer: a system that can't find the right document will fabricate, whatever the model. It has no other material to work with.</p>
<p>Stuffing the context window isn't the fix either. Ten irrelevant documents distract the model and inflate the bill. In our measurements, two relevant paragraphs consistently beat twenty added 'just in case'. Less, but on target, wins.</p>
<h2>Don't Change Prompts Blind</h2>
<p>You changed three words in the prompt; is the system better now? Without measurement, the honest answer is 'no idea'. For every critical flow we keep an evaluation set compiled from real user questions — inputs and expected behaviors, an ordinary file in version control:</p>
<pre><code>{
  &quot;input&quot;: &quot;Who approved last month's AWS invoice?&quot;,
  &quot;expect&quot;: {
    &quot;must_cite_source&quot;: true,
    &quot;must_not_contain&quot;: [&quot;I guess&quot;, &quot;probably&quot;],
    &quot;tool_called&quot;: &quot;search_invoices&quot;
  }
}</code></pre>
<p>Whenever the prompt or the model changes, the set reruns; if the score drops, the change doesn't reach the main branch. This is the regression test of the LLM world. It took half a day to set up; in return, deployments stopped being gambles.</p>
<h2>The Token Bill and the Three-Second Void</h2>
<p>Nobody looks at cost in a demo; in production every request is real money. The most effective remedy is routing: send classification and short summaries to a small model, and save the big one for genuinely hard questions. That single decision cut our bill by more than half. On the latency side, streaming is non-negotiable: if the first word lands in 300 milliseconds, users don't mind a three-second total. Three seconds staring at a blank screen, though, is an eternity.</p>
<blockquote><p>A demo is a showcase of the happy path; a product is the sum of the unhappy ones. With LLMs, that gap is wider than ever.</p></blockquote>
<p>The chasm can be crossed. Schema validation, targeted context, an evaluation set and cost discipline — together they turn a mesmerizing demo into a dependable product. In order, and without rushing.</p>]]></content:encoded>
      <category>AI</category>
      <category>Product</category>
    </item>
    <item>
      <title>Core Web Vitals: How Users Actually Feel Speed</title>
      <link>https://playerberry.com/blog/core-web-vitals</link>
      <guid isPermaLink="true">https://playerberry.com/blog/core-web-vitals</guid>
      <pubDate>Fri, 03 Jul 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[Lighthouse said 90; users said the site was slow. Both were right. What the three metrics mean in the field, and how they actually got fixed.]]></description>
      <content:encoded><![CDATA[<p>An e-commerce client came to us with a 'the site is slow' complaint. We opened Lighthouse: above 90, all green. Then we looked at real user data and the picture flipped — on mid-range Android phones, LCP was over four seconds. Both were true: the lab was fast, the field was slow. That's exactly where Core Web Vitals earn their keep: they measure what users actually feel.</p>
<h2>LCP: When Did the Page 'Arrive'</h2>
<p>Largest Contentful Paint marks the moment the biggest piece of content — usually the hero image or the headline — shows up. In that project the culprit was a late-discovered hero image: the browser downloaded the CSS first and only then learned the image existed. The cure is telling it up front:</p>
<pre><code>&lt;link
  rel=&quot;preload&quot;
  as=&quot;image&quot;
  href=&quot;/hero.avif&quot;
  imagesrcset=&quot;/hero-800.avif 800w, /hero-1600.avif 1600w&quot;
/&gt;</code></pre>
<p>Then there's the webfont classic: sites that hide text until the font arrives show users a long blank stare. <code>font-display: swap</code> renders with a system font first and switches when the brand font lands:</p>
<pre><code>@font-face {
  font-family: &quot;Space Grotesk&quot;;
  src: url(&quot;/fonts/space-grotesk.woff2&quot;) format(&quot;woff2&quot;);
  font-display: swap;
}</code></pre>
<p>Those two changes plus converting images to AVIF took that site's LCP from 4.1 seconds to 1.9. Nothing exotic — just making the browser's job easier.</p>
<h2>INP: I Clicked, Why Is Nothing Happening</h2>
<p>Interaction to Next Paint measures the time from an interaction to the first visible response. The chief enemy is JavaScript that locks the main thread for hundreds of milliseconds: while the browser is busy with one long task, clicks queue up and the UI feels frozen. The cure is splitting the work:</p>
<pre><code>async function processRows(rows) {
  for (const [i, row] of rows.entries()) {
    handle(row);
    if (i % 100 === 0) {
      // Let the browser breathe every 100 rows:
      await new Promise((r) =&gt; setTimeout(r, 0));
    }
  }
}</code></pre>
<p>Crude but effective: the long loop now yields the main thread periodically, and pending clicks get processed. There are nicer tools — <code>scheduler.yield()</code>, Web Workers — but the principle never changes: the main thread belongs to the user; you're a guest there.</p>
<h2>CLS: I Was About to Tap It, and It Moved</h2>
<p>Cumulative Layout Shift measures how much content jumps around while loading. Everyone has lived it: the paragraph you're reading dives down because a banner popped in above. The cause is almost always the same — images and embeds with no declared size. In modern CSS the fix is two lines:</p>
<pre><code>img {
  aspect-ratio: 16 / 9; /* space is reserved before the image loads */
  width: 100%;
  height: auto;
}</code></pre>
<p>The moment the browser knows the aspect ratio, it reserves the spot up front; nothing jumps. Same principle for dynamic banners and ads: reserve the area first, drop the content into it.</p>
<blockquote><p>Lab tests measure your computer; RUM measures your user's phone. When deciding, look at the second one.</p></blockquote>
<p>The lesson that project left us with: performance isn't a one-off spring cleaning, it's managed like a budget. Every new feature spends from it. Keep measuring and the debt stays visible before it piles up; stop, and it comes back with interest.</p>]]></content:encoded>
      <category>Performance</category>
      <category>UX</category>
    </item>
    <item>
      <title>Making Invalid States Impossible with TypeScript</title>
      <link>https://playerberry.com/blog/typescript-tip-guvenligi</link>
      <guid isPermaLink="true">https://playerberry.com/blog/typescript-tip-guvenligi</guid>
      <pubDate>Sat, 27 Jun 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[If refactoring scares you, the problem may not be your courage — it may be your types. On using the type system as a design tool, not a labeling machine.]]></description>
      <content:encoded><![CDATA[<p>If the room goes quiet when someone says 'let's rename this field', the problem isn't courage — it's the types. In a well-modeled codebase, refactoring is fearless: you make the change, everything broken lights up red, you clear the list, done. Teams that use TypeScript as 'JavaScript with labels' never get to feel that ease.</p>
<h2>The Boolean Swamp</h2>
<p>The classic example, because it's still everywhere: three separate fields — <code>isLoading</code>, <code>isError</code>, <code>data</code>. Three fields give you eight combinations; three of them are meaningful. A state that's both loading and failed doesn't logically exist — but it exists in the type system, and one day it shows up in production. Model the state as a discriminated union and the absurd combinations drop out of the language:</p>
<pre><code>type State =
  | { status: &quot;loading&quot; }
  | { status: &quot;error&quot;; error: Error }
  | { status: &quot;success&quot;; data: User };</code></pre>
<p><code>data</code> is now reachable only on the <code>success</code> branch. Code that tries to read it while loading doesn't even compile; that whole bug class dies in the editor, long before production.</p>
<p>The lesser-known bonus of this pattern: combined with <code>switch</code>, the compiler catches missing branches too:</p>
<pre><code>function render(state: State) {
  switch (state.status) {
    case &quot;loading&quot;:
      return spinner();
    case &quot;error&quot;:
      return alert(state.error);
    case &quot;success&quot;:
      return table(state.data);
    default: {
      const missing: never = state; // breaks here if a new state is added
      return missing;
    }
  }
}</code></pre>
<p>Six months later, when someone adds <code>{ status: &quot;empty&quot; }</code> to <code>State</code>, this <code>switch</code> fails to compile and every place that needs updating reports itself. The compiler writes your to-do list.</p>
<h2>When Everything Is a string</h2>
<p>User id: <code>string</code>. Email: <code>string</code>. Order number: <code>string</code>... When they're all the same type, nothing stops you from writing <code>sendEmail(user.id)</code>. Branded types turn that mix-up into a compile error:</p>
<pre><code>type UserId = string &amp; { readonly __brand: &quot;UserId&quot; };
type Email = string &amp; { readonly __brand: &quot;Email&quot; };

function sendEmail(to: Email) {
  /* ... */
}

sendEmail(user.id); // compile error — an id is not an Email</code></pre>
<p>At runtime these are ordinary strings; the cost is zero. But for values where a mix-up is expensive — money, dates, units of measure — this zero-cost fence prevents real accidents. The Mars Climate Orbiter was lost to a metric-imperial mix-up; a well-shaped type system would have caught that before launch.</p>
<h2>Validate at the Boundary, Trust Inside</h2>
<p>Types only hold inside your own code. Calling <code>as User</code> on JSON from an API is not a fact — it's a wish. Do runtime validation at the boundary; inside, trust your types with a clear conscience:</p>
<pre><code>const User = z.object({
  id: z.string(),
  name: z.string(),
  age: z.number().int().min(0),
});

const user = User.parse(await res.json()); // blows up here if it doesn't fit</code></pre>
<p>Now the failure happens at the door where the data enters, with a readable message — not three layers deep as 'undefined is not a function'.</p>
<blockquote><p><code>as</code> is an expression of hope; <code>parse</code> is proof. Demand proof at the boundary.</p></blockquote>
<p>Once you start treating types as a design tool, something interesting happens: you see the gaps in your design while modeling, before writing any code. The compiler makes you answer 'what happens to this field in that state?'. A type definition that feels bad to write is very often the first symptom of a bad design decision.</p>]]></content:encoded>
      <category>TypeScript</category>
      <category>Architecture</category>
    </item>
    <item>
      <title>Accessibility Is Not a Layer You Add Later</title>
      <link>https://playerberry.com/blog/erisilebilirlik-varsayilan</link>
      <guid isPermaLink="true">https://playerberry.com/blog/erisilebilirlik-varsayilan</guid>
      <pubDate>Mon, 22 Jun 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[Put the mouse in a drawer and try navigating your site with Tab alone. That five-minute test says more about most interfaces than a month of analytics.]]></description>
      <content:encoded><![CDATA[<p>During a user test, we watched a participant navigate our site with a screen reader. Ten minutes surfaced five problems we hadn't noticed in months. The most painful one: what we thought was an 'Add to cart' button was, to him, just 'something clickable' — a nameless box that never said what it did. That was the day we understood accessibility isn't a compliance item; it's product quality, plain and simple.</p>
<h2>The Five-Minute Test: Drop the Mouse</h2>
<p>The fastest read on your site's state: put the mouse away and navigate with Tab, Enter and the arrow keys only. Can you see where focus is? Can you reach every button? Can you close that menu with Escape? An interface that fails this test is broken not just for disabled users but for everyone who prefers working from the keyboard.</p>
<p>The mistake this test catches most often is well known: building buttons out of <code>div</code>s.</p>
<pre><code>&lt;!-- Looks like a button, does none of the job: --&gt;
&lt;div class=&quot;btn&quot; onclick=&quot;save()&quot;&gt;Save&lt;/div&gt;

&lt;!-- Keyboard, focus, Enter, screen reader — all free: --&gt;
&lt;button type=&quot;button&quot; onclick=&quot;save()&quot;&gt;Save&lt;/button&gt;</code></pre>
<p>The first one works with a mouse and stops there: it can't take focus, doesn't hear Enter, never tells a screen reader it's a button. You could patch all of that back with <code>tabindex</code>, <code>role</code> and <code>keydown</code> — but why? The right HTML element does with zero lines what a hundred lines of ARIA patchwork can't.</p>
<h2>Structure Is Told, Not Shown</h2>
<p>A screen reader user doesn't scan the page visually; they listen, jumping between headings and landmarks. That's why <code>&lt;nav&gt;</code>, <code>&lt;main&gt;</code>, <code>&lt;header&gt;</code> and a sane heading hierarchy aren't a 'clean code' aesthetic — they're an actual navigation instrument. Alt text is part of the same job, and a craft of its own: 'chart' carries no information; 'line chart showing revenue up 20 percent in Q4' does. The rule is simple: don't describe the image, convey what it says.</p>
<h2>Color Can't Speak Alone</h2>
<p>If you marked invalid form fields with red alone, then for a color-blind user you marked nothing — and that's one in twelve men, not a rounding error. Color always needs a second channel: an icon, a label, a pattern. Contrast is equally non-negotiable; that elegant light-gray text disappears on a phone screen at noon.</p>
<p>Motion is less talked about, but for some users it's not comfort — it's health: large parallax and sweeping transitions can trigger genuine physical nausea in people with vestibular disorders. Respecting the user who asked their system to reduce motion is one media query:</p>
<pre><code>@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}</code></pre>
<blockquote><p>Captions were invented for deaf users; today we all watch videos with them on the subway. What's designed for the constraint gets clearer for everyone.</p></blockquote>
<p>Since that user test, the team rule is: every new component gets keyboard-tested before merging, every image arrives with its alt text, every color is chosen with its contrast. On paper it looks like extra work; in practice, next to the cost of fixing it later, doing it right up front is nearly free.</p>]]></content:encoded>
      <category>Accessibility</category>
      <category>Frontend</category>
    </item>
    <item>
      <title>Vue 3.5 Reactivity: Practical Performance Notes</title>
      <link>https://playerberry.com/blog/vue-3-5-performans</link>
      <guid isPermaLink="true">https://playerberry.com/blog/vue-3-5-performans</guid>
      <pubDate>Thu, 18 Jun 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[A dashboard that froze while typing over a 5,000-row table became smooth with three changes. No magic — just knowing how reactivity works.]]></description>
      <content:encoded><![CDATA[<p>A few months ago we worked on a classic complaint in an analytics dashboard: the UI froze while a 5,000-row table updated. Moving to Vue 3.5 helped by itself — the new reactivity internals cut memory use substantially — but the real difference came from three deliberate changes. All three hide in the answer to the same question: how does reactivity actually work?</p>
<h2>Don't Deep-Track Everything</h2>
<p>By default Vue makes every level of an object reactive. Attaching a tracker to each of 5,000 chart points just burns memory — you never mutate those points individually anyway. If you replace the data wholesale, tracking the reference alone is enough:</p>
<pre><code>import { shallowRef } from &quot;vue&quot;;

const points = shallowRef&lt;Point[]&gt;([]);

// Replace wholesale when new data arrives — one trigger:
async function refresh() {
  points.value = await fetchPoints();
}</code></pre>
<p>The rule in practice: if you reassign rather than mutate, use <code>shallowRef</code>. In our dashboard this was the single biggest win; memory visibly dropped and the stutter on large updates disappeared.</p>
<h2>Fat computed, Lazy Chain</h2>
<p>The second culprit was one giant <code>computed</code> doing everything: filter, sort, group, sum — all in one. Any dependency twitching reran the whole chain. We split it:</p>
<pre><code>// Before: everything in one computed, everything reruns on any change.
// After: each link runs only when its own input changes.
const filtered = computed(() =&gt; rows.value.filter(matches(query.value)));
const sorted = computed(() =&gt; sortBy(filtered.value, sortKey.value));
const total = computed(() =&gt; sum(sorted.value, &quot;amount&quot;));</code></pre>
<p>When the sort key changes, filtering no longer repeats; only <code>sorted</code> and downstream recompute. Small computeds are both faster and more readable — nobody could tell what was going on inside the giant one anyway.</p>
<h2>v-memo: Powerful, but Sharp</h2>
<p>The final touch: <code>v-memo</code> on the table rows. A row skips re-rendering as long as its dependencies haven't changed:</p>
<pre><code>&lt;tr
  v-for=&quot;row in rows&quot;
  :key=&quot;row.id&quot;
  v-memo=&quot;[row.id === selectedId, row.updatedAt]&quot;
&gt;
  ...
&lt;/tr&gt;</code></pre>
<p>When the selection changes, only two of the 5,000 rows — the previously and newly selected — re-render. But let's be honest: <code>v-memo</code> is a sharp knife. Get the dependency list wrong and you've told Vue 'never update this row'; the UI goes silently stale. We use it only on a measured bottleneck — never 'just in case'.</p>
<blockquote><p>Measure first, then optimize — but once you've measured, don't hesitate either. Optimization by guesswork creates more problems than it solves.</p></blockquote>
<p>The outcome: first paint halved, the typing freezes gone, and the code more readable than before. That's the satisfying part of performance work — once the diagnosis is right, the fix is usually a few lines.</p>]]></content:encoded>
      <category>Vue</category>
      <category>Performance</category>
    </item>
    <item>
      <title>Building a Design System from Scratch: Tokens to Components</title>
      <link>https://playerberry.com/blog/tasarim-sistemi-kurmak</link>
      <guid isPermaLink="true">https://playerberry.com/blog/tasarim-sistemi-kurmak</guid>
      <pubDate>Sat, 02 May 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[Our first system started with a color palette and collapsed into 14 button variants within three months. What keeps the second one standing isn't colors — it's a three-layer token architecture and one hard rule.]]></description>
      <content:encoded><![CDATA[<p>We built our first design system by picking a color palette. Six weeks later came the first 'special case': a campaign page wanted the button 'just a bit darker'. Then another. Three months in, the system had 14 button variants and nobody knew which one to use when. On the second attempt we started not with color, but with decisions.</p>
<h2>Decisions, Not Values</h2>
<p>What a design system actually standardizes isn't colors — it's decisions: how many variants does a button need? When does a card get a shadow? When is the error color used — and more importantly, when is it not? A palette picked before answering those questions is a pretty color collection, nothing more. Values are the output of decisions, not the input.</p>
<h2>Three Layers: Raw, Semantic, Component</h2>
<p>Our token architecture has three layers, and components never look at raw values — only at semantic roles:</p>
<pre><code>// 1) Raw values — no meaning, just constants:
@berry-500: #ff3d77;
@ink-900: #0a0a10;

// 2) Semantic roles — 'what is this value for':
@color-danger: @berry-500;
@surface-base: @ink-900;

// 3) Component tokens — one specific use:
@button-danger-bg: @color-danger;</code></pre>
<p>At first glance it looks like a needless indirection — until the first theming request arrives. Dark theme, branded white theme, high contrast: all of them come out of changing only the second layer, without touching a line of component code. The moment you write <code>#ff3d77</code> inside a component, that flexibility is gone; the value now lives there and won't travel.</p>
<h2>Resist Variant Inflation</h2>
<p>On the component side the principle is the same: a <code>Button</code> offers 'primary, secondary, danger' — not 'blue button, red button'. The designer says 'this action is destructive', the developer picks the <code>danger</code> variant; what color that is stays the system's internal business, not the page's.</p>
<p>Keeping the variant count low is a conscious fight. Every new variant is another surface to test, document and keep consistent. Those 14 buttons in our first system taught us the lesson: everything added 'just in case' becomes the fastest-rotting corner of the system.</p>
<h2>The One Hard Rule</h2>
<p>The rule that has kept the system alive for two years is one sentence: no value that isn't in the design enters the code, no component that isn't in the code enters the design. Both sides feed from the same token source. The rule gets annoying at times — the 'it's a two-minute job, I'll just hardcode it' temptation never dies — but those two-minute jobs are exactly what kills systems. Special-case CSS is the first sign of rot; let one in and more will follow.</p>
<blockquote><p>A design system isn't a project — it's an agreement. Projects end; an agreement lives as long as both sides honor it.</p></blockquote>
<p>Our second system has been growing for six months without a single line of special-case CSS. Opening a new page no longer means writing CSS; it means arranging existing components. And everything we learned from the wreck of the first system sits under this one — which might be the real first step of building a design system: crashing one.</p>]]></content:encoded>
      <category>Design Systems</category>
      <category>Less</category>
    </item>
    <item>
      <title>Interfaces That Feel Native with SwiftUI</title>
      <link>https://playerberry.com/blog/swiftui-ile-native</link>
      <guid isPermaLink="true">https://playerberry.com/blog/swiftui-ile-native</guid>
      <pubDate>Fri, 27 Mar 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[The honest answer to 'couldn't we just use Flutter?' — you could, but that feel won't be there. The three details behind the native feel, and how we handle them in SwiftUI.]]></description>
      <content:encoded><![CDATA[<p>Nearly every project kickoff includes the same question: 'Couldn't we go cross-platform?' The honest answer: you could — but that feel won't be there. An app feeling native comes down to three details: the curve of its transition animations, the timing of its haptic feedback, and its scroll physics. Users can't name them, but they sense the difference instantly. SwiftUI's edge is that it takes all three straight from the platform; it doesn't imitate them, it uses them.</p>
<h2>Don't Reinvent the Spring</h2>
<p>iOS's spring animations are the product of a decade-plus of tuning. An animation with the right duration but the wrong curve leaves users with a 'something's off' feeling they can't explain. With the real thing available, there's no reason to draw curves by hand:</p>
<pre><code>withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
    isExpanded.toggle()
}</code></pre>
<p>Two parameters, and the animation speaks the platform's language. Haptic feedback lives in the same cheap-win category: a tiny, well-timed tap — <code>sensoryFeedback(.success, trigger:)</code> — gives an action the feeling that it 'really happened'. These aren't decoration; they're the building blocks of user trust.</p>
<h2>Keep Views Dumb</h2>
<p>The one principle we hold onto tightly in architecture: views are dumb. They know no business logic; they take data and draw. State management lives in the container layer, navigation in its own:</p>
<pre><code>struct AvatarView: View {
    let user: User

    var body: some View {
        HStack(spacing: 12) {
            Circle()
                .fill(.pink.gradient)
                .frame(width: 44, height: 44)
            Text(user.name)
                .font(.headline)
        }
    }
}</code></pre>
<p>The everyday payoff of this split shows up in previews: a dumb view can be previewed on its own, without booting the app. We sit with the designer looking at the same screen and settle the 'should this gap be 4 or 8' debate on the code, instantly. Previews became our living design document; the 'is it actually the same?' gap between Figma and code closed.</p>
<h2>Knowing Where the Border Is</h2>
<p>SwiftUI has matured, but it's not the weapon for every battle. Text editors that demand millimetric control, camera interfaces, very custom scroll behaviors — there we still drop down to UIKit and bridge with <code>UIViewRepresentable</code>. There's no defeat in that; the craft is knowing where to draw the line. A dogmatic 'everything must be SwiftUI' stance is the most reliable way to turn a simple task into an epic.</p>
<blockquote><p>The question is no longer 'can SwiftUI do it?' — it's 'where is it done cleanest?'</p></blockquote>
<p>Where things stand today: the native feel comes nearly free, and you spend your effort on your own product decisions instead. A one-person team that respects the details can ship something App Store quality in weeks. 'Wouldn't Flutter have worked?' It would have. But the difference is felt in those first three seconds users can't put into words.</p>]]></content:encoded>
      <category>Swift</category>
      <category>iOS</category>
    </item>
    <item>
      <title>Monorepo Architecture: One Repo, Many Products</title>
      <link>https://playerberry.com/blog/monorepo-mimarisi</link>
      <guid isPermaLink="true">https://playerberry.com/blog/monorepo-mimarisi</guid>
      <pubDate>Tue, 10 Feb 2026 09:00:00 GMT</pubDate>
      <description><![CDATA[A field got renamed in the API; web updated, mobile was forgotten, and the bug blew up in production three days later. That week we decided to fold three repos into one.]]></description>
      <content:encoded><![CDATA[<p>The event that made the decision for us: a field in the API got renamed. The web team updated the same day; the counterpart in the mobile repo slipped through. The bug surfaced three days later, in production, as 'my profile page is empty' messages landing in customer support. Three repos, three versions, pull requests waiting on each other... That week we decided to move everything that shares a contract into a single repo.</p>
<h2>The Comfort of Atomic Change</h2>
<p>The real promise of a monorepo is one word: atomicity. Rename a field in the API schema and the web and mobile code consuming it update in the same pull request. Whatever breaks is right there in the diff, in front of your eyes. In separate repos the same job becomes a version-bump dance: publish the package, bump it in three repos, keep a table of which version is compatible with which. In one repo, the main branch is consistent with itself at every moment — for us, that was the sentence that ended the architecture debate.</p>
<h2>The Setup: Less Than You'd Think</h2>
<p>Our recipe is pnpm workspaces + Turborepo; the core is two small files:</p>
<pre><code># pnpm-workspace.yaml
packages:
  - &quot;apps/*&quot; # web, mobile, api
  - &quot;packages/*&quot; # shared: ui, config, api-client</code></pre>
<pre><code>{
  &quot;tasks&quot;: {
    &quot;build&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;],
      &quot;outputs&quot;: [&quot;dist/**&quot;]
    },
    &quot;test&quot;: { &quot;dependsOn&quot;: [&quot;build&quot;] }
  }
}</code></pre>
<p><code>^build</code> means: before building a package, build what it depends on. Turborepo works out the task graph and serves everything unchanged from cache. As the repo grows this cache becomes gold — the repo tripled in size and our CI is still faster than on day one.</p>
<h2>The Real Issue: Layer Discipline</h2>
<p>The bad news: tools won't save you. Where monorepos collapse isn't technical, it's disciplinary — boundaries blur, everyone reaches into every package, and six months later you're holding a dependency tangle the size of a repo. Our rule is one-directional: apps may depend on packages; packages never on apps. And we entrust the rule to lint, not to people:</p>
<pre><code>{
  &quot;rules&quot;: {
    &quot;import/no-restricted-paths&quot;: [&quot;error&quot;, {
      &quot;zones&quot;: [
        { &quot;target&quot;: &quot;./packages&quot;, &quot;from&quot;: &quot;./apps&quot; }
      ]
    }]
  }
}</code></pre>
<p>Once the ban is automated, the debate ends too: a violation is a red CI mark, not a difference of opinion in code review. People spend their energy on the work, not on remembering rules.</p>
<blockquote><p>As the repo grows, add automation, not rules. A rule enforced by tools is permanent; a rule people remember lasts until that person goes on holiday.</p></blockquote>
<p>One honest footnote: a monorepo isn't free. CI takes real effort to set up, git history gets crowded, and misconfigured, everyone ends up waiting on everyone. For products that don't share a contract and live separate lives, we still recommend separate repos. But ever since that three-day production bug, we can't accept code that speaks the same contract living in different houses.</p>]]></content:encoded>
      <category>Architecture</category>
      <category>Tooling</category>
    </item>
  </channel>
</rss>
