<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>moli.codes</title>
    <link>https://moli.codes</link>
    <description>moli&apos;s personal site: projects, blog posts, live status, music, and developer notes.</description>
    <language>en</language>
    <atom:link href="https://moli.codes/feed.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Sat, 01 Aug 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>my first hackathon</title>
      <link>https://moli.codes/blog/first-hackathon-wraithathon</link>
      <guid isPermaLink="true">https://moli.codes/blog/first-hackathon-wraithathon</guid>
      <pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate>
      <description>went to wraithathon with no idea what to expect, came back with second place and a lot of notes.</description>
      <content:encoded><![CDATA[<h1>my first hackathon</h1>
<p>i went to my first hackathon last week. wraithathon, mobile track, roughly a day and a night. we came second.</p>
<p>i had been putting this off for years. every time one came up i found a reason: not ready, don&#39;t have a team, don&#39;t have an idea good enough. the actual reason was simpler than all of that. i had never done one and didn&#39;t want to be bad at it in a room full of people.</p>
<p>turns out nobody is watching you. everyone is staring at their own screen, panicking about their own thing.</p>
<h2>what we built</h2>
<p>sefer. you write one sentence — <code>İstanbul&#39;dan Kapadokya&#39;ya 2 kişi, 3 günlük sakin ve bütçe dostu bir gezi planla</code> — and it gives you a day-by-day route with real transport prices, real hotels, a map, and links to actually book it.</p>
<p>android, kotlin, compose. i took the app, ertuğrul took the scraper: a small fastapi service pulling live prices off the sites that actually sell tickets. two repos, one demo.</p>
<p>the part i&#39;m still happy about is where we let the model do things and where we didn&#39;t. gemini parses your sentence and shapes the days. it is not allowed to produce a single price. every number on screen comes from the data layer, and the line under the route title tells you which layer it came from — live scraper, bundled scrape, or an estimate.</p>
<p>that last bit is what actually saved the demo.</p>
<h2>the thing that saved the demo</h2>
<p>the venue wifi was the venue wifi.</p>
<p>we had built a fallback chain mostly because it felt like the correct thing to do: try the live service, fall back to the last real scrape bundled into the apk, fall back to a distance-based estimate. never show an empty screen, and always say out loud which source you&#39;re on.</p>
<p>on stage the live service was not reachable. the app just quietly dropped a layer and kept going. nobody in the room could tell, and if they had asked, the answer was printed right there on the screen.</p>
<p>if there&#39;s one thing i took from this: <strong>the demo is the product.</strong> not the architecture, not the test suite. the three minutes in front of the jury is the whole thing you built, and every decision should be judged by whether it survives those three minutes with no network.</p>
<h2>the night</h2>
<p>46 commits in about nineteen hours. i can watch the whole night in <code>git log</code>.</p>
<p>the early messages are full sentences. by 3am they read <code>f</code>, <code>easter</code>, <code>map arrows</code>, <code>earth kaldirildi tamamen</code>. somewhere in there the entire ui got rebuilt around the map because we realised navigating between screens was destroying and rebuilding the map twice on the way to a plan. that was not a planned refactor. that was 8pm, someone saying &quot;this feels bad&quot;, and doing it anyway.</p>
<p>i also lost real time to turkish lowercase. <code>&quot;İstanbul&quot;.lowercase()</code> doesn&#39;t do what you want with the default locale, and it silently stopped matching our own data. very funny at noon. not funny at 4am.</p>
<h2>what i&#39;d do differently</h2>
<ul>
<li><strong>sleep some.</strong> i didn&#39;t. the code i wrote between 4 and 6am was mostly undone by 8. the hours were real, the progress wasn&#39;t.</li>
<li><strong>write the demo script early.</strong> we wrote ours near the end and then discovered which paths were fragile. doing it first would have told us what to build.</li>
<li><strong>decide who owns what in the first hour.</strong> we got this one right by accident. two people, two repos, one contract between them, almost no merge pain all night.</li>
<li><strong>stop polishing.</strong> i pushed a launcher icon fix at 5am. nobody looked at the launcher icon.</li>
</ul>
<h2>going again</h2>
<p>the honest summary: it was a day of building with no room to overthink, which is the condition i&#39;m best in and almost never actually put myself in. the deadline does something no self-imposed plan has ever done for me.</p>
<p>second place is nice. the thing i&#39;ll keep is that i now know what a day of this feels like, and the next one won&#39;t have that &quot;i&#39;ve never done one&quot; tax on it.</p>
<p>going to the next one.</p>
<ul>
<li><a href="https://github.com/lostf1sh/sefer">sefer</a> — the app</li>
<li><a href="https://github.com/ertugrulerden/sefer-scraper">sefer-scraper</a> — the price service</li>
</ul>
<p>-- moli</p>
]]></content:encoded>
      <category>hackathon</category>
      <category>personal</category>
      <category>android</category>
      <category>sefer</category>
    </item>
    <item>
      <title>i&apos;m 18 now</title>
      <link>https://moli.codes/blog/turning-18</link>
      <guid isPermaLink="true">https://moli.codes/blog/turning-18</guid>
      <pubDate>Sat, 06 Jun 2026 00:00:00 GMT</pubDate>
      <description>turned 18 today. nothing flipped overnight, but writing it down anyway.</description>
      <content:encoded><![CDATA[<h1>i&#39;m 18 now</h1>
<p>turned 18 today.</p>
<p>i kept expecting to feel different. some switch flipping at midnight. it didn&#39;t. same room, same projects open, same half-finished repos staring back at me. i woke up and the little age counter on the homepage just rolled over.</p>
<p>writing it down anyway, because the small stuff adds up even when no single day feels like much.</p>
<p>started taking this seriously a few years back. picked up linux properly somewhere in here. broke things, fixed them, broke them again. most of what i know now i didn&#39;t a couple years ago — i only really notice it looking back.</p>
<p>so the honest version: i don&#39;t have it figured out, and i&#39;m not supposed to yet. the plan is the same as yesterday. keep building small things. ship more than i polish. stay curious.</p>
<p>eighteen just means i get to keep doing that with fewer excuses.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>personal</category>
      <category>life</category>
    </item>
    <item>
      <title>zed is really that good</title>
      <link>https://moli.codes/blog/zed-is-really-that-good</link>
      <guid isPermaLink="true">https://moli.codes/blog/zed-is-really-that-good</guid>
      <pubDate>Sun, 26 Apr 2026 00:00:00 GMT</pubDate>
      <description>the first editor in a long time that makes most other editors feel older than they are.</description>
      <content:encoded><![CDATA[<h1>zed is really that good</h1>
<p>most editors are either powerful but messy, or polished but slow.</p>
<p>zed is the first one in a while that feels fast, modern, and opinionated without becoming annoying. after using it seriously, i get the hype.</p>
<h2>speed is the feature</h2>
<p>people talk about speed like it is a benchmark problem. it is not. it is a mood problem.</p>
<p>when an editor opens instantly, search feels instant, no ui animations at all, no lag, your brain stays in the code instead of noticing the tool.</p>
<p>that is zed&#39;s biggest win.</p>
<p>it is written in rust, pushes rendering hard, and actually feels like a native app instead of a web app pretending to be one. vscode is still good, but next to zed it can feel a little heavier and a little older.</p>
<p>not broken. just older.</p>
<h2>the defaults are unusually sane</h2>
<p>this matters more than plugin people admit.</p>
<p>most editors ask you to assemble your ideal environment from parts:</p>
<ul>
<li>install extensions</li>
<li>fix keymaps</li>
<li>wire up language servers</li>
<li>debug your debugger</li>
<li>repeat on every machine</li>
</ul>
<p>zed starts from a much better place.</p>
<p>language support is good out of the box. the ui is clean. the keyboard shortcuts make sense. the settings format is readable. even vim mode is good enough that it does not feel like a compromise feature bolted on later.</p>
<p>that last part matters. bad vim mode is worse than no vim mode.</p>
<h2>collaboration is not an afterthought</h2>
<p>this is where zed still feels weirdly underrated.</p>
<p>most editors treat collaboration like a plugin, a screen share, or a temporary hack. zed treats it like part of the editor itself. shared projects, channels, notes, following another person&#39;s cursor, and built-in screen sharing all make more sense than the usual &quot;jump on a call and say line 184&quot; workflow.</p>
<p>if you pair program often, mentor people, or review work live, zed is operating in a category most editors barely acknowledge.</p>
<p>it feels closer to &quot;google docs for code, but serious&quot; than to traditional remote pair tools.</p>
<h2>the ai integration is less embarrassing than usual</h2>
<p>most ai editor features feel stapled on.</p>
<p>zed&#39;s recent direction is more interesting because it is not pretending there will only be one agent forever. the built-in agent exists, but zed also opened up external agents through acp, so the editor can act more like a good client than a locked platform.</p>
<p>that is the right idea.</p>
<p>i do not want my editor to marry one model vendor. i want a good interface for code review, codebase context, multi-file edits, and letting me swap the brain behind it when the market changes again next month.</p>
<p>in 2026, that flexibility matters a lot more than a flashy demo.</p>
<h2>it is finally becoming a complete editor</h2>
<p>the old criticism of zed was always the same: &quot;cool, fast, but not ready.&quot;</p>
<p>that criticism made sense for a while.</p>
<p>it makes less sense now.</p>
<p>the debugger is here. external agents are here. collaboration is mature enough to be a real differentiator. the project is open source. releases ship constantly. it still has rough edges, but it no longer feels like a beautiful preview of a future editor. it feels like an actual editor.</p>
<p>that is a big difference.</p>
<h2>where it is still weaker</h2>
<p>it is not magic.</p>
<p>if your workflow depends on some ultra-specific vscode extension, zed may still not replace it. if you have built your entire brain around terminal-first neovim workflows, zed can still feel a little too gui-native in places. and because it is moving fast, you should expect some churn.</p>
<p>but these are normal tradeoffs, not fatal flaws.</p>
<p>the important part is that zed is now good enough that people are choosing it for what it is, not just for what it might become.</p>
<h2>verdict</h2>
<p>zed is good in the way good tools usually are: it removes friction you had quietly accepted.</p>
<p>faster startup. better defaults. real collaboration. a serious vim mode. ai features that are flexible instead of captive. and now enough core editor surface area that recommending it no longer feels like telling someone to &quot;wait for 1.0.&quot;</p>
<p>it is not perfect.</p>
<p>it is just one of the few editors right now that actually feels like it was built in the current decade.</p>
<p>that alone makes it worth taking seriously.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>zed</category>
      <category>editor</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>linux as a daily driver in 2026</title>
      <link>https://moli.codes/blog/linux-daily-driver-2026</link>
      <guid isPermaLink="true">https://moli.codes/blog/linux-daily-driver-2026</guid>
      <pubDate>Sat, 14 Mar 2026 00:00:00 GMT</pubDate>
      <description>it finally feels like &quot;the year of linux desktop&quot; stopped being a joke.</description>
      <content:encoded><![CDATA[<h1>linux as a daily driver in 2026</h1>
<p>i have been running linux as my only operating system for over three years now. no dual boot, no macos laptop on the side, no windows vm &quot;just in case.&quot; here is everything i learned, broke, fixed, and settled on.</p>
<h2>choosing a distro</h2>
<p>this is where everyone gets stuck. i have distro-hopped enough to have opinions.</p>
<h3>what i have tried</h3>
<table>
<thead>
<tr>
<th>distro</th>
<th>time spent</th>
<th>verdict</th>
</tr>
</thead>
<tbody><tr>
<td>fedora</td>
<td>~1 months</td>
<td>sucks imo, dnf was slow back then</td>
</tr>
<tr>
<td>arch</td>
<td>~1 year</td>
<td>learned a lot, broke things often</td>
</tr>
<tr>
<td>nixos</td>
<td>~3 months</td>
<td>beautiful concept, steep learning curve</td>
</tr>
<tr>
<td>cachyos</td>
<td>current</td>
<td>arch-based, sane defaults, performance patches</td>
</tr>
</tbody></table>
<h3>why cachyos</h3>
<p>cachyos ships with performance-optimized kernels (bore scheduler, cachyline patches), a graphical installer that actually works, and pacman with parallel downloads out of the box. it is arch underneath so you get the aur and the wiki without the weekend-long install ritual.</p>
<pre><code class="language-bash"># check your scheduler
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
# performance

# kernel version
uname -r
# 6.19.7-1-cachyos
</code></pre>
<p>if you want arch but value your weekends, cachyos is the answer.</p>
<h2>desktop environment</h2>
<h3>the wayland situation</h3>
<p>wayland in 2026 is finally stable enough to recommend to everyone. xwayland handles legacy apps, screen sharing works in browsers, and most compositors have caught up.</p>
<p>i use hyprland. it is a tiling wayland compositor with smooth animations and a config file that actually makes sense.</p>
<pre><code class="language-conf"># ~/.config/hypr/hyprland.conf

monitor=DP-1, 2560x1440@165, 0x0, 1
monitor=HDMI-A-1, 1920x1080@60, 2560x0, 1

input {
    kb_layout = tr
    follow_mouse = 1
    sensitivity = -0.3
}

general {
    gaps_in = 4
    gaps_out = 8
    border_size = 2
    col.active_border = rgb(cba6f7)
    col.inactive_border = rgb(313244)
    layout = dwindle
}

animations {
    enabled = true
    bezier = snappy, 0.05, 0.9, 0.1, 1.05
    animation = windows, 1, 5, snappy
    animation = fade, 1, 4, default
    animation = workspaces, 1, 4, snappy, slide
}
</code></pre>
<h3>why tiling</h3>
<p>once you go tiling, floating feels like chaos. every window has its place. no overlapping, no searching for that terminal you minimized twenty minutes ago.</p>
<p>my layout is usually:</p>
<pre><code>┌──────────────┬──────────┐
│              │ terminal │
│   browser    ├──────────┤
│              │  editor  │
└──────────────┴──────────┘
</code></pre>
<p><code>super + 1-9</code> switches workspaces. <code>super + enter</code> opens a terminal. <code>super + q</code> closes a window. muscle memory takes a week to build, then you never look back.</p>
<h3>status bar</h3>
<p>waybar with a minimal config. clock, workspaces, volume, battery, network. no system tray icons because i do not need fifteen applets telling me things i already know.</p>
<pre><code class="language-json">{
    &quot;layer&quot;: &quot;top&quot;,
    &quot;position&quot;: &quot;top&quot;,
    &quot;height&quot;: 32,
    &quot;modules-left&quot;: [&quot;hyprland/workspaces&quot;],
    &quot;modules-center&quot;: [&quot;clock&quot;],
    &quot;modules-right&quot;: [&quot;pulseaudio&quot;, &quot;network&quot;, &quot;battery&quot;],
    &quot;clock&quot;: {
        &quot;format&quot;: &quot;{:%H:%M}&quot;,
        &quot;format-alt&quot;: &quot;{:%Y-%m-%d}&quot;
    }
}
</code></pre>
<h2>the daily workflow</h2>
<h3>app launcher</h3>
<p>rofi on wayland (rofi-wayland fork). fast, themeable, handles both app launching and window switching.</p>
<pre><code class="language-bash"># bind in hyprland.conf
bind = SUPER, d, exec, rofi -show drun -theme catppuccin-mocha
</code></pre>
<h3>notifications</h3>
<p>dunst replaced with mako. lighter, wayland-native, and the config is three lines:</p>
<pre><code class="language-ini">[urgency=low]
background-color=#1e1e2e
text-color=#cdd6f4
border-color=#313244

[urgency=normal]
background-color=#1e1e2e
text-color=#cdd6f4
border-color=#cba6f7

[urgency=critical]
background-color=#1e1e2e
text-color=#f38ba8
border-color=#f38ba8
</code></pre>
<h3>file management</h3>
<p>thunar for when i need a gui, but honestly <code>lf</code> (terminal file manager) handles 90% of my file operations. it is like ranger but written in go and much faster.</p>
<pre><code class="language-bash"># ~/.config/lf/lfrc
set previewer ctpv
set cleaner ctpvclear
set shell zsh
set icons true
</code></pre>
<h3>screenshots</h3>
<p>grimblast. one keybind for area selection, one for full screen, auto-copies to clipboard.</p>
<pre><code class="language-bash">bind = , Print, exec, grimblast --notify copy area
bind = SHIFT, Print, exec, grimblast --notify copy screen
</code></pre>
<h2>audio and media</h2>
<h3>pipewire</h3>
<p>pipewire replaced pulseaudio and it just works. low latency, bluetooth codec switching, no configuration needed for 99% of use cases.</p>
<pre><code class="language-bash"># check pipewire is running
systemctl --user status pipewire wireplumber
</code></pre>
<h3>bluetooth</h3>
<p>bluetooth on linux used to be a nightmare. with bluez 5.x and pipewire, my sony headphones connect on boot, codec switches to ldac automatically, and battery level shows in waybar.</p>
<p>the only trick: make sure <code>experimental = true</code> is in <code>/etc/bluetooth/main.conf</code> for battery reporting.</p>
<h3>music</h3>
<p>ncmpcpp + mpd for local music, spotify via the flatpak client. last.fm scrobbling works through mpdscribble for local and the spotify client handles its own.</p>
<h2>gaming</h2>
<h3>the steam deck effect</h3>
<p>valve single-handedly made linux gaming viable. proton translates directx to vulkan, and the compatibility database is enormous.</p>
<pre><code class="language-bash"># check vulkan support
vulkaninfo --summary

# install steam
sudo pacman -S steam
</code></pre>
<h3>what works</h3>
<ul>
<li><strong>native games</strong>: most indie games ship linux builds. factorio, celeste, hollow knight all run natively.</li>
<li><strong>proton</strong>: elden ring, cyberpunk 2077, baldur&#39;s gate 3 — all run through proton with minimal setup.</li>
<li><strong>anti-cheat</strong>: the biggest remaining pain point. some games with kernel-level anti-cheat still refuse to run.</li>
</ul>
<h3>what does not</h3>
<ul>
<li>valorant. vanguard anti-cheat requires windows kernel access. not happening.</li>
<li>some competitive fps games with aggressive anti-cheat.</li>
<li>adobe creative suite. not a game, but worth mentioning. gimp and inkscape are good, but they are not photoshop.</li>
</ul>
<h3>performance</h3>
<p>on my rx 7900 xtx with mesa drivers, most games run at 95-100% of windows performance. some vulkan-native games actually run <em>faster</em> on linux.</p>
<pre><code class="language-bash"># useful environment variables for gaming
MANGOHUD=1          # fps overlay
DXVK_HUD=fps       # directx translation overlay
mesa_glthread=true  # opengl threading
</code></pre>
<h3>gamescope</h3>
<p>valve&#39;s micro-compositor for gaming. handles resolution scaling, hdr, frame limiting. i use it for every game:</p>
<pre><code class="language-bash">gamescope -w 2560 -h 1440 -r 165 -f -- %command%
</code></pre>
<h2>the pain points</h2>
<h3>fractional scaling</h3>
<p>4k monitors at 150% scaling still have blurry xwayland apps. the fix is per-app: force wayland on electron apps with <code>--ozone-platform=wayland</code>, use native wayland firefox, and accept that some gtk3 apps will look slightly off.</p>
<pre><code class="language-bash"># force electron apps to use wayland
echo &quot;--ozone-platform-hint=auto&quot; &gt;&gt; ~/.config/electron-flags.conf
</code></pre>
<h3>printer setup</h3>
<p>every six months i need to print something and every six months i rediscover that cups exists. the web interface at <code>localhost:631</code> is ugly but functional.</p>
<h3>video calls</h3>
<p>screen sharing in teams/zoom on wayland requires xdg-desktop-portal-hyprland. once installed, it works. but &quot;once installed&quot; took me an hour of searching the first time.</p>
<pre><code class="language-bash">sudo pacman -S xdg-desktop-portal-hyprland
</code></pre>
<h3>firmware updates</h3>
<p>fwupd handles most firmware updates through <code>fwupdmgr</code>. but some hardware (looking at you, certain wifi cards) still requires windows to update firmware.</p>
<h2>dotfiles management</h2>
<p>i use chezmoi for syncing configs across machines. wrote a whole post about it, but the tldr:</p>
<pre><code class="language-bash">chezmoi init --apply https://github.com/lostf1sh/dotfiles
</code></pre>
<p>one command, entire setup replicated. new machine goes from fresh install to fully configured in under ten minutes.</p>
<h2>the ecosystem in 2026</h2>
<h3>what got better</h3>
<ul>
<li><strong>wayland</strong>: finally mature. screen sharing, clipboard, multi-monitor all work.</li>
<li><strong>gaming</strong>: proton is a miracle. most steam games just work.</li>
<li><strong>flatpak</strong>: sandboxed apps that update themselves. great for gui apps.</li>
<li><strong>fonts</strong>: font rendering on linux is actually better than windows now.</li>
</ul>
<h3>what still needs work</h3>
<ul>
<li><strong>adobe alternatives</strong>: gimp 3.0 is better but the ux gap is still real.</li>
<li><strong>office compatibility</strong>: libreoffice handles most things, but complex excel macros break.</li>
<li><strong>anti-cheat</strong>: kernel-level anti-cheat is the last major gaming barrier.</li>
</ul>
<h2>who should try it</h2>
<p>if you are a developer, linux is the obvious choice. your deployment target is probably linux anyway. docker runs natively. package managers are first-class.</p>
<p>if you are a gamer who plays mostly single-player or steam games, you will be fine. check protondb before buying.</p>
<p>if you depend on adobe or microsoft office daily, dual boot or use a vm. do not try to force it.</p>
<h2>my current stack</h2>
<table>
<thead>
<tr>
<th>component</th>
<th>choice</th>
</tr>
</thead>
<tbody><tr>
<td>distro</td>
<td>cachyos</td>
</tr>
<tr>
<td>kernel</td>
<td>6.19-cachyos (bore)</td>
</tr>
<tr>
<td>compositor</td>
<td>hyprland</td>
</tr>
<tr>
<td>terminal</td>
<td>alacritty</td>
</tr>
<tr>
<td>shell</td>
<td>zsh</td>
</tr>
<tr>
<td>editor</td>
<td>zed</td>
</tr>
<tr>
<td>browser</td>
<td>firefox (wayland)</td>
</tr>
<tr>
<td>file manager</td>
<td>thunar + lf</td>
</tr>
<tr>
<td>launcher</td>
<td>rofi-wayland</td>
</tr>
<tr>
<td>bar</td>
<td>waybar</td>
</tr>
<tr>
<td>notifications</td>
<td>mako</td>
</tr>
<tr>
<td>audio</td>
<td>pipewire</td>
</tr>
<tr>
<td>gaming</td>
<td>steam + proton + gamescope</td>
</tr>
</tbody></table>
<p>three years in and i do not miss windows. not even a little.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>linux</category>
      <category>setup</category>
      <category>workflow</category>
      <category>gaming</category>
      <category>opinion</category>
    </item>
    <item>
      <title>git beyond the basics</title>
      <link>https://moli.codes/blog/git-beyond-the-basics</link>
      <guid isPermaLink="true">https://moli.codes/blog/git-beyond-the-basics</guid>
      <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
      <description>the commands that separate &quot;i use git&quot; from &quot;i understand git.&quot;</description>
      <content:encoded><![CDATA[<h1>git beyond the basics</h1>
<p>everyone knows <code>add</code>, <code>commit</code>, <code>push</code>, <code>pull</code>. but git has an entire layer of powerful tools that most developers never touch. these are the ones that changed how i work.</p>
<h2>interactive rebase</h2>
<p>this is the single most useful advanced git feature. it lets you rewrite history before pushing.</p>
<pre><code class="language-bash">git rebase -i HEAD~5
</code></pre>
<p>this opens your editor with the last 5 commits:</p>
<pre><code>pick a1b2c3d fix login bug
pick e4f5g6h add user avatar
pick i7j8k9l wip
pick m0n1o2p fix typo
pick q3r4s5t add avatar tests
</code></pre>
<h3>what you can do</h3>
<table>
<thead>
<tr>
<th>command</th>
<th>effect</th>
</tr>
</thead>
<tbody><tr>
<td><code>pick</code></td>
<td>keep the commit as-is</td>
</tr>
<tr>
<td><code>reword</code></td>
<td>change the commit message</td>
</tr>
<tr>
<td><code>edit</code></td>
<td>stop at commit, let you amend</td>
</tr>
<tr>
<td><code>squash</code></td>
<td>merge into previous commit</td>
</tr>
<tr>
<td><code>fixup</code></td>
<td>like squash but discard message</td>
</tr>
<tr>
<td><code>drop</code></td>
<td>remove the commit entirely</td>
</tr>
</tbody></table>
<h3>real workflow</h3>
<p>that <code>wip</code> and <code>fix typo</code> commit? squash them into the commits they belong to:</p>
<pre><code>pick a1b2c3d fix login bug
pick e4f5g6h add user avatar
fixup i7j8k9l wip
fixup m0n1o2p fix typo
pick q3r4s5t add avatar tests
</code></pre>
<p>result: clean history with three meaningful commits instead of five noisy ones.</p>
<h3>the golden rule</h3>
<p>never rebase commits that have been pushed to a shared branch. rewriting published history breaks everyone&#39;s local repos. rebase your local branch before pushing, not after.</p>
<h2>git bisect</h2>
<p>you know something broke between version 2.1 and now, but there are 200 commits in between. bisect finds the exact commit using binary search.</p>
<pre><code class="language-bash">git bisect start
git bisect bad                 # current commit is broken
git bisect good v2.1           # this tag was working
</code></pre>
<p>git checks out the middle commit. you test it and tell git:</p>
<pre><code class="language-bash">git bisect good   # this commit is fine
# or
git bisect bad    # this commit is broken
</code></pre>
<p>git narrows down by half each time. 200 commits takes at most 8 steps.</p>
<h3>automated bisect</h3>
<p>even better — give it a test script:</p>
<pre><code class="language-bash">git bisect start HEAD v2.1
git bisect run npm test
</code></pre>
<p>git runs <code>npm test</code> at each step and finds the first failing commit automatically. walk away, get coffee, come back to the answer.</p>
<h3>when i use it</h3>
<ul>
<li>a test that used to pass now fails and nobody knows why</li>
<li>performance degraded somewhere in the last month</li>
<li>a css layout broke and the commit is not obvious from the diff</li>
</ul>
<p>bisect turns a needle-in-a-haystack problem into a five-minute task.</p>
<h2>git worktrees</h2>
<p>worktrees let you have multiple branches checked out at the same time in different directories. no stashing, no context switching, no losing your place.</p>
<pre><code class="language-bash"># create a worktree for a hotfix
git worktree add ../hotfix-branch hotfix/login-crash

# now you have two directories:
# ~/project          → main branch (your current work)
# ~/hotfix-branch    → hotfix/login-crash
</code></pre>
<h3>why this is better than stashing</h3>
<p>with stash:</p>
<ol>
<li>save your work (<code>git stash</code>)</li>
<li>switch branch (<code>git checkout hotfix</code>)</li>
<li>fix the issue</li>
<li>switch back (<code>git checkout main</code>)</li>
<li>restore your work (<code>git stash pop</code>)</li>
<li>hope nothing conflicts</li>
</ol>
<p>with worktrees:</p>
<ol>
<li>create worktree (<code>git worktree add</code>)</li>
<li>fix the issue in the other directory</li>
<li>done. your original work never moved.</li>
</ol>
<h3>managing worktrees</h3>
<pre><code class="language-bash">git worktree list           # see all worktrees
git worktree remove ../hotfix-branch  # clean up when done
git worktree prune          # remove stale entries
</code></pre>
<p>i keep a convention: worktrees go in sibling directories named after the branch. <code>~/project</code> stays on main, <code>~/project-feat-x</code> is a feature branch.</p>
<h3>when i use them</h3>
<ul>
<li>reviewing someone&#39;s pr while i have uncommitted work</li>
<li>working on a hotfix without disrupting my feature branch</li>
<li>running tests on one branch while coding on another</li>
</ul>
<h2>git hooks</h2>
<p>hooks are scripts that run automatically at specific points in the git workflow. they live in <code>.git/hooks/</code> or you can manage them with tools like husky or lefthook.</p>
<h3>useful hooks</h3>
<pre><code class="language-bash"># .git/hooks/pre-commit
#!/bin/sh
# run linter before every commit
npm run lint --quiet
if [ $? -ne 0 ]; then
    echo &quot;lint failed. fix errors before committing.&quot;
    exit 1
fi
</code></pre>
<pre><code class="language-bash"># .git/hooks/commit-msg
#!/bin/sh
# enforce conventional commit messages
if ! head -1 &quot;$1&quot; | grep -qE &quot;^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,50}&quot;; then
    echo &quot;invalid commit message format.&quot;
    echo &quot;use: type(scope): description&quot;
    exit 1
fi
</code></pre>
<h3>shared hooks with lefthook</h3>
<pre><code class="language-yaml"># lefthook.yml
pre-commit:
  parallel: true
  commands:
    lint:
      run: npx eslint {staged_files} --fix
    format:
      run: npx prettier {staged_files} --write
    types:
      run: npx tsc --noEmit

pre-push:
  commands:
    test:
      run: npm test
</code></pre>
<p>hooks catch problems before they reach the remote. lint on commit, test on push, format on save. automate the boring stuff.</p>
<h3>the escape hatch</h3>
<pre><code class="language-bash">git commit --no-verify  # skip hooks (use sparingly)
</code></pre>
<p>sometimes you need to commit broken code to switch context. that is fine. just do not push it.</p>
<h2>git reflog</h2>
<p>reflog is git&#39;s undo button. it records every change to HEAD, even things that <code>git log</code> does not show.</p>
<pre><code class="language-bash">git reflog
</code></pre>
<pre><code>a1b2c3d HEAD@{0}: commit: add tests
e4f5g6h HEAD@{1}: rebase: squash
i7j8k9l HEAD@{2}: rebase: start
m0n1o2p HEAD@{3}: commit: wip
q3r4s5t HEAD@{4}: checkout: moving from main to feature
</code></pre>
<h3>recovering from disasters</h3>
<p>accidentally deleted a branch:</p>
<pre><code class="language-bash">git reflog                    # find the commit hash
git branch recovered a1b2c3d  # recreate the branch
</code></pre>
<p>rebase went wrong:</p>
<pre><code class="language-bash">git reflog                        # find the state before rebase
git reset --hard HEAD@{3}         # go back to that state
</code></pre>
<p>force pushed and lost commits:</p>
<pre><code class="language-bash">git reflog                        # they are still there locally
git cherry-pick a1b2c3d           # rescue specific commits
</code></pre>
<h3>how long does reflog keep things</h3>
<p>by default, 90 days for reachable refs and 30 days for unreachable ones. you can change this:</p>
<pre><code class="language-bash">git config gc.reflogExpire &quot;180 days&quot;
</code></pre>
<p>the lesson: almost nothing in git is truly lost. if you committed it, reflog remembers.</p>
<h2>git stash (properly)</h2>
<p>most people know <code>git stash</code> and <code>git stash pop</code>. but stash has more to offer.</p>
<h3>named stashes</h3>
<pre><code class="language-bash">git stash push -m &quot;wip: login redesign&quot;
git stash push -m &quot;experiment: new api format&quot;
git stash list
# stash@{0}: On main: experiment: new api format
# stash@{1}: On main: wip: login redesign
</code></pre>
<p>without names, <code>stash list</code> is a wall of unhelpful &quot;WIP on main&quot; entries.</p>
<h3>partial stashes</h3>
<p>stash only specific files:</p>
<pre><code class="language-bash">git stash push -m &quot;just the css&quot; -- src/styles/
</code></pre>
<p>or interactively choose hunks:</p>
<pre><code class="language-bash">git stash push -p
</code></pre>
<h3>stash as a branch</h3>
<p>if a stash grows complex enough to keep, turn it into a branch:</p>
<pre><code class="language-bash">git stash branch new-feature stash@{0}
</code></pre>
<p>this creates a new branch from the stash point and applies the stash. clean.</p>
<h2>git blame and log archaeology</h2>
<h3>blame with context</h3>
<pre><code class="language-bash">git blame -L 50,70 src/auth.js
</code></pre>
<p>shows who wrote lines 50-70 and when. useful for understanding <em>why</em> code looks the way it does.</p>
<h3>ignore formatting commits</h3>
<p>if someone ran prettier across the whole codebase, blame becomes useless. fix it:</p>
<pre><code class="language-bash"># create .git-blame-ignore-revs
echo &quot;a1b2c3d  # prettier reformatting&quot; &gt;&gt; .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
</code></pre>
<p>now <code>git blame</code> skips that commit and shows the actual author.</p>
<h3>log tricks</h3>
<pre><code class="language-bash"># search commit messages
git log --grep=&quot;login&quot; --oneline

# find when a function was added
git log -S &quot;function authenticate&quot; --oneline

# find when a function was changed
git log -G &quot;authenticate.*token&quot; --oneline

# show commits that touched a specific file
git log --follow -- src/auth.js

# pretty graph
git log --oneline --graph --all
</code></pre>
<p>the <code>-S</code> flag (pickaxe) is incredibly powerful. it finds the commit that added or removed a specific string. combine it with <code>-p</code> to see the actual diff.</p>
<h2>git cherry-pick</h2>
<p>take a specific commit from one branch and apply it to another.</p>
<pre><code class="language-bash">git checkout main
git cherry-pick a1b2c3d
</code></pre>
<h3>when to use it</h3>
<ul>
<li>a bugfix landed on a feature branch but main needs it now</li>
<li>you committed to the wrong branch</li>
<li>you need one specific commit from a long-lived branch without merging everything</li>
</ul>
<h3>cherry-pick a range</h3>
<pre><code class="language-bash">git cherry-pick a1b2c3d..e4f5g6h
</code></pre>
<p>this applies all commits between those two hashes (exclusive of the first one).</p>
<h3>cherry-pick without committing</h3>
<pre><code class="language-bash">git cherry-pick --no-commit a1b2c3d
</code></pre>
<p>applies the changes but lets you modify them before committing. useful when the commit needs adaptation.</p>
<h2>git clean and reset</h2>
<h3>cleaning untracked files</h3>
<pre><code class="language-bash">git clean -n    # dry run: see what would be deleted
git clean -fd   # actually delete untracked files and directories
</code></pre>
<p>always dry run first. <code>git clean</code> is destructive and not recoverable (unless the files are in your editor&#39;s undo history).</p>
<h3>reset levels</h3>
<pre><code class="language-bash">git reset --soft HEAD~1   # undo commit, keep changes staged
git reset --mixed HEAD~1  # undo commit, keep changes unstaged (default)
git reset --hard HEAD~1   # undo commit, delete changes entirely
</code></pre>
<p><code>--soft</code> is my go-to when i committed too early. it puts everything back in staging so i can recommit properly.</p>
<p><code>--hard</code> is the nuclear option. use reflog if you mess this up.</p>
<h2>advanced config</h2>
<p>some git config options that improve daily life:</p>
<pre><code class="language-bash"># better diff algorithm
git config --global diff.algorithm histogram

# auto-correct typos (runs after 1.5s delay)
git config --global help.autocorrect 15

# reuse recorded resolution for merge conflicts
git config --global rerere.enabled true

# always rebase on pull instead of merge
git config --global pull.rebase true

# sort branches by most recent commit
git config --global branch.sort -committerdate

# sign commits with ssh key
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
</code></pre>
<h3>rerere</h3>
<p><code>rerere</code> stands for &quot;reuse recorded resolution.&quot; when you resolve a merge conflict, git remembers. next time the same conflict appears (rebasing, cherry-picking), it resolves automatically.</p>
<p>this alone saves hours over a long-lived feature branch.</p>
<h2>the mental model</h2>
<p>git is not a linear timeline. it is a directed acyclic graph of snapshots. every commit points to its parent(s). branches are just pointers to commits. HEAD is a pointer to the current commit.</p>
<p>once you internalize this, everything clicks:</p>
<ul>
<li><strong>rebase</strong> replays commits onto a new base</li>
<li><strong>merge</strong> creates a commit with two parents</li>
<li><strong>cherry-pick</strong> copies a commit&#39;s diff to a new location</li>
<li><strong>reset</strong> moves the branch pointer</li>
<li><strong>reflog</strong> records every pointer movement</li>
</ul>
<p>the commands are just different ways of manipulating pointers and snapshots. learn the model, and the commands make themselves obvious.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>git</category>
      <category>workflow</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>building this site: a deep dive</title>
      <link>https://moli.codes/blog/building-this-site</link>
      <guid isPermaLink="true">https://moli.codes/blog/building-this-site</guid>
      <pubDate>Fri, 06 Mar 2026 00:00:00 GMT</pubDate>
      <description>how a terminal aesthetic, vue 3, and too many late nights became f1sh.dev.</description>
      <content:encoded><![CDATA[<h1>building this site: a deep dive</h1>
<p>this site started as a weekend project and turned into something i actually care about. here is how every piece fits together, the decisions behind them, and the code that makes it work.</p>
<h2>the stack</h2>
<table>
<thead>
<tr>
<th>layer</th>
<th>choice</th>
<th>why</th>
</tr>
</thead>
<tbody><tr>
<td>framework</td>
<td>vue 3</td>
<td>composition api is clean, reactive without boilerplate</td>
</tr>
<tr>
<td>build</td>
<td>vite</td>
<td>instant hmr, fast builds, no webpack nightmares</td>
</tr>
<tr>
<td>styling</td>
<td>tailwind css</td>
<td>utility-first works perfectly for custom designs</td>
</tr>
<tr>
<td>colors</td>
<td>catppuccin mocha</td>
<td>warm, accessible, beautiful</td>
</tr>
<tr>
<td>fonts</td>
<td>jetbrains mono</td>
<td>monospace everywhere for the terminal feel</td>
</tr>
<tr>
<td>animations</td>
<td>motion-v</td>
<td>spring physics that feel natural</td>
</tr>
<tr>
<td>markdown</td>
<td>marked.js</td>
<td>lightweight, extensible renderer</td>
</tr>
<tr>
<td>syntax</td>
<td>prismjs</td>
<td>reliable, themeable code highlighting</td>
</tr>
<tr>
<td>routing</td>
<td>vue router</td>
<td>spa navigation with query-based blog urls</td>
</tr>
</tbody></table>
<p>no cms, no database, no server. markdown files, static hosting, done.</p>
<h2>the boot sequence</h2>
<p>when you first visit the site, you see a fake bios boot screen. it is silly. it is completely unnecessary. i love it.</p>
<pre><code class="language-javascript">onMounted(() =&gt; {
    if (sessionStorage.getItem(&quot;booted&quot;)) {
        done.value = true;
        return;
    }

    const prefersReducedMotion = window.matchMedia(
        &quot;(prefers-reduced-motion: reduce)&quot;
    ).matches;
    if (prefersReducedMotion) {
        sessionStorage.setItem(&quot;booted&quot;, &quot;true&quot;);
        done.value = true;
        return;
    }

    show.value = true;
    setTimeout(() =&gt; {
        show.value = false;
        sessionStorage.setItem(&quot;booted&quot;, &quot;true&quot;);
        setTimeout(() =&gt; { done.value = true; }, 400);
    }, 2500);
});
</code></pre>
<h3>design decisions</h3>
<ul>
<li><strong>runs once per session</strong>: <code>sessionStorage</code> means refreshing the page skips it. you only see it when you first arrive.</li>
<li><strong>respects reduced motion</strong>: if your os says you prefer reduced motion, the boot sequence is skipped entirely. accessibility is not optional.</li>
<li><strong>staggered lines</strong>: each line appears with a css <code>animation-delay</code>, simulating a real boot sequence. the timing is tuned to feel fast but readable.</li>
</ul>
<pre><code class="language-css">.boot-line {
    opacity: 0;
    animation: boot-appear 0.15s ease-out forwards;
}
</code></pre>
<p>the progress bar fills at 1.6 seconds and the whole thing wraps up at 2.5 seconds. long enough to register, short enough to not annoy.</p>
<h2>page transitions</h2>
<p>every page change has a smooth fade-and-slide animation powered by motion-v (a vue port of framer motion).</p>
<pre><code class="language-javascript">import { motion, AnimatePresence } from &quot;motion-v&quot;;

const pageEnter = { opacity: 0, y: 20 };
const pageAnimate = { opacity: 1, y: 0 };
const pageExit = { opacity: 0, y: -20 };
</code></pre>
<pre><code class="language-html">&lt;router-view v-slot=&quot;{ Component, route }&quot;&gt;
    &lt;AnimatePresence mode=&quot;wait&quot;&gt;
        &lt;motion.div
            :key=&quot;route.path&quot;
            :initial=&quot;isInitialLoad ? false : pageEnter&quot;
            :animate=&quot;pageAnimate&quot;
            :exit=&quot;pageExit&quot;
            :transition=&quot;pageTransition&quot;
        &gt;
            &lt;component :is=&quot;Component&quot; /&gt;
        &lt;/motion.div&gt;
    &lt;/AnimatePresence&gt;
&lt;/router-view&gt;
</code></pre>
<h3>the subtle details</h3>
<ul>
<li><strong>no animation on first load</strong>: <code>isInitialLoad ? false : pageEnter</code> prevents the home page from sliding in when you first visit. the boot sequence handles the entrance.</li>
<li><strong>wait mode</strong>: <code>AnimatePresence mode=&quot;wait&quot;</code> means the old page fades out before the new one fades in. no overlap, no jank.</li>
<li><strong>spring physics</strong>: transitions use spring-based easing instead of linear or cubic-bezier. springs feel more natural because they can overshoot slightly, like real objects.</li>
</ul>
<h2>the command palette</h2>
<p><code>cmd+k</code> opens a spotlight-style command palette. it searches pages, toggles theme, and finds blog posts.</p>
<pre><code class="language-javascript">const filteredActions = computed(() =&gt; {
    const q = query.value.toLowerCase().trim();
    const all = [
        ...pages,
        themeAction.value,
        ...blogPosts.value,
    ];

    if (!q) {
        return [
            ...pages,
            themeAction.value,
            ...blogPosts.value.slice(0, 5),
        ];
    }

    return all.filter(action =&gt;
        action.label.toLowerCase().includes(q) ||
        action.description.toLowerCase().includes(q)
    );
});
</code></pre>
<h3>how it works</h3>
<ol>
<li><strong>global keydown listener</strong> captures <code>cmd+k</code> / <code>ctrl+k</code></li>
<li><strong>teleported to body</strong> so it renders above everything</li>
<li><strong>backdrop blur</strong> creates depth without a harsh overlay</li>
<li><strong>keyboard navigation</strong>: arrow keys move selection, enter executes, escape closes</li>
<li><strong>auto-focus</strong>: input gets focus immediately on open</li>
</ol>
<h3>the animation</h3>
<p>the modal slides in with a custom cubic-bezier that overshoots slightly:</p>
<pre><code class="language-css">.palette-modal {
    animation: modal-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}

@keyframes modal-in {
    from {
        opacity: 0;
        transform: translateY(-8px) scale(0.96);
    }
    to {
        opacity: 1;
        transform: translateY(0) scale(1);
    }
}
</code></pre>
<p>the <code>0.2s</code> duration is intentional. any slower and it feels sluggish for something that should feel instant. any faster and you do not see the animation at all.</p>
<h2>markdown rendering</h2>
<p>blog posts are plain markdown files with yaml-like frontmatter. the rendering pipeline has three stages.</p>
<h3>stage 1: frontmatter parsing</h3>
<pre><code class="language-javascript">export const parseFrontmatter = (content) =&gt; {
    const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
    if (!match) return { frontmatter: {}, content };

    const [, frontmatterText, bodyContent] = match;
    const frontmatter = {};

    frontmatterText.split(&quot;\n&quot;).forEach((line) =&gt; {
        const [key, ...rest] = line.split(&quot;:&quot;);
        if (!key || rest.length === 0) return;
        const value = rest.join(&quot;:&quot;).trim();
        if (value.startsWith(&quot;[&quot;) &amp;&amp; value.endsWith(&quot;]&quot;)) {
            frontmatter[key.trim()] = value
                .slice(1, -1)
                .split(&quot;,&quot;)
                .map((item) =&gt; item.trim());
        } else {
            frontmatter[key.trim()] = value;
        }
    });

    return { frontmatter, content: bodyContent };
};
</code></pre>
<p>no yaml parser dependency. the format is simple enough that a regex and some string splitting handles everything. arrays use bracket notation: <code>tags: [vue, webdev, tutorial]</code>.</p>
<h3>stage 2: markdown to html</h3>
<p>marked.js with a custom renderer that applies catppuccin classes to every element:</p>
<pre><code class="language-javascript">heading({ tokens, depth }) {
    const text = this.parser.parseInline(tokens);
    const id = slugify(text);
    const classes = {
        1: &quot;text-2xl font-bold text-catppuccin-text mt-8 mb-4&quot;,
        2: &quot;text-xl font-semibold text-catppuccin-blue mt-8 mb-4&quot;,
        3: &quot;text-lg font-semibold text-catppuccin-mauve mt-6 mb-3&quot;,
    };
    return `&lt;h${depth} id=&quot;${id}&quot; class=&quot;${classes[depth]}&quot;&gt;${text}&lt;/h${depth}&gt;`;
}
</code></pre>
<p>every heading gets a slugified id for anchor links. h2 headings are blue, h3 headings are mauve. this color distinction helps you scan the page visually.</p>
<h3>stage 3: syntax highlighting</h3>
<p>prismjs runs after the markdown is rendered and inserted into the dom:</p>
<pre><code class="language-javascript">const highlightCodeBlocks = async () =&gt; {
    await ensurePostEnhancers();
    await nextTick();
    if (PrismInstance &amp;&amp; articleContentRef.value) {
        PrismInstance.highlightAllUnder(articleContentRef.value);
    }
};
</code></pre>
<p>prism is lazy-loaded. it only imports when you open a blog post, not on the home page. the language components (javascript, python, bash, css) are loaded in parallel with the main prism module.</p>
<h3>the copy button</h3>
<p>every code block gets a copy button that appears on hover:</p>
<pre><code class="language-javascript">code({ text, lang }) {
    const id = `code-block-${codeBlockCounter++}`;
    return `&lt;div class=&quot;relative group&quot;&gt;
        &lt;button data-copy-target=&quot;${id}&quot;
            class=&quot;absolute top-2 right-2 opacity-0
                   group-hover:opacity-100 ...&quot;&gt;
            copy
        &lt;/button&gt;
        &lt;pre class=&quot;...&quot;&gt;
            &lt;code id=&quot;${id}&quot; class=&quot;language-${lang}&quot;&gt;
                ${escaped}
            &lt;/code&gt;
        &lt;/pre&gt;
    &lt;/div&gt;`;
}
</code></pre>
<p>the button uses event delegation on the article container rather than attaching listeners to each button. one listener handles all code blocks.</p>
<h2>the table of contents</h2>
<p>the sticky toc sidebar is one of my favorite features. it uses intersection observer to highlight the current section as you scroll.</p>
<pre><code class="language-javascript">const extractHeadings = () =&gt; {
    const els = articleContentRef.value.querySelectorAll(&quot;h2, h3&quot;);
    headings.value = Array.from(els).map(el =&gt; ({
        id: el.id,
        text: el.textContent,
        level: parseInt(el.tagName[1]),
    }));
};
</code></pre>
<h3>intersection observer setup</h3>
<pre><code class="language-javascript">observer = new IntersectionObserver(
    (entries) =&gt; {
        entries.forEach((entry) =&gt; {
            if (entry.isIntersecting) {
                activeId.value = entry.target.id;
            }
        });

        const allAbove = entries
            .filter(e =&gt; e.boundingClientRect.top &lt; 0)
            .sort((a, b) =&gt;
                b.boundingClientRect.top - a.boundingClientRect.top
            );

        if (allAbove.length &amp;&amp; !entries.some(e =&gt; e.isIntersecting)) {
            activeId.value = allAbove[0].target.id;
        }
    },
    { rootMargin: &quot;-20% 0px -70% 0px&quot;, threshold: 0 }
);
</code></pre>
<h3>why these margins</h3>
<p><code>-20% 0px -70% 0px</code> means the observer considers a heading &quot;visible&quot; when it is in the top 20-30% of the viewport. this feels right because you are reading downward — by the time a heading reaches the top third of the screen, that is the section you are in.</p>
<p>the fallback logic handles an edge case: when you scroll fast and no heading is currently intersecting, it picks the last heading that scrolled above the viewport.</p>
<h3>the visual indicator</h3>
<pre><code class="language-html">&lt;button
    :class=&quot;[
        activeId === heading.id
            ? &#39;text-catppuccin-mauve border-l-2 border-catppuccin-mauve pl-2&#39;
            : &#39;text-catppuccin-subtle border-l-2 border-transparent pl-2&#39;,
    ]&quot;
&gt;
</code></pre>
<p>active sections get a mauve left border and text color. h3 headings are indented. the whole thing is hidden on mobile because a sidebar toc does not make sense on small screens.</p>
<h2>the reading progress bar</h2>
<p>a thin mauve bar at the top of blog posts that fills as you scroll:</p>
<pre><code class="language-javascript">const updateReadingProgress = () =&gt; {
    rafId = requestAnimationFrame(() =&gt; {
        const scrollTop = window.scrollY;
        const docHeight =
            document.documentElement.scrollHeight - window.innerHeight;
        readingProgress.value =
            docHeight &gt; 0
                ? Math.min((scrollTop / docHeight) * 100, 100)
                : 0;
    });
};
</code></pre>
<p>wrapped in <code>requestAnimationFrame</code> for performance. scroll events fire constantly, but raf ensures we only calculate once per frame.</p>
<pre><code class="language-html">&lt;div class=&quot;fixed top-0 left-0 w-full h-[3px] z-[9998]&quot;&gt;
    &lt;div
        class=&quot;h-full bg-catppuccin-mauve&quot;
        :style=&quot;{
            width: readingProgress + &#39;%&#39;,
            transition: &#39;width 0.1s ease-out&#39;
        }&quot;
    &gt;&lt;/div&gt;
&lt;/div&gt;
</code></pre>
<p>3 pixels tall. enough to notice, not enough to distract. the 0.1s transition smooths out the movement so it does not jitter on fast scrolling.</p>
<h2>blog post loading</h2>
<p>posts are loaded at build time using vite&#39;s glob import:</p>
<pre><code class="language-javascript">const postModules = import.meta.glob(&quot;/posts/*.md&quot;, {
    eager: true,
    query: &quot;?raw&quot;,
    import: &quot;default&quot;,
});
</code></pre>
<h3>why eager loading</h3>
<p><code>eager: true</code> means all posts are bundled into the javascript. for a personal blog with 15-20 posts, this is fine — maybe 50-100kb of markdown total. the alternative (lazy loading each post) adds network requests and loading states for negligible savings.</p>
<h3>slug generation</h3>
<pre><code class="language-javascript">const slug = path.replace(&quot;/posts/&quot;, &quot;&quot;).replace(&quot;.md&quot;, &quot;&quot;);
</code></pre>
<p>the filename <em>is</em> the slug. <code>building-this-site.md</code> becomes <code>building-this-site</code>. no database ids, no generated hashes. rename the file, change the url.</p>
<h3>sorting and caching</h3>
<p>posts are sorted by date (newest first) once at load time and cached. no re-sorting on every render.</p>
<h2>the catppuccin theme system</h2>
<p>the entire site uses catppuccin mocha as its color system. tailwind is configured with css custom properties:</p>
<pre><code class="language-javascript">// tailwind.config.js
colors: {
    catppuccin: {
        text: &quot;rgb(var(--color-text))&quot;,
        base: &quot;rgb(var(--color-base))&quot;,
        crust: &quot;rgb(var(--color-crust))&quot;,
        mauve: &quot;rgb(var(--color-mauve))&quot;,
        blue: &quot;rgb(var(--color-blue))&quot;,
        // ... all 26 catppuccin colors
    }
}
</code></pre>
<h3>dark and light modes</h3>
<p>css variables switch between mocha (dark) and latte (light):</p>
<pre><code class="language-css">:root {
    --color-text: 205 214 244;    /* mocha text */
    --color-base: 30 30 46;       /* mocha base */
    --color-mauve: 203 166 247;   /* mocha mauve */
}

.light {
    --color-text: 76 79 105;      /* latte text */
    --color-base: 239 241 245;    /* latte base */
    --color-mauve: 136 57 239;    /* latte mauve */
}
</code></pre>
<p>by using rgb values without the <code>rgb()</code> wrapper, tailwind can add opacity modifiers: <code>bg-catppuccin-surface/50</code> becomes <code>rgba(var(--color-surface), 0.5)</code>. this one trick makes the entire theme system work with tailwind&#39;s opacity utilities.</p>
<h3>the 0.9 scale trick</h3>
<pre><code class="language-css">@media (min-width: 1024px) {
    #app {
        transform: scale(0.9);
        transform-origin: center center;
        margin-top: -5vh;
    }
}
</code></pre>
<p>on desktop, the entire app is scaled to 90%. this makes the monospace font and terminal aesthetic feel more &quot;contained&quot; — like looking at a terminal window rather than a full-screen application. the negative margin compensates for the visual gap the scaling creates.</p>
<h2>performance decisions</h2>
<h3>what i did not add</h3>
<ul>
<li><strong>no ssr</strong>: this is a static personal site. the spa loads fast enough. adding nuxt or ssr would triple the complexity for marginal improvement.</li>
<li><strong>no image optimization pipeline</strong>: i have maybe five images total. a build-time optimizer would be over-engineering.</li>
<li><strong>no state management</strong>: vue&#39;s <code>ref()</code> and <code>computed()</code> handle everything. pinia would be unused overhead.</li>
<li><strong>no analytics</strong>: i do not need to know how many people read my posts. the site exists because i enjoy building it.</li>
</ul>
<h3>what i optimized</h3>
<ul>
<li><strong>lazy prismjs</strong>: syntax highlighting only loads when viewing a blog post.</li>
<li><strong>eager post loading</strong>: all markdown bundled upfront to avoid loading spinners.</li>
<li><strong>raf-throttled scroll handlers</strong>: reading progress bar and toc use <code>requestAnimationFrame</code> instead of raw scroll events.</li>
<li><strong>css animations over js</strong>: boot sequence and palette animations use css keyframes. js controls when they start, css handles the motion.</li>
<li><strong>minimal dependencies</strong>: the total dependency count is under 10. every package earns its place.</li>
</ul>
<h2>what i would do differently</h2>
<h3>vue over astro</h3>
<p>if i started today, i might consider astro with vue islands. the blog content is static — it does not need to be an spa. but honestly, the spa approach works fine for this scale and i enjoy having full control.</p>
<h3>tailwind classes in markdown</h3>
<p>the custom marked renderer injects tailwind classes directly into html strings. it works, but it means the markdown output is tightly coupled to tailwind. if i ever migrate away from tailwind, every renderer function needs rewriting.</p>
<p>a better approach would be using semantic css classes and styling them separately. but &quot;better&quot; and &quot;worth refactoring&quot; are different things.</p>
<h3>testing</h3>
<p>there are no tests. for a personal blog, i am fine with this. i test by looking at the site. but if this were a team project, the markdown renderer and blog service would need unit tests.</p>
<h2>the terminal aesthetic philosophy</h2>
<p>the whole site follows one principle: <em>look like a terminal, behave like a website.</em></p>
<ul>
<li><code>~$ cd ~/blog</code> above the blog list — decorative, not functional</li>
<li><code>~$ cat post.md</code> above post content — sets the mood</li>
<li><code>~$ toc</code> above the table of contents — consistent theming</li>
<li>monospace font everywhere — commitment to the bit</li>
<li>left border on content — mimics a terminal cursor column</li>
</ul>
<p>but the site still has proper links, responsive layout, smooth animations, and accessibility features. the terminal aesthetic is a skin, not a limitation.</p>
<p>the most important lesson from building this site: constraints breed creativity. &quot;make it look like a terminal&quot; sounds limiting, but it actually made every design decision easier. when you have a strong visual language, you spend less time deciding and more time building.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>vue</category>
      <category>vite</category>
      <category>catppuccin</category>
      <category>webdev</category>
      <category>design</category>
    </item>
    <item>
      <title>bypassing dpi with zapret on linux</title>
      <link>https://moli.codes/blog/zapret-installation</link>
      <guid isPermaLink="true">https://moli.codes/blog/zapret-installation</guid>
      <pubDate>Sat, 10 Jan 2026 00:00:00 GMT</pubDate>
      <description>step by step zapret installation to bypass isp deep packet inspection.</description>
      <content:encoded><![CDATA[<h1>bypassing dpi with zapret on linux</h1>
<p>isps use dpi (deep packet inspection) to analyze traffic and block certain sites. zapret bypasses these restrictions.</p>
<h2>what we&#39;re doing</h2>
<ol>
<li>dns over tls setup</li>
<li>zapret installation</li>
<li>isp-specific parameter detection</li>
<li>profit</li>
</ol>
<h2>required packages</h2>
<pre><code class="language-bash"># debian/ubuntu
sudo apt install -y curl dnsutils nftables unzip

# fedora/rhel
sudo dnf install -y bind-utils curl nftables unzip

# arch
sudo pacman -S --noconfirm bind-tools curl nftables unzip
</code></pre>
<h2>dns setup</h2>
<p>zapret only bypasses dpi. we need to set up dns ourselves. using yandex dns here, alternatives:</p>
<ul>
<li><a href="https://keift.gitbook.io/blog/linux/use-dns-over-tls#alternative-cloudflare-dns-recommended">cloudflare dns</a> (recommended)</li>
<li><a href="https://keift.gitbook.io/blog/linux/use-dns-over-tls#alternative-mullvad-dns">mullvad dns</a></li>
<li><a href="https://keift.gitbook.io/blog/linux/use-dns-over-tls#alternative-google-dns">google dns</a></li>
</ul>
<pre><code class="language-bash"># enable systemd-resolved
sudo systemctl enable --now systemd-resolved

# write dns config
sudo tee /etc/systemd/resolved.conf &amp;&gt;/dev/null &lt;&lt; EOF
[Resolve]
DNS=77.88.8.8#common.dot.dns.yandex.net
DNS=2a02:6b8::feed:0ff#common.dot.dns.yandex.net
DNSOverTLS=yes
EOF

# symlink resolv.conf
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf

# restart
sudo systemctl restart systemd-resolved
</code></pre>
<h2>download zapret</h2>
<pre><code class="language-bash"># clean start
sudo rm -rf /tmp/zapret-v72.7*

# download and extract
sudo wget -P /tmp https://github.com/bol-van/zapret/releases/download/v72.7/zapret-v72.7.zip
sudo unzip -d /tmp /tmp/zapret-v72.7.zip
sudo rm /tmp/zapret-v72.7.zip
</code></pre>
<h2>pre-installation</h2>
<pre><code class="language-bash"># remove old installation
sudo /opt/zapret/uninstall_easy.sh 2&gt;/dev/null
sudo rm -rf /opt/zapret

# install requirements
sudo /tmp/zapret-v72.7/install_prereq.sh
sudo /tmp/zapret-v72.7/install_bin.sh
</code></pre>
<p>firewall question: <strong>leave blank</strong> (nftables will be selected)</p>
<h2>blockcheck - isp analysis</h2>
<p>detect the dpi method your isp uses:</p>
<pre><code class="language-bash">sudo /tmp/zapret-v72.7/blockcheck.sh
</code></pre>
<p>answers:</p>
<table>
<thead>
<tr>
<th>question</th>
<th>answer</th>
</tr>
</thead>
<tbody><tr>
<td>domain</td>
<td>a blocked site (e.g. discord.com)</td>
</tr>
<tr>
<td>ip version</td>
<td>leave blank</td>
</tr>
<tr>
<td>check http/https</td>
<td>leave blank</td>
</tr>
<tr>
<td>repeat count</td>
<td>leave blank</td>
</tr>
<tr>
<td>scan mode</td>
<td>leave blank (standard)</td>
</tr>
</tbody></table>
<p>test takes a few minutes. you&#39;ll get output like:</p>
<pre><code>curl_test_https_tls12 ipv4 discord.com : nfqws --dpi-desync=fakeddisorder --dpi-desync-ttl=1 --dpi-desync-autottl=-5 --dpi-desync-split-pos=1
</code></pre>
<p>note the part starting with <code>nfqws</code>. these are your isp-specific bypass parameters.</p>
<h2>zapret installation</h2>
<pre><code class="language-bash">sudo /tmp/zapret-v72.7/install_easy.sh
</code></pre>
<table>
<thead>
<tr>
<th>question</th>
<th>answer</th>
</tr>
</thead>
<tbody><tr>
<td>copy for you</td>
<td><strong>Y</strong></td>
</tr>
<tr>
<td>firewall type</td>
<td>leave blank</td>
</tr>
<tr>
<td>ipv6 support</td>
<td>leave blank</td>
</tr>
<tr>
<td>flow offloading</td>
<td>leave blank</td>
</tr>
<tr>
<td>filtering</td>
<td>leave blank</td>
</tr>
<tr>
<td>tpws socks</td>
<td>leave blank</td>
</tr>
<tr>
<td>tpws transparent</td>
<td>leave blank</td>
</tr>
<tr>
<td>enable nfqws</td>
<td><strong>Y</strong></td>
</tr>
<tr>
<td>edit options</td>
<td><strong>Y</strong></td>
</tr>
</tbody></table>
<p>nano will open. find <code>NFQWS_OPT</code> and paste your blockcheck parameters:</p>
<pre><code class="language-ini">NFQWS_OPT=&quot;--dpi-desync=fakeddisorder --dpi-desync-ttl=1 --dpi-desync-autottl=-5 --dpi-desync-split-pos=1&quot;
</code></pre>
<p>save: <code>Ctrl+S</code>, exit: <code>Ctrl+X</code></p>
<p>leave the remaining questions blank.</p>
<h2>cleanup</h2>
<pre><code class="language-bash">sudo rm -rf /tmp/zapret-v72.7
</code></pre>
<p>done. 🎉</p>
<h2>uninstalling</h2>
<pre><code class="language-bash"># remove zapret
sudo /opt/zapret/uninstall_easy.sh
sudo rm -rf /opt/zapret

# reset dns
sudo tee /etc/systemd/resolved.conf &amp;&gt;/dev/null &lt;&lt;&lt; &quot;&quot;
sudo systemctl restart systemd-resolved
</code></pre>
<p>-- moli</p>
]]></content:encoded>
      <category>linux</category>
      <category>privacy</category>
      <category>networking</category>
      <category>censorship</category>
    </item>
    <item>
      <title>2025 reflections and 2026 goals</title>
      <link>https://moli.codes/blog/new-year-2026</link>
      <guid isPermaLink="true">https://moli.codes/blog/new-year-2026</guid>
      <pubDate>Mon, 05 Jan 2026 00:00:00 GMT</pubDate>
      <description>what i learned, what i built, and what i am aiming for this year.</description>
      <content:encoded><![CDATA[<h1>2025 reflections</h1>
<p>another year of code, mistakes, and small victories.</p>
<h2>what went well</h2>
<p>finally shipped the personal website. it sat in drafts for 2 years. this year i stopped polishing and just released.</p>
<p>switched to zed full time. neovim config was a second job. not missing it.</p>
<p>started using chezmoi for dotfiles. should have done this 5 years ago.</p>
<h2>what did not</h2>
<p>too many half-finished projects. the graveyard has 7 repos with 3 commits each.</p>
<p> neglected the blog. 10 posts was the goal, hit 6. better than zero but not what i wanted.</p>
<h2>numbers</h2>
<pre><code>github contributions: 847
repos created: 12
repos deleted: 7
blog posts: 6
</code></pre>
<h1>2026 goals</h1>
<h2>writing</h2>
<p>12 blog posts. one per month. topics: rust, devops, tooling, and fewer opinion pieces.</p>
<h2>code</h2>
<p>ship 4 cli tools. small, focused, documented.</p>
<p>get comfortable with rust ownership. stop fighting the borrow checker.</p>
<h2>learning</h2>
<p>go deeper on systems programming. currently surface level with too many languages.</p>
<h2>health</h2>
<p>less screen time outside of work. the eyes notice.</p>
<h1>the plan</h1>
<p>nothing revolutionary. just show up more consistently.</p>
<p>happy new year.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>reflection</category>
      <category>goals</category>
      <category>personal</category>
    </item>
    <item>
      <title>coding with llms for a year</title>
      <link>https://moli.codes/blog/llm-coding</link>
      <guid isPermaLink="true">https://moli.codes/blog/llm-coding</guid>
      <pubDate>Sun, 28 Dec 2025 00:00:00 GMT</pubDate>
      <description>what got faster, what stayed the same, and what got harder.</description>
      <content:encoded><![CDATA[<h1>coding with llms for a year</h1>
<p>using claude (and others) for code daily. here is the honest assessment.</p>
<h2>what got faster</h2>
<p><strong>boilerplate</strong>: generate sql schemas, api types, test setups. 10 seconds vs 10 minutes.</p>
<p><strong>searching</strong>: &quot;how do i configure vite for multiple entry points&quot; returns the exact config in seconds instead of digging through docs.</p>
<p><strong>refactoring</strong>: &quot;rename this function and update all call sites&quot; across 20 files. done.</p>
<p><strong>documentation</strong>: explain what this regex does, what this error means, why this type error appears.</p>
<h2>what stayed the same</h2>
<p><strong>architecture</strong>: choosing between database approaches, api design patterns, library selection. llms give options but you must judge them.</p>
<p><strong>debugging</strong>: weird runtime errors, race conditions, deployment failures. llms guess wrong as often as they guess right.</p>
<p><strong>creative work</strong>: novel features, unique solutions, edge cases. llms remix existing patterns.</p>
<h2>what got harder</h2>
<p><strong>reading code</strong>: i rely on llms to explain code. without them, i struggle to parse unfamiliar codebases.</p>
<p><strong>writing from scratch</strong>: starting a project from zero feels harder because i default to &quot;ask first&quot; instead of &quot;figure it out&quot;.</p>
<p><strong>attention span</strong>: quick answers mean less deep thinking. sometimes you need to struggle with a problem to understand it.</p>
<h2>the balance</h2>
<p>i use llms for:</p>
<ul>
<li>repetitive tasks</li>
<li>documentation lookup</li>
<li>small refactors</li>
<li>one-off scripts</li>
</ul>
<p>i avoid llms for:</p>
<ul>
<li>security-critical code</li>
<li>learning new concepts</li>
<li>architectural decisions</li>
<li>unfamiliar codebases</li>
</ul>
<h2>the future</h2>
<p>the good developers will use llms as force multipliers. the bad ones will depend on them completely.</p>
<p>the skill is knowing when to ask and when to figure it out yourself.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>ai</category>
      <category>llm</category>
      <category>coding</category>
      <category>productivity</category>
    </item>
    <item>
      <title>my terminal setup 2025</title>
      <link>https://moli.codes/blog/terminal-setup-2025</link>
      <guid isPermaLink="true">https://moli.codes/blog/terminal-setup-2025</guid>
      <pubDate>Thu, 25 Dec 2025 00:00:00 GMT</pubDate>
      <description>alacritty, zsh, tmux, and the small configs that make it work.</description>
      <content:encoded><![CDATA[<h1>my terminal setup 2025</h1>
<p>minimal, fast, keyboard-driven.</p>
<h2>terminal</h2>
<p>alacritty with gpu acceleration. 60fps scrolling on 4k monitors.</p>
<pre><code class="language-toml">[font]
size = 13
offset = { y = 1 }

[window]
opacity = 0.9
decorations = &quot;None&quot;
</code></pre>
<p>no title bar, slight transparency, monospace font.</p>
<h2>shell</h2>
<p>zsh with oh-my-zsh stripped down.</p>
<pre><code>plugins=(git python docker)
</code></pre>
<p>prompt is minimal:</p>
<pre><code>╭─ user@host ~/project ────────────────────────────────────
╰─ $
</code></pre>
<p>no fortune, no cowsay, no ascii art on start.</p>
<h2>multiplexing</h2>
<p>tmux with prefix changed to <code>Ctrl+a</code>.</p>
<pre><code>set -g mouse on
set -g status-style &quot;bg=#1e1e2e&quot;
set -g window-style &quot;bg=#1e1e2e&quot;
set -g pane-border-style &quot;fg=#313244&quot;
</code></pre>
<p>copy mode with mouse works. scrolling works. it feels like a gui.</p>
<h2>the workflow</h2>
<pre><code>Ctrl+a + c     # new window
Ctrl+a + n     # next window
Ctrl+a + |     # horizontal split
Ctrl+a + _     # vertical split
Ctrl+a + arrow # navigate panes
</code></pre>
<p>all keyboard. hands never leave home row.</p>
<h2>tools</h2>
<table>
<thead>
<tr>
<th>tool</th>
<th>purpose</th>
</tr>
</thead>
<tbody><tr>
<td>fzf</td>
<td>fuzzy finding</td>
</tr>
<tr>
<td>ripgrep</td>
<td>searching</td>
</tr>
<tr>
<td>bat</td>
<td>syntax highlighted cat</td>
</tr>
<tr>
<td>eza</td>
<td>ls replacement</td>
</tr>
<tr>
<td>bottom</td>
<td>system monitor</td>
</tr>
</tbody></table>
<p>small binaries that do one thing well.</p>
<h2>config location</h2>
<pre><code>~/.config/
  alacritty/
  tmux/
  zsh/
</code></pre>
<p>all version controlled. chezmoi manages the sync.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>terminal</category>
      <category>setup</category>
      <category>productivity</category>
      <category>ricing</category>
    </item>
    <item>
      <title>rust for js developers</title>
      <link>https://moli.codes/blog/rust-for-js-developers</link>
      <guid isPermaLink="true">https://moli.codes/blog/rust-for-js-developers</guid>
      <pubDate>Mon, 15 Dec 2025 00:00:00 GMT</pubDate>
      <description>the mental shift from garbage collection to ownership.</description>
      <content:encoded><![CDATA[<h1>rust for js developers</h1>
<p>moving from javascript to rust felt like learning to code again. here is what clicked for me.</p>
<h2>no garbage collector</h2>
<p>in js, you forget about memory. in rust, you own it.</p>
<pre><code class="language-rust">let mut vec = Vec::new();
vec.push(&quot;hello&quot;);
</code></pre>
<p>the vector owns its memory. when it goes out of scope, memory is freed. no leaks, no gc pauses.</p>
<h2>ownership is the key</h2>
<p>every value has one owner. when the owner is dropped, the value is dropped.</p>
<pre><code class="language-rust">let s1 = String::from(&quot;hello&quot;);
let s2 = s1; // s1 is moved to s2
// println!(&quot;{}&quot;, s1); // error! s1 is no longer valid
</code></pre>
<p>this is not a reference. this is ownership transfer.</p>
<h2>borrowing</h2>
<pre><code class="language-rust">fn calculate_length(s: &amp;String) -&gt; usize {
    s.len()
} // s goes out of scope but nothing is dropped
</code></pre>
<p>references are borrowing. you cannot modify what you borrow unless you use <code>&amp;mut</code>.</p>
<h2>the compiler is your friend</h2>
<p>rust compiler errors are incredibly helpful:</p>
<pre><code>error[E0382]: use of moved value: `vec`
   --&gt; src/main.rs:10:13
    |
8  |     let vec = vec![1, 2, 3];
    |         --- move occurs because `vec` has type `Vec&lt;i32&gt;`, which does not implement the `Copy` trait
9  |     let vec2 = vec;
    |             --- value moved here
10 |     println!(&quot;{:?}&quot;, vec);
    |                     ^^^ value used after move
</code></pre>
<p>it tells you exactly what happened and how to fix it.</p>
<h2>tooling</h2>
<ul>
<li><code>cargo</code>: package manager, build tool, test runner</li>
<li><code>rustfmt</code>: automatic formatting</li>
<li><code>clippy</code>: linting</li>
<li><code>rust-analyzer</code>: the best language server i have ever used</li>
</ul>
<h2>getting started</h2>
<pre><code class="language-bash">rustup init
cargo new my_project
cd my_project
cargo run
</code></pre>
<p>start small. the book is free at doc.rust-lang.org.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>rust</category>
      <category>javascript</category>
      <category>learning</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>building a heatmap for github contributions</title>
      <link>https://moli.codes/blog/github-contribution-heatmap</link>
      <guid isPermaLink="true">https://moli.codes/blog/github-contribution-heatmap</guid>
      <pubDate>Wed, 10 Dec 2025 00:00:00 GMT</pubDate>
      <description>recreating the github contribution graph from scratch for my personal website.</description>
      <content:encoded><![CDATA[<h1>building a heatmap for github contributions</h1>
<p>github&#39;s contribution graph is iconic. i wanted it on my site without their api rate limits.</p>
<h2>the data</h2>
<p>github exposes contribution counts at <code>https://github.com/users/{username}/contributions</code>.</p>
<p>a quick scrape gives you the raw data in html. parsing it is straightforward:</p>
<pre><code class="language-javascript">const data = Array.from(document.querySelectorAll(&#39;.day&#39;))
  .map(day =&gt; ({
    date: day.dataset.date,
    count: parseInt(day.dataset.count, 10)
  }))
</code></pre>
<h2>the grid</h2>
<p>52 weeks × 7 days = 364 squares. css grid makes this trivial:</p>
<pre><code>display: grid;
grid-template-rows: repeat(7, 1fr);
grid-auto-flow: column;
</code></pre>
<p>this creates the left-to-right week flow with days stacked vertically.</p>
<h2>color scale</h2>
<p>contribution counts map to colors. catppuccin palette works well:</p>
<pre><code>0: crust
1-3: base
4-6: overlay
7-9: mauve
10+: pink
</code></pre>
<h2>interactivity</h2>
<p>hover shows the date and count. click could link to that day&#39;s github profile.</p>
<pre><code>title = `${date}: ${count} contributions`
</code></pre>
<p>simple and effective.</p>
<h2>deployment</h2>
<p>fetch on build time. cache the json. no client-side api calls, no rate limit issues.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>visualization</category>
      <category>github</category>
      <category>vue</category>
      <category>d3</category>
    </item>
    <item>
      <title>the case for smaller containers</title>
      <link>https://moli.codes/blog/smaller-containers</link>
      <guid isPermaLink="true">https://moli.codes/blog/smaller-containers</guid>
      <pubDate>Mon, 01 Dec 2025 00:00:00 GMT</pubDate>
      <description>building images under 50mb instead of gigabytes and why it matters.</description>
      <content:encoded><![CDATA[<h1>the case for smaller containers</h1>
<p>a 1.2gb python image for a 10mb script is ridiculous. here is how i shrank my deployments.</p>
<h2>start small</h2>
<pre><code>FROM python:3.12-slim
# vs
FROM python:3.12
</code></pre>
<p>slim saves 800mb immediately. alpine saves more but brings libc compatibility issues.</p>
<p>for go binaries:</p>
<pre><code>FROM scratch
COPY main /main
</code></pre>
<p>zero runtime dependencies. the image is just your binary.</p>
<h2>multi-stage builds</h2>
<pre><code>FROM golang:1.21 AS builder
COPY . .
RUN CGO_ENABLED=0 go build -ldflags=&quot;-s -w&quot; -o main main.go

FROM alpine:latest
COPY --from=builder /main /main
CMD [&quot;/main&quot;]
</code></pre>
<p>builder stage is discarded. final image contains only what runs.</p>
<h2>tooling</h2>
<ul>
<li><code>dive</code>: inspect layer contents</li>
<li><code>docker-slim</code>: auto-optimize</li>
<li><code>grype</code>: scan for vulnerabilities in thin images</li>
</ul>
<h2>numbers</h2>
<table>
<thead>
<tr>
<th>stack</th>
<th>before</th>
<th>after</th>
</tr>
</thead>
<tbody><tr>
<td>go api</td>
<td>950mb</td>
<td>12mb</td>
</tr>
<tr>
<td>node scraper</td>
<td>1.1gb</td>
<td>145mb</td>
</tr>
<tr>
<td>python etl</td>
<td>2.3gb</td>
<td>89mb</td>
</tr>
</tbody></table>
<p>smaller images deploy faster, scan faster, and have smaller attack surfaces.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>docker</category>
      <category>containers</category>
      <category>optimization</category>
      <category>devops</category>
    </item>
    <item>
      <title>last.fm is still goated</title>
      <link>https://moli.codes/blog/lastfm-is-still-goated</link>
      <guid isPermaLink="true">https://moli.codes/blog/lastfm-is-still-goated</guid>
      <pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate>
      <description>why i still use last.fm in 2025 and how i added it to my site.</description>
      <content:encoded><![CDATA[<h1>last.fm is still goated</h1>
<p>in a world of spotify wrapped once a year, last.fm just keeps scrobbling. every single track. forever.</p>
<h2>why last.fm in 2025?</h2>
<p>spotify gives you stats once a year. last.fm gives you stats every second.</p>
<ul>
<li><strong>real-time tracking</strong> - see what you&#39;re listening to right now</li>
<li><strong>historical data</strong> - years of listening history</li>
<li><strong>open api</strong> - actually lets you build stuff</li>
<li><strong>cross-platform</strong> - works with everything, not just spotify</li>
</ul>
<h2>the api is actually good</h2>
<pre><code class="language-js">const response = await axios.get(&#39;https://ws.audioscrobbler.com/2.0/&#39;, {
    params: {
        method: &#39;user.getrecenttracks&#39;,
        user: &#39;yourusername&#39;,
        api_key: &#39;your_key&#39;,
        format: &#39;json&#39;,
        limit: 10
    }
});
</code></pre>
<p>that&#39;s it. no oauth dance. no token refresh hell. just an api key and you&#39;re good.</p>
<h2>what you get back</h2>
<ul>
<li>track name</li>
<li>artist</li>
<li>album</li>
<li>album art</li>
<li>timestamp</li>
<li>whether it&#39;s currently playing</li>
</ul>
<p>everything you need to show &quot;now playing&quot; on your site.</p>
<h2>my setup</h2>
<p>i added a recent tracks section to my site. updates every 30 seconds. shows what i&#39;m listening to in real-time.</p>
<p>the cool part: consolidating repeat plays. if i listen to the same song 5 times in a row (no judgment), it shows <code>×5</code> instead of listing it five times.</p>
<h2>the scrobbling ecosystem</h2>
<p>last.fm works with basically everything:</p>
<ul>
<li>spotify (native)</li>
<li>apple music (via apps)</li>
<li>youtube music</li>
<li>local files</li>
<li>literally anything with a scrobbler</li>
</ul>
<h2>vs spotify wrapped</h2>
<table>
<thead>
<tr>
<th>last.fm</th>
<th>spotify wrapped</th>
</tr>
</thead>
<tbody><tr>
<td>real-time</td>
<td>once a year</td>
</tr>
<tr>
<td>all platforms</td>
<td>spotify only</td>
</tr>
<tr>
<td>open api</td>
<td>lol no</td>
</tr>
<tr>
<td>years of data</td>
<td>current year</td>
</tr>
<tr>
<td>free</td>
<td>&quot;free&quot;</td>
</tr>
</tbody></table>
<h2>the vibe</h2>
<p>there&#39;s something nice about having a complete record of everything you&#39;ve ever listened to. not just for stats, but for the memories.</p>
<p>looking at my scrobbles from 2020 hits different.</p>
<h2>add it to your site</h2>
<p>if you&#39;re building a personal site, add your recent tracks. it&#39;s:</p>
<ul>
<li>easy to implement</li>
<li>shows personality</li>
<li>actually useful</li>
<li>free api</li>
</ul>
<h2>the truth</h2>
<p>last.fm has been doing &quot;music tracking&quot; since 2002. spotify wrapped is just last.fm once a year with better marketing.</p>
<p>real ones know.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>music</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>managing dotfiles with chezmoi</title>
      <link>https://moli.codes/blog/dotfiles-with-chezmoi</link>
      <guid isPermaLink="true">https://moli.codes/blog/dotfiles-with-chezmoi</guid>
      <pubDate>Thu, 20 Nov 2025 00:00:00 GMT</pubDate>
      <description>finally a dotfiles manager that does not feel like a second job.</description>
      <content:encoded><![CDATA[<h1>managing dotfiles with chezmoi</h1>
<p>i tried symlinking manually. i tried git bare repo tricks. i tried ansible. chezmoi is the only one that stuck.</p>
<h2>why it works</h2>
<p>chezmoi is idempotent. running it twice does nothing bad. it respects <code>$HOME</code> and creates symlinks or copies based on your preference.</p>
<pre><code class="language-bash">chezmoi add ~/.zshrc
chezmoi apply
</code></pre>
<p>that&#39;s it. no <code>Makefile</code> gymnastics, no <code>stow</code> confusion.</p>
<h2>the templating</h2>
<p>need different configs for work and home? chezmoi templates with sprig functions.</p>
<pre><code>{{- if eq .hostname &quot;work-laptop&quot; }}
# work settings
{{- end }}
</code></pre>
<p>handles per-machine config without branching repositories.</p>
<h2>the diff</h2>
<p><code>chezmoi diff</code> shows exactly what will change before you apply. safety net for people who fear ruining their shell config.</p>
<h2>getting started</h2>
<pre><code class="language-bash">brew install chezmoi
chezmoi init --apply https://github.com/yourusername/dotfiles
</code></pre>
<p>migrate existing configs:</p>
<pre><code class="language-bash">chezmoi add ~/.config/alacritty/alacritty.toml
chezmoi status
chezmoi apply
</code></pre>
<p>-- moli</p>
]]></content:encoded>
      <category>dotfiles</category>
      <category>chezmoi</category>
      <category>automation</category>
      <category>dotfiles</category>
    </item>
    <item>
      <title>why i switched from neovim to zed</title>
      <link>https://moli.codes/blog/switching-from-neovim-to-zed</link>
      <guid isPermaLink="true">https://moli.codes/blog/switching-from-neovim-to-zed</guid>
      <pubDate>Sat, 15 Nov 2025 00:00:00 GMT</pubDate>
      <description>giving up 5 years of modal editing for a mouse-friendly rust-based editor.</description>
      <content:encoded><![CDATA[<h1>why i switched from neovim to zed</h1>
<p>after 5 years of muscle memory, i deleted my <code>.config/nvim</code> folder. here is what led me there.</p>
<h2>the problem with plugins</h2>
<p>neovim plugins are amazing until one breaks after an update. lsp configurations drift, treesitter parsers desync, and suddenly you spend a saturday debugging instead of coding.</p>
<p>zed has zero config out of the box. language servers just work. no <code>PackerSync</code>, no <code>MasonInstallAll</code>, no <code>checkhealth</code>.</p>
<h2>the mouse thing</h2>
<p>i thought i hated the mouse. turns out i just never had a good one.</p>
<p>zed&#39;s multi cursor with mouse support is faster than any vim motions for repetitive edits. select, alt+click more, type. done.</p>
<h2>the tradeoffs</h2>
<p>missing:</p>
<ul>
<li><code>:terminal</code></li>
<li><code>:G</code> for git (though zed&#39;s built-in git is surprisingly good)</li>
<li>tmux integration</li>
</ul>
<p>gaining:</p>
<ul>
<li>no startup time</li>
<li>collaboration features (remote sessions are wild)</li>
<li>vim mode that actually feels complete</li>
</ul>
<h2>verdict</h2>
<p>zed is not for everyone. if you live in terminal, stick with neovim. if you want a fast editor that feels like 2025, try it for a week.</p>
<p>i am not looking back.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>zed</category>
      <category>editor</category>
      <category>neovim</category>
      <category>rust</category>
    </item>
    <item>
      <title>how i sync obsidian notes into vue</title>
      <link>https://moli.codes/blog/obsidian-to-vue-notes-pipeline</link>
      <guid isPermaLink="true">https://moli.codes/blog/obsidian-to-vue-notes-pipeline</guid>
      <pubDate>Tue, 28 Oct 2025 00:00:00 GMT</pubDate>
      <description>wiring a tiny pipeline that turns my messy vault into clean blog-ready markdown.</description>
      <content:encoded><![CDATA[<h1>how i sync obsidian notes into vue</h1>
<p>i finally automated the thing i always complained about: moving notes from obsidian into this site without copy paste misery.</p>
<h2>the rough edges</h2>
<ul>
<li>obsidian makes it easy to write, hard to export.</li>
<li>backlinks, custom callouts, and dataview blocks break vite builds.</li>
<li>manual cleanup means i forget to publish at all.</li>
</ul>
<h2>the new flow</h2>
<ol>
<li>tag a note with <code>#publish</code>.</li>
<li>run <code>bun run sync:notes</code>.</li>
<li>script converts frontmatter, strips obsidian syntax, and drops the file into <code>posts/</code>.</li>
<li>vite hot reloads and i get instant preview.</li>
</ol>
<h2>key pieces</h2>
<pre><code class="language-text">scripts/
  sync-notes.ts      # pulls marked notes over fs
  render-snippets.ts # converts callouts -&gt; blockquotes
</code></pre>
<ul>
<li>parses the first heading for the title.</li>
<li>default excerpt comes from the first paragraph.</li>
<li>preserves code blocks and inline formatting.</li>
</ul>
<h2>why this setup</h2>
<ul>
<li>fast fs operations.</li>
<li>typescript without extra config.</li>
<li>easy to ship a single command.</li>
</ul>
<h2>what needs polish</h2>
<ul>
<li>images still require hand uploading.</li>
<li>dataview tables get downgraded to plain lists.</li>
<li>i want a diff preview before overwriting an existing post.</li>
</ul>
<p>for now, it feels like magic compared to the old workflow. more excuses to write.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>tooling</category>
      <category>automation</category>
      <category>obsidian</category>
      <category>vue</category>
    </item>
    <item>
      <title>shipping tiny cli tools on a weekend</title>
      <link>https://moli.codes/blog/shipping-small-cli-tools</link>
      <guid isPermaLink="true">https://moli.codes/blog/shipping-small-cli-tools</guid>
      <pubDate>Sun, 26 Oct 2025 00:00:00 GMT</pubDate>
      <description>notes on building opinionated little binaries fast and keeping scope under control.</description>
      <content:encoded><![CDATA[<h1>shipping tiny cli tools on a weekend</h1>
<p>there is something addictive about finishing a small cli tool before monday hits. no roadmap, just a scratch to itch.</p>
<h2>the idea bank</h2>
<ul>
<li>convert spotify playlists to mp3 tags.</li>
<li>wrap openapi specs into copy pasteable markdown.</li>
<li>run <code>npm audit</code> across multiple repos with one command.</li>
</ul>
<p>i keep them in a <code>todo.md</code> file, label the ones that can be done in under 6 hours, and pick one on friday night.</p>
<h2>constraints i set</h2>
<ol>
<li>only one external dependency allowed.</li>
<li>must build on macos and linux without extra steps.</li>
<li>readme and gif demo required before calling it done.</li>
</ol>
<p>with rules in place, i avoid feature creep and focus on the happy path.</p>
<h2>tooling</h2>
<ul>
<li>language: go for binaries, node for scripts.</li>
<li>tests: snapshot helpers with <code>@vitest/ui</code> for quick feedback.</li>
<li>packaging: <code>goreleaser</code> for go, <code>bun run build</code> for js.</li>
</ul>
<h2>releasing</h2>
<ul>
<li>tag <code>v0.x.0</code> even if it is just a prototype.</li>
<li>publish binaries on github releases.</li>
<li>write a short blog post describing the problem solved.</li>
</ul>
<h2>lessons</h2>
<ul>
<li>single purpose tools invite fewer bug reports.</li>
<li>shipping early surfaces real workflows that docs ignore.</li>
<li>saying &quot;no&quot; to extra flags keeps the interface friendly.</li>
</ul>
<p>if i can keep this streak going, i will end the year with a neat toolbox instead of an idea backlog.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>cli</category>
      <category>shipping</category>
      <category>weekend-project</category>
    </item>
    <item>
      <title>designing a terminal aesthetic without going full retro</title>
      <link>https://moli.codes/blog/designing-terminal-aesthetic-ui</link>
      <guid isPermaLink="true">https://moli.codes/blog/designing-terminal-aesthetic-ui</guid>
      <pubDate>Mon, 20 Oct 2025 00:00:00 GMT</pubDate>
      <description>balancing nostalgia and usability while building the new theme for this site.</description>
      <content:encoded><![CDATA[<h1>designing a terminal aesthetic without going full retro</h1>
<p>my site looks like a shell prompt, but that does not mean i want to live in vt100 land. here is how i kept the vibe without the eye strain.</p>
<h2>color choices</h2>
<ul>
<li>base palette from catppuccin mocha.</li>
<li>bumped contrast on text by 6 percent for accessibility.</li>
<li>reserved neon colors for accents only.</li>
</ul>
<h2>typography</h2>
<ul>
<li>jetbrains mono for everything, but with tighter letter spacing above 26px.</li>
<li>headlines use uppercase sparingly to avoid shouting.</li>
<li>paragraphs stay at 14px with 1.6 line height to mimic terminal proportions.</li>
</ul>
<h2>micro interactions</h2>
<ul>
<li>focus rings get a subtle mauve outline instead of pure white.</li>
<li>list animations use <code>transition-delay</code> to feel like commands printing.</li>
<li>cards tilt between 2 and 4 degrees on hover, nodding to crt jitter.</li>
</ul>
<h2>pitfalls avoided</h2>
<ul>
<li>no fake scanlines. they age poorly and hurt readability.</li>
<li>refused to add artificial typing animations on every section.</li>
<li>kept layout responsive; terminal does not mean fixed width.</li>
</ul>
<h2>tools</h2>
<ul>
<li>tailwind for spacing and responsive helpers.</li>
<li><code>@headlessui/vue</code> for accessible menus (even if they look old school).</li>
<li>figma variables to experiment with contrast quickly.</li>
</ul>
<p>keeping the aesthetic light enough means i can enjoy the nostalgia while staying productive. feels like the right balance for now.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>design</category>
      <category>ui</category>
      <category>tailwind</category>
      <category>catppuccin</category>
    </item>
    <item>
      <title>ai browsers are just chromium</title>
      <link>https://moli.codes/blog/ai-browsers-are-just-chromium</link>
      <guid isPermaLink="true">https://moli.codes/blog/ai-browsers-are-just-chromium</guid>
      <pubDate>Sun, 19 Oct 2025 00:00:00 GMT</pubDate>
      <description>stop shipping chromium with a chatbot sidebar and calling it revolutionary. please.</description>
      <content:encoded><![CDATA[<h1>ai browsers are just chromium</h1>
<p>another day, another &quot;revolutionary ai browser&quot; launches. it&#39;s just chromium with a sidebar. again.</p>
<h2>the pattern</h2>
<ol>
<li>take chromium</li>
<li>add ai chatbot in sidebar</li>
<li>maybe throw in some &quot;smart&quot; features</li>
<li>call it the future of browsing</li>
<li>raise $20m</li>
</ol>
<p>looking at you, comet. and atlas. and literally every other &quot;ai-first&quot; browser.</p>
<h2>what they promise</h2>
<ul>
<li>&quot;revolutionary browsing experience&quot;</li>
<li>&quot;ai-powered productivity&quot;</li>
<li>&quot;the browser reimagined&quot;</li>
<li>&quot;intelligent web companion&quot;</li>
</ul>
<h2>what you get</h2>
<ul>
<li>chromium (again)</li>
<li>chatgpt/claude in a sidebar</li>
<li>maybe some summarization features</li>
<li>500mb of electron bloat</li>
<li>another app eating your ram</li>
</ul>
<h2>the real innovation would be</h2>
<p>actually rethinking browsing. not just bolting ai onto the side.</p>
<p><strong>ideas that would actually be interesting:</strong></p>
<ul>
<li>ai that understands page structure beyond just text</li>
<li>intelligent tab management that actually works</li>
<li>privacy-first ai that runs locally</li>
<li>new ways to navigate the web beyond urls</li>
</ul>
<p>but no. we get chromium + sidebar. revolutionary.</p>
<h2>why this matters</h2>
<p>every new &quot;browser&quot; is just chromium. the web is becoming a monoculture. firefox is hanging on by a thread. safari exists only because apple.</p>
<p>and now ai browsers are making it worse by pretending a wrapper is innovation.</p>
<h2>the sidebar problem</h2>
<p>you know what i can do? open chatgpt in a tab. or use raycast. or literally any other tool. </p>
<p>putting it in a sidebar doesn&#39;t make it special. it makes it annoying.</p>
<h2>what i actually want</h2>
<p>give me a browser that:</p>
<ul>
<li>respects my ram</li>
<li>doesn&#39;t track everything</li>
<li>has actual innovation in navigation</li>
<li>integrates ai thoughtfully, not just as a bolt-on</li>
<li>isn&#39;t just chromium in a trench coat</li>
</ul>
<h2>the truth</h2>
<p>these aren&#39;t ai browsers. they&#39;re chromium distributions with a chatbot. </p>
<p>stop calling it innovation. it&#39;s not. it&#39;s lazy.</p>
<p>real innovation would be building something new. but that&#39;s hard. wrapping chromium is easy. and vcs love easy.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>browsers</category>
      <category>ai</category>
      <category>tech-criticism</category>
    </item>
    <item>
      <title>welcome to my blog</title>
      <link>https://moli.codes/blog/welcome-to-my-blog</link>
      <guid isPermaLink="true">https://moli.codes/blog/welcome-to-my-blog</guid>
      <pubDate>Fri, 17 Oct 2025 00:00:00 GMT</pubDate>
      <description>first post on this new blog. thoughts on why i&apos;m writing and what to expect.</description>
      <content:encoded><![CDATA[<h1>welcome to my blog</h1>
<p>decided to start writing down my thoughts and experiences. mostly tech stuff, but also random things i find interesting.</p>
<h2>why blog?</h2>
<ul>
<li>document my learning journey</li>
<li>share solutions to problems i&#39;ve faced</li>
<li>practice writing and articulating ideas</li>
<li>connect with other devs</li>
</ul>
<h2>what to expect</h2>
<p>posts about web dev, projects i&#39;m working on, tools i discover, and occasional random thoughts. no fixed schedule - i&#39;ll write when i have something worth sharing.</p>
<p>thanks for reading.</p>
<p>-- moli</p>
]]></content:encoded>
      <category>meta</category>
      <category>personal</category>
    </item>
  </channel>
</rss>
