<?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>Matt Novak</title>
    <link>https://mattnovak.email/</link>
    <atom:link href="https://mattnovak.email/feed.xml" rel="self" type="application/rss+xml" />
    <description>An activity log on email, warmup, and deliverability, plus the occasional deep technical build. By Matt Novak.</description>
    <language>en-us</language>
    <lastBuildDate>Fri, 28 Aug 2026 00:29:55 +0000</lastBuildDate>
    
    <item>
      <title>Email Deliverability in 2026: The Complete Guide to Inboxing and Sender Reputation</title>
      <link>https://mattnovak.email/email-deliverability-guide/</link>
      <guid isPermaLink="true">https://mattnovak.email/email-deliverability-guide/</guid>
      <pubDate>Fri, 28 Aug 2026 00:27:57 +0000</pubDate>
      <dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Matt Novak</dc:creator>
      <category>deliverability</category><category>gmail</category><category>sender-reputation</category><category>spamhaus</category><category>inboxing</category><category>email</category>
      <description>Google is retiring its reputation dashboards — but your sender reputation isn&#39;t. A practical 2026 guide to inboxing, Gmail, Spamhaus, and diagnosing spam yourself.</description>
      <content:encoded><![CDATA[<p><strong>Your reputation score is disappearing. Your reputation is
not.</strong></p>
<p><em>The practical 2026 guide to email deliverability, inboxing,
sender reputation, Gmail, Spamhaus, domains, IPs, links, data, and
everything else that can send you to spam.</em></p>
<p>For years, one of the first places email operators went when Gmail
delivery started looking strange was Google Postmaster Tools.</p>
<p>You checked Domain Reputation.</p>
<p>You checked IP Reputation.</p>
<p>If Google said "High," you felt pretty good.</p>
<p>If it said "Medium," you started watching things.</p>
<p>If it said "Low" or "Bad," you knew you had work to do.</p>
<p>That era is ending.</p>
<p>As of August 2026, Google has postponed the complete retirement of
the legacy Postmaster Tools interface, but the direction is clear.
Google's Postmaster Tools v2 does not carry forward the traditional
Domain Reputation and IP Reputation dashboards. Google says those
dashboards will be retired and replaced by more actionable information.
Google also openly acknowledges something experienced email operators
have known for years. A reputation label can be misleading because
reputation is only one component of deliverability.</p>
<p>That does <strong>not</strong> mean domain reputation is
disappearing.</p>
<p>It does <strong>not</strong> mean IP reputation is disappearing.</p>
<p>It means Google is becoming less interested in giving you a
convenient label describing what it thinks your reputation is.</p>
<p>The underlying reputation systems are still very much alive.</p>
<p>In fact, Google still documents delivery errors specifically related
to low IP reputation, low domain reputation, public blocklists,
spam-like content, rate limits, and other reputation-related
factors.</p>
<p>So now you need to know how to diagnose those things yourself.</p>
<p>That is what this guide is about.</p>
<p>This is not intended to be the final word on every topic below. I
could write an entire article about IP warming, another about Spamhaus,
another about domains, another about segmentation, another about DKIM,
and another about tracking domains.</p>
<p>I probably will.</p>
<p>Consider this the master guide.</p>
<p>If your email is going to spam and you want to understand
<strong>why</strong>, this is where I would start.</p>
<hr />
<h2
id="deliverability-and-inboxing-are-not-the-same-thing">Deliverability
and Inboxing Are Not the Same Thing</h2>
<p><img
src="https://mattnovak.email/media/5558dbbe88a74345861430b4aee81ef5.webp"
alt="Deliverability means the server accepted the message; inboxing means where it landed — inbox or spam." /></p>
<p>This distinction is incredibly important.</p>
<p>People constantly say things like this.</p>
<blockquote>
<p>"My deliverability is 98%."</p>
</blockquote>
<p>That usually means 98% of the messages were accepted by the receiving
mail servers.</p>
<p>It does <strong>not</strong> mean 98% reached the inbox.</p>
<p>An email can be successfully accepted by Gmail with a
<code>250</code> response and immediately placed in spam.</p>
<p>That email was delivered.</p>
<p>It did not inbox.</p>
<p>For the purposes of this article, I use the terms this way.</p>
<p><strong>Deliverability</strong> means whether the receiving mail
system accepted the message.</p>
<p><strong>Inboxing</strong> means whether the message actually reached
the primary inbox or another legitimate inbox category rather than
spam.</p>
<p>You can therefore have fantastic technical deliverability and
horrible inboxing.</p>
<p>That distinction matters because troubleshooting a bounce problem is
completely different from troubleshooting spam placement.</p>
<p>If Gmail is rejecting you, look at SMTP responses, authentication,
blocklists, rate limits, DNS, infrastructure, and reputation.</p>
<p>If Gmail is accepting everything but placing it in spam, you have a
classification problem.</p>
<p>Those are two different problems.</p>
<hr />
<h2 id="sender-reputation-is-no-longer-one-number">Sender Reputation Is
No Longer One Number</h2>
<p><img
src="https://mattnovak.email/media/9f6e572480214cd99182db607d47943e.webp"
alt="Sender reputation is built from many signals: data, complaints, authentication, links, blocklists, IP, and engagement." /></p>
<p>There is no single thing called "your reputation."</p>
<p>Modern email filtering evaluates a collection of interconnected
reputations and signals.</p>
<p>At minimum, you should assume receivers are evaluating some
combination of the following.</p>
<ul>
<li>Your sending IP</li>
<li>Your IP range or surrounding network</li>
<li>Your domain</li>
<li>Your organizational or root domain</li>
<li>Your DKIM signing domain</li>
<li>Your envelope sender domain</li>
<li>Your Return-Path domain</li>
<li>Your From domain</li>
<li>Your tracking domain</li>
<li>Domains appearing inside your content</li>
<li>Redirect domains</li>
<li>Your final landing page</li>
<li>Your sending platform</li>
<li>Your traffic patterns</li>
<li>Your sending history</li>
<li>Your message construction</li>
<li>Your list quality</li>
<li>Recipient engagement</li>
<li>Recipient complaints</li>
<li>Bounce behavior</li>
<li>Unsubscribe behavior</li>
<li>Message content</li>
<li>Message headers</li>
<li>Authentication</li>
<li>Your relationship with individual recipients</li>
</ul>
<p>This is why changing an IP does not necessarily fix a problem.</p>
<p>It is also why changing the From domain does not necessarily fix a
problem.</p>
<p>Filters are evaluating an ecosystem.</p>
<p>Think of sender reputation less like a credit score and more like a
constantly changing profile.</p>
<p>The receiver is effectively trying to answer four questions.</p>
<p><strong>Who are you?</strong></p>
<p><strong>What are you sending?</strong></p>
<p><strong>Who are you sending it to?</strong></p>
<p><strong>Do those people appear to want it?</strong></p>
<p>Everything else feeds into those questions.</p>
<hr />
<h2
id="authentication-is-the-starting-line-not-the-finish-line">Authentication
Is the Starting Line, Not the Finish Line</h2>
<p>One of the biggest misconceptions in deliverability is that SPF,
DKIM, and DMARC somehow make your email trustworthy.</p>
<p>They don't.</p>
<p>They make your email identifiable.</p>
<p>That distinction is huge.</p>
<p>A perfectly authenticated spammer is still a spammer.</p>
<p>Spamhaus makes essentially the same point. SPF, DKIM, and DMARC are
important, but malicious and abusive senders can configure them too.
Authentication alone does not create a good reputation.</p>
<p>What authentication does is allow Gmail and other receivers to
confidently associate your behavior with an identity.</p>
<p>That identity then earns a reputation.</p>
<h3 id="spf">SPF</h3>
<p>SPF tells a receiving system which servers are authorized to send
mail for a domain.</p>
<p>Your SPF record needs to include every legitimate system sending mail
for that domain.</p>
<p>A common problem occurs when someone changes ESPs, adds a CRM, adds a
support platform, adds transactional infrastructure, or creates some
other sending source without updating SPF.</p>
<p>Now part of the organization's mail passes SPF and part does not.</p>
<p>That is unnecessary reputation damage.</p>
<p>Google currently requires SPF or DKIM for all senders to personal
Gmail accounts. Senders reaching bulk-sender thresholds need both SPF
and DKIM.</p>
<h3 id="dkim">DKIM</h3>
<p>DKIM digitally signs the message.</p>
<p>It allows receivers to verify that the message was authorized by the
signing domain and was not improperly modified after signing.</p>
<p>For Gmail, DKIM is particularly important because it provides a
persistent domain identity that can follow your traffic across IP
addresses.</p>
<p>Google requires at least a 1024-bit DKIM key for Gmail delivery and
recommends 2048-bit keys when supported.</p>
<p>Make sure DKIM is actually passing.</p>
<p>Do not stop at "we published the DNS record."</p>
<p>Send a message.</p>
<p>Open the original headers.</p>
<p>Verify it.</p>
<h3 id="dmarc">DMARC</h3>
<p>DMARC adds alignment.</p>
<p>Your visible From domain needs to align with the domain authenticated
through SPF or DKIM for DMARC to pass.</p>
<p>For bulk senders to personal Gmail accounts, DMARC is required,
although Google currently allows a policy as permissive as
<code>p=none</code>.</p>
<p>DMARC reporting is also extremely useful because it tells you who is
actually sending on behalf of your domain.</p>
<p>That occasionally reveals systems you forgot existed.</p>
<h3 id="ptr-and-reverse-dns">PTR and Reverse DNS</h3>
<p>Your sending IP should have proper reverse DNS.</p>
<p>If your IP is <code>192.0.2.10</code>, its PTR should resolve to a
legitimate hostname associated with your mail infrastructure.</p>
<p>That hostname should then resolve forward to the same IP.</p>
<p>Google explicitly requires valid forward and reverse DNS for
senders.</p>
<p>Do not treat PTR records as cosmetic.</p>
<h3 id="helo-and-ehlo">HELO and EHLO</h3>
<p>Your SMTP server introduces itself.</p>
<p>That identity should make sense.</p>
<p>It should not be some generic localhost-style hostname, mismatched
server name, obviously temporary VPS hostname, or something unrelated to
your mail infrastructure.</p>
<p>Professional infrastructure should look professional from the first
SMTP command forward.</p>
<h3 id="tls">TLS</h3>
<p>Use TLS.</p>
<p>This is not optional for serious bulk email operations anymore.</p>
<p>Gmail requires TLS for bulk senders and can temporarily or
permanently reject non-compliant traffic.</p>
<h3 id="message-formatting">Message Formatting</h3>
<p>Make sure messages comply with RFC 5322.</p>
<p>That includes valid headers, a valid Message-ID, sane MIME
construction, proper Date headers, proper From headers, and avoiding
duplicate headers that should only exist once.</p>
<p>Google specifically calls out malformed messages as a potential cause
of delivery problems.</p>
<h3 id="one-click-unsubscribe">One-Click Unsubscribe</h3>
<p>If you are sending marketing or promotional email at Gmail
bulk-sender volumes, implement RFC-compliant one-click unsubscribe.</p>
<p>That does not simply mean putting the word "unsubscribe" at the
bottom of your message.</p>
<p>The appropriate List-Unsubscribe infrastructure and headers need to
exist.</p>
<p>Google requires one-click unsubscribe for marketing and subscription
messages sent by bulk senders.</p>
<hr />
<h2 id="authentication-does-not-guarantee-inboxing">Authentication Does
Not Guarantee Inboxing</h2>
<p>You can have all of this.</p>
<ul>
<li>SPF passes</li>
<li>DKIM passes</li>
<li>DMARC passes</li>
<li>PTR is correct</li>
<li>TLS works</li>
<li>One-click unsubscribe works</li>
<li>The message is RFC compliant</li>
</ul>
<p>And Gmail can still put everything in spam.</p>
<p>Why?</p>
<p>Because authentication answers this question.</p>
<blockquote>
<p>"Who sent this?"</p>
</blockquote>
<p>It does not answer this question.</p>
<blockquote>
<p>"Do Gmail users want this?"</p>
</blockquote>
<p>That second question is where reputation begins.</p>
<hr />
<h2 id="data-quality-is-probably-more-important-than-you-think">Data
Quality Is Probably More Important Than You Think</h2>
<p>If I had to pick the variable that destroys more sending programs
than almost anything else, it would be bad data.</p>
<p>People obsess over IPs.</p>
<p>They obsess over domains.</p>
<p>They obsess over subject lines.</p>
<p>Then they send the same garbage list through the new IP and new
domain.</p>
<p>Three days later they wonder why the new setup is ruined.</p>
<p>Your recipients are part of your reputation.</p>
<p>Bad data can produce some combination of the following.</p>
<ul>
<li>Hard bounces</li>
<li>Unknown users</li>
<li>Spam traps</li>
<li>Long-inactive mailboxes</li>
<li>Low engagement</li>
<li>Low clicks</li>
<li>Low replies</li>
<li>High complaint rates</li>
<li>Unsubscribes</li>
<li>Deletions without engagement</li>
<li>Messages ignored repeatedly</li>
</ul>
<p>A perfectly warmed IP cannot indefinitely overcome recipients telling
Gmail that they do not care about your mail.</p>
<p>You cannot infrastructure your way out of a fundamentally bad
audience.</p>
<hr />
<h2 id="complaints-matter-enormously">Complaints Matter Enormously</h2>
<p>Google currently recommends keeping the user-reported spam rate in
Postmaster Tools below <strong>0.10%</strong> and avoiding <strong>0.30%
or higher</strong>. Gmail's bulk sender enforcement uses 0.30% as an
important threshold, but Google explicitly says even rates above 0.10%
can negatively impact inboxing.</p>
<p>That means one complaint per thousand inboxed messages is already
something you should watch.</p>
<p>Three per thousand is not something I would casually ignore.</p>
<p>There is another catch.</p>
<p>Postmaster Tools spam rate can look artificially wonderful when Gmail
is already putting most of your mail in spam.</p>
<p>Why?</p>
<p>Because Gmail calculates that metric from messages delivered to
inboxes and subsequently marked as spam.</p>
<p>If Gmail automatically sends most of your traffic to spam, fewer
recipients have an opportunity to hit the spam button on inboxed
mail.</p>
<p>Google explicitly warns about this. A suspiciously low spam rate can
sometimes mean Gmail is already filtering a large percentage of your
traffic before users ever see it.</p>
<p>A 0.00% complaint rate does not automatically mean you are
healthy.</p>
<p>Context matters.</p>
<hr />
<h2 id="open-rates-still-matter-but-not-the-way-they-used-to">Open Rates
Still Matter, but Not the Way They Used To</h2>
<p>Google states that it does not track open rates and cannot verify
open-rate data reported by third parties.</p>
<p>That does not mean opens are useless.</p>
<p>It means you should stop treating an open rate as a perfectly
accurate measurement of human engagement.</p>
<p>Privacy systems, proxy image loading, bot activity, image caching,
and tracking protection have made opens increasingly noisy.</p>
<p>I still watch them.</p>
<p>I just watch the <strong>trend</strong> more than the exact
number.</p>
<p>For a mailing list that I believe is healthy, I would much rather see
a measured open rate consistently above 30% than one sitting around 10%
to 15%.</p>
<p>That does not mean 30% is a Gmail requirement.</p>
<p>It is not.</p>
<p>Different industries, message types, audiences, and tracking systems
can produce wildly different numbers.</p>
<p>The useful question is this.</p>
<blockquote>
<p>"What is normal for this particular stream, and is that number
getting better or worse?"</p>
</blockquote>
<p>If your normal tracked open rate is 38% and it suddenly becomes 19%,
something changed.</p>
<p>Investigate.</p>
<hr />
<h2
id="clicks-are-a-better-signal-when-the-email-contains-a-link">Clicks
Are a Better Signal When the Email Contains a Link</h2>
<p>If the purpose of the message is to get someone to click, watch
clicks.</p>
<p>Clicks generally represent stronger intent than an open.</p>
<p>A recipient may open by accident.</p>
<p>A privacy system may generate an open.</p>
<p>A click usually requires something more.</p>
<p>That said, security scanners and automated systems can also visit
links, so click data is not perfectly clean either.</p>
<p>Again, watch your baseline.</p>
<p>If a campaign usually generates healthy clicks and suddenly generates
almost none, I care.</p>
<p>If you are sending link-heavy promotional mail and supposedly getting
massive opens with almost no clicks, I would question how meaningful
those opens actually are.</p>
<hr />
<h2 id="if-there-are-no-links-and-no-open-tracking-watch-replies">If
There Are No Links and No Open Tracking, Watch Replies</h2>
<p>Sometimes I intentionally want a message with almost nothing
measurable inside it.</p>
<p>No tracking link.</p>
<p>No open pixel.</p>
<p>Maybe no link at all.</p>
<p>In that case, replies become extremely useful.</p>
<p>A reply is an unmistakably strong form of engagement.</p>
<p>If people are replying to you, they are interacting with the
sender.</p>
<p>For conversational mail, sales outreach, relationship-based
newsletters, or mail designed specifically to create dialogue, I care
far more about replies than pixel-based opens.</p>
<p>There is no universal "good reply rate" because a newsletter,
political appeal, ecommerce promotion, and one-to-one sales message are
completely different products.</p>
<p>What matters is establishing your normal reply rate and protecting
it.</p>
<hr />
<h2 id="plain-text-is-one-of-my-favorite-deliverability-tests">Plain
Text Is One of My Favorite Deliverability Tests</h2>
<p>When I want to know whether a domain and IP <strong>can</strong>
inbox, I like stripping the message down.</p>
<p>Start with plain text.</p>
<p>No images.</p>
<p>No HTML template.</p>
<p>No buttons.</p>
<p>No tracking pixel.</p>
<p>No click-tracking redirect.</p>
<p>No affiliate link.</p>
<p>No complicated footer.</p>
<p>No remote assets.</p>
<p>No marketing template.</p>
<p>Just legitimate, human-readable text.</p>
<p>Why?</p>
<p>Because you have removed an enormous number of variables.</p>
<p>A plain-text diagnostic email does not magically receive a Gmail
"plain text bonus."</p>
<p>Google does not publish such a rule.</p>
<p>But plain text frequently performs better during diagnosis because
there is simply less that can be wrong.</p>
<p>You have removed links.</p>
<p>You have removed redirect chains.</p>
<p>You have removed tracking domains.</p>
<p>You have removed image hosts.</p>
<p>You have removed questionable HTML.</p>
<p>You have removed template fingerprints.</p>
<p>You have removed hidden CSS problems.</p>
<p>You may have removed platform tracking.</p>
<p>That makes plain text an incredibly useful control.</p>
<p>Google itself notes that internet links can increase the possibility
of a message being treated as spam.</p>
<hr />
<h2 id="html-introduces-more-things-that-can-go-wrong">HTML Introduces
More Things That Can Go Wrong</h2>
<p>HTML email can inbox beautifully.</p>
<p>Billions of HTML emails reach inboxes every day.</p>
<p>The problem is not "HTML equals spam."</p>
<p>The problem is that HTML introduces additional signals.</p>
<p>A typical commercial HTML email may contain all of this.</p>
<ul>
<li>Open-tracking pixel</li>
<li>Click-tracking domain</li>
<li>Image CDN</li>
<li>Multiple destination domains</li>
<li>Redirect parameters</li>
<li>List-Unsubscribe links</li>
<li>View-in-browser links</li>
<li>Social links</li>
<li>Tracking parameters</li>
<li>Template-specific HTML</li>
<li>Platform-specific headers</li>
<li>Hosted images</li>
<li>CSS</li>
<li>Tracking identifiers</li>
</ul>
<p>Every one of those is another variable.</p>
<p>That is why my diagnostic sequence is usually additive.</p>
<hr />
<h2 id="how-i-test-whether-a-setup-can-inbox">How I Test Whether a Setup
Can Inbox</h2>
<p><img
src="https://mattnovak.email/media/ae1d1df7f069487ba38a11f67007b5ca.webp"
alt="A seven-step inbox test, from plain text with no links up to the full creative — changing one variable at a time." /></p>
<p>Do not change eight things at once.</p>
<p>Change one category at a time.</p>
<h3 id="test-1">Test 1</h3>
<p>Send plain text with no links and no tracking.</p>
<p>If that spams, I immediately become more suspicious of the IP,
domain, data, authentication, sending pattern, or broader
infrastructure.</p>
<h3 id="test-2">Test 2</h3>
<p>Send simple HTML with tracking disabled.</p>
<p>No links yet.</p>
<p>If plain text inboxes but simple HTML spams, investigate the message
construction and platform.</p>
<h3 id="test-3">Test 3</h3>
<p>Add one clean first-party link.</p>
<p>Use a domain you trust.</p>
<p>If inboxing deteriorates, investigate the linked domain.</p>
<h3 id="test-4">Test 4</h3>
<p>Add the final landing page you actually intend to use.</p>
<h3 id="test-5">Test 5</h3>
<p>Enable click tracking.</p>
<h3 id="test-6">Test 6</h3>
<p>Enable open tracking.</p>
<h3 id="test-7">Test 7</h3>
<p>Introduce the full production creative.</p>
<p>At each stage you are asking one question.</p>
<blockquote>
<p>"What changed when placement changed?"</p>
</blockquote>
<p>That is far more useful than sending ten completely different
creatives through three domains and four IPs and trying to infer
something from the chaos.</p>
<hr />
<h2 id="tracking-links-can-hurt-you">Tracking Links Can Hurt You</h2>
<p>Most ESPs rewrite links.</p>
<p>You think the email contains this.</p>
<p><code>https://example.com/article</code></p>
<p>The recipient may actually receive something functionally closer to
this.</p>
<p><code>https://track.example-mail.net/c/abc123...</code></p>
<p>That URL redirects to the final page.</p>
<p>The tracking domain is now part of your message.</p>
<p>Its reputation matters.</p>
<p>If it is shared with other senders, their behavior may matter.</p>
<p>If it has been heavily abused, that matters.</p>
<p>If the domain was recently registered and suddenly appears in huge
volumes of commercial mail, that can matter.</p>
<p>If the redirect ultimately lands on something questionable, that can
matter too.</p>
<p>Turning off click tracking is therefore one of the first things I
test when placement suddenly collapses.</p>
<hr />
<h2 id="the-final-landing-page-matters-too">The Final Landing Page
Matters Too</h2>
<p>Do not assume the only URL that matters is the one visible in the
email.</p>
<p>Email security systems analyze links.</p>
<p>Google operates link-protection systems specifically because URLs
inside messages can lead users to malicious or unwanted
destinations.</p>
<p>Spamhaus' DBL is also specifically designed to evaluate domains
appearing in email content and headers, including domains used in
URLs.</p>
<p>Assume filters can care about the entire path.</p>
<p>That may include the following.</p>
<ol type="1">
<li>The visible URL</li>
<li>Your tracking domain</li>
<li>Intermediate redirects</li>
<li>Affiliate redirects</li>
<li>The final landing page</li>
<li>Domains loaded by that landing page</li>
<li>Infrastructure historically associated with those domains</li>
</ol>
<p>This creates a problem I see surprisingly often.</p>
<p>The sending domain is clean.</p>
<p>The IP is clean.</p>
<p>SPF passes.</p>
<p>DKIM passes.</p>
<p>The email itself looks fine.</p>
<p>But every message contains a link to a domain with a terrible
history.</p>
<p>Changing the sending IP will not fix that.</p>
<hr />
<h2 id="content-still-matters">Content Still Matters</h2>
<p>There was a time when people talked about "spam words" like the
filter was simply counting how many times you used "FREE" or how many
exclamation points appeared in the subject line.</p>
<p>Modern filtering is far more sophisticated.</p>
<p>There is no magical list of words that automatically sends a message
to spam.</p>
<p>Context matters.</p>
<p>That does not mean content is irrelevant.</p>
<p>Gmail explicitly documents "email content is possibly spammy" as a
delivery problem and notes that links can contribute to
classification.</p>
<p>Things I would watch include the following.</p>
<ul>
<li>Misleading subject lines</li>
<li>Fake <code>Re:</code> or <code>Fwd:</code> subjects</li>
<li>Deceptive display names</li>
<li>Hidden HTML</li>
<li>Hidden text</li>
<li>Obfuscated content</li>
<li>Link destinations that do not match expectations</li>
<li>Suspicious attachments</li>
<li>Image-only messages</li>
<li>Extremely malformed HTML</li>
<li>URL shorteners</li>
<li>Excessive redirects</li>
<li>Misleading buttons</li>
<li>Phishing-like language</li>
<li>Impersonation</li>
<li>Strange character substitutions</li>
<li>Brand mismatches</li>
<li>Content radically different from your normal traffic</li>
</ul>
<p>Google specifically warns against deceptive headers, misleading
display names, fake reply-style subjects, hidden HTML and CSS, and links
where users cannot understand what they are clicking.</p>
<p>The important point is that content reputation and sender reputation
interact.</p>
<p>A strong sender can sometimes survive mediocre content.</p>
<p>A weak sender may not.</p>
<hr />
<h2 id="what-is-an-email-footprint">What Is an Email Footprint?</h2>
<p>This is one of the most important concepts in modern
deliverability.</p>
<p>A <strong>footprint</strong> is the collection of technical and
behavioral signals that allows receiving systems, reputation companies,
blocklists, and security systems to associate one stream of email with
another.</p>
<p>A footprint is not automatically bad.</p>
<p>In fact, legitimate senders should have consistent footprints.</p>
<p>Your DKIM identity is a footprint.</p>
<p>Your From domain is a footprint.</p>
<p>Your IP is a footprint.</p>
<p>Consistency helps receivers understand who you are.</p>
<p>The problem is a <strong>bad footprint</strong>.</p>
<p>A bad footprint associates your mail with infrastructure, domains,
URLs, platforms, or behavior that already has a poor reputation.</p>
<hr />
<h2 id="examples-of-sending-footprints">Examples of Sending
Footprints</h2>
<p>A receiving system may be able to correlate traffic through things
such as these.</p>
<ul>
<li>Sending IP</li>
<li>IP range</li>
<li>ASN</li>
<li>Reverse DNS</li>
<li>HELO hostname</li>
<li>DKIM domain</li>
<li>DKIM selector patterns</li>
<li>Envelope sender</li>
<li>Return-Path</li>
<li>Bounce domain</li>
<li>From domain</li>
<li>Reply-To domain</li>
<li>Message-ID hostname</li>
<li>Message-ID format</li>
<li>Tracking domain</li>
<li>Tracking URL structure</li>
<li>Image-hosting domain</li>
<li>List-Unsubscribe domain</li>
<li>List-ID</li>
<li>X-Mailer</li>
<li>ESP-specific X headers</li>
<li>MIME construction</li>
<li>Header ordering</li>
<li>Template HTML</li>
<li>Redirect patterns</li>
<li>URL parameters</li>
<li>Source IPs exposed inside Received headers</li>
<li>Platform relay hostnames</li>
<li>Landing pages</li>
<li>Sending cadence</li>
<li>Similar creatives</li>
<li>Similar recipient populations</li>
</ul>
<p>You do not need to assume every provider uses every signal.</p>
<p>The point is that changing the visible From domain does not
necessarily make you a completely different sender.</p>
<hr />
<h2 id="sending-platform-footprints-matter">Sending Platform Footprints
Matter</h2>
<p>This deserves special attention.</p>
<p>Suppose an ESP has thousands of customers.</p>
<p>Messages from those customers might share certain technical
characteristics.</p>
<p>Perhaps they use similar bounce domains.</p>
<p>Perhaps Message-ID construction is similar.</p>
<p>Perhaps the same tracking infrastructure appears repeatedly.</p>
<p>Perhaps mail travels through recognizable relay hosts.</p>
<p>Perhaps custom headers identify the platform.</p>
<p>None of that is necessarily bad.</p>
<p>Large legitimate platforms have obvious footprints too.</p>
<p>The problem is what happens when a platform becomes heavily
associated with low-quality traffic.</p>
<p>If a provider repeatedly attracts bad senders, receivers can become
cautious about traffic associated with that infrastructure.</p>
<p>This is one reason changing from one sending platform to another can
occasionally change placement even when your own domain remains
unchanged.</p>
<hr />
<h2 id="source-ips-inside-the-message-can-matter">Source IPs Inside the
Message Can Matter</h2>
<p>Do not look only at the IP making the final SMTP connection.</p>
<p>Open the raw source.</p>
<p>Look at the complete <code>Received:</code> chain.</p>
<p>Some sending systems expose upstream injection servers.</p>
<p>Some expose internal hostnames.</p>
<p>Some expose originating IP addresses.</p>
<p>Some reveal relay infrastructure belonging to another provider.</p>
<p>Those signals may give receiving systems additional context.</p>
<p>If an upstream platform has a terrible history, hiding behind a new
outbound IP does not necessarily erase every relationship with that
infrastructure.</p>
<p>Again, the answer is not to start manipulating headers to conceal who
you are.</p>
<p>That creates a completely different problem.</p>
<p>The answer is to use clean infrastructure.</p>
<hr />
<h2 id="do-not-try-to-hide-your-footprint">Do Not Try to Hide Your
Footprint</h2>
<p>This is where some operators make a huge mistake.</p>
<p>They learn about footprints and conclude that the solution is
randomization.</p>
<p>Random Message-IDs.</p>
<p>Constantly changing domains.</p>
<p>New tracking hosts.</p>
<p>Rotating IPs.</p>
<p>Changing DKIM selectors constantly.</p>
<p>Different hostnames every few days.</p>
<p>Random From addresses.</p>
<p>That does not necessarily make you invisible.</p>
<p>It can make you look like a snowshoe spammer.</p>
<p>Legitimate mailers generally benefit from durable, understandable
identities.</p>
<p>Reputation is an asset.</p>
<p>You want to build it.</p>
<p>Not continuously throw it away.</p>
<hr />
<h2 id="what-is-snowshoe-spamming">What Is Snowshoe Spamming?</h2>
<p>The term comes from an actual snowshoe.</p>
<p>A snowshoe spreads a person's weight across a large surface area so
they do not sink deeply into snow.</p>
<p>Snowshoe spamming applies the same idea to email.</p>
<p>Instead of sending a large volume from a small number of recognizable
IPs and domains, the sender spreads that traffic across large numbers of
IPs and domains.</p>
<p>The idea was to dilute reputation.</p>
<p>Rather than send 1,000,000 messages from one identity, a sender might
historically try spreading traffic across dozens or hundreds of IPs and
domains so each one appeared to have lower volume.</p>
<p>Spamhaus defines snowshoe spam in essentially these terms. The
technique spreads spam across many IPs and domains to dilute reputation
metrics and evade filtering. Spamhaus also specifically warns that
domains behaving like snowshoers can be treated like snowshoers.</p>
<p>This used to work much better than it does today.</p>
<p>Modern filtering systems correlate infrastructure.</p>
<p>Creating 100 mediocre domains does not give you 100 trustworthy
identities.</p>
<p>It may simply give the receiving system 100 reasons to believe you
are trying to avoid reputation.</p>
<hr />
<h2 id="too-many-domains-can-be-worse-than-one-good-domain">Too Many
Domains Can Be Worse Than One Good Domain</h2>
<p>I regularly see senders terrified of putting volume through one
domain.</p>
<p>So they build enormous domain farms.</p>
<p>That can be exactly backwards.</p>
<p>Reputation requires history.</p>
<p>A domain that sends legitimate, wanted email consistently for years
can become extremely valuable.</p>
<p>A collection of constantly rotating domains has very little
opportunity to build durable trust.</p>
<p>Spamhaus specifically notes that reputable bulk senders invest time
into persistent domains and IPs and therefore tend to use fewer of them
than snowshoe operations.</p>
<p>Google's own sender guidance also says that, ideally, senders should
send messages from the same IP address and maintain consistent traffic
patterns.</p>
<p>The old strategy was often this.</p>
<blockquote>
<p>"The domain went bad. Throw it away."</p>
</blockquote>
<p>Then another.</p>
<p>Then another.</p>
<p>Then another.</p>
<p>Eventually the rotation itself becomes part of the reputation
problem.</p>
<hr />
<h2 id="yes-you-can-send-more-than-one-million-emails-from-one-ip">Yes,
You Can Send More Than One Million Emails From One IP</h2>
<p>There is no rule saying a high-volume sender needs dozens of IP
addresses.</p>
<p>A properly warmed dedicated IP with good recipient engagement, stable
traffic, strong authentication, clean infrastructure, and established
history can handle enormous volume.</p>
<p>That can include more than one million messages per day.</p>
<p>That can even mean very large volumes primarily or entirely destined
for Gmail.</p>
<p>The SMTP capacity of a modern mail server is usually not the
interesting part.</p>
<p>Receiver trust is.</p>
<p>Gmail can throttle traffic.</p>
<p>It can defer mail.</p>
<p>It can adjust limits based on IP and domain behavior.</p>
<p>Google explicitly says it tracks volume and limits at both domain and
IP levels and recommends gradual increases and consistent sending
rates.</p>
<p>There is therefore no magical statement like this.</p>
<blockquote>
<p>"One IP can send exactly 500,000 Gmail messages."</p>
</blockquote>
<p>It does not work that way.</p>
<p>A sender with excellent history may be able to push volumes that
would destroy a brand-new IP.</p>
<hr />
<h2
id="gmail-does-not-have-a-published-test-10000-then-release-the-rest-rule">Gmail
Does Not Have a Published "Test 10,000 Then Release the Rest" Rule</h2>
<p>People often notice something interesting during large campaigns.</p>
<p>The first portion inboxes.</p>
<p>Then placement changes.</p>
<p>Or the first portion spams and later traffic improves.</p>
<p>Or Gmail begins deferring traffic partway through a campaign.</p>
<p>This leads to theories like this.</p>
<blockquote>
<p>"Gmail tests the first 10,000 messages."</p>
</blockquote>
<p>Or 5,000.</p>
<p>Or 20,000.</p>
<p>Or 50,000.</p>
<p>I would not build an operating strategy around any fixed number.</p>
<p>Google does not publish a universal test batch.</p>
<p>What Gmail clearly does is continuously evaluate traffic.</p>
<p>Volume matters.</p>
<p>Recipient feedback matters.</p>
<p>Sending speed matters.</p>
<p>Authentication matters.</p>
<p>Reputation matters.</p>
<p>Gmail also applies rate limits and temporary failures when traffic
appears suspicious. Google recommends consistent sending instead of
bursts and explicitly tells senders to gradually increase volume.</p>
<p>Think of Gmail as continuously reevaluating the stream rather than
conducting one single test.</p>
<p>This explains why a campaign can start well and deteriorate.</p>
<p>Your reputation is not frozen when the first message leaves the
server.</p>
<hr />
<h2 id="seed-testing-is-useful-but-seeds-are-not-the-truth">Seed Testing
Is Useful, but Seeds Are Not the Truth</h2>
<p>I use seed accounts.</p>
<p>I like seed testing.</p>
<p>I would never use seed placement as the sole definition of my
reputation.</p>
<p>Seeds have limitations.</p>
<p>A seed Gmail account does not necessarily behave like a normal
recipient.</p>
<p>It may have little historical interaction with you.</p>
<p>It may receive unusual amounts of test email.</p>
<p>Its mailbox behavior may be artificial.</p>
<p>The relationship between that account and your sender can differ
dramatically from the relationship between your sender and a real
subscriber.</p>
<p>Gmail also personalizes filtering.</p>
<p>A recipient who regularly opens your mail may see inbox placement
while someone who has ignored you for six months sees spam.</p>
<p>This means you can send the exact same message through the exact same
infrastructure and receive different placement for different
recipients.</p>
<p>Seeds give you <strong>evidence</strong>.</p>
<p>They do not give you absolute truth.</p>
<hr />
<h2 id="how-i-use-seeds">How I Use Seeds</h2>
<p>I am looking for patterns.</p>
<p>If ten Gmail seeds spam simultaneously after a domain change, I
care.</p>
<p>If one of ten spams, I care less.</p>
<p>If plain text inboxes everywhere but the production creative spams
everywhere, that is valuable information.</p>
<p>If Domain A spams across multiple known-good IPs while Domain B
inboxes through those same IPs, that is valuable information.</p>
<p>Seeds become powerful when you use them as part of controlled
testing.</p>
<p>They become dangerous when you treat one mailbox as an oracle.</p>
<hr />
<h2 id="blacklists-and-blocklists">Blacklists and Blocklists</h2>
<p>The terms "blacklist," "blocklist," DNSBL, and RBL are frequently
used interchangeably.</p>
<p>The industry increasingly prefers "blocklist."</p>
<p>A blocklist is essentially a reputation dataset containing IP
addresses, domains, or other identifiers associated with unwanted or
risky behavior.</p>
<p>Receiving systems can query those datasets and decide what to do.</p>
<p>They may reject the mail.</p>
<p>They may place it in spam.</p>
<p>They may increase its spam score.</p>
<p>They may simply use the listing as one signal among many.</p>
<p>Spamhaus explains that its blocklists can be used either to reject
mail in real time or to tag mail for additional filtering.</p>
<p>Not every blacklist matters equally.</p>
<p>Being listed on some obscure DNSBL that virtually nobody uses may
have no meaningful impact.</p>
<p>Being listed by Spamhaus is a very different situation.</p>
<hr />
<h2 id="the-spamhaus-lists-you-should-understand">The Spamhaus Lists You
Should Understand</h2>
<p>Spamhaus maintains several different datasets.</p>
<h3 id="sbl">SBL</h3>
<p>The Spamhaus Blocklist focuses on IP addresses associated with spam
sources and related abuse.</p>
<h3 id="css">CSS</h3>
<p>Combined Spam Sources focuses heavily on IPs associated with
low-reputation SMTP traffic.</p>
<p>Spamhaus specifically says potential CSS triggers include unsolicited
email, poor marketing list hygiene, compromised systems, and snowshoe
behavior.</p>
<h3 id="xbl">XBL</h3>
<p>The Exploits Blocklist focuses on compromised systems and
malware-related activity.</p>
<h3 id="pbl">PBL</h3>
<p>The Policy Blocklist includes IP space that generally should not be
sending mail directly to recipient MX servers, such as many residential
broadband ranges.</p>
<h3 id="zen">ZEN</h3>
<p>ZEN combines several Spamhaus IP-oriented datasets into one query
system.</p>
<h3 id="dbl">DBL</h3>
<p>The Domain Blocklist is different.</p>
<p>DBL is for domains.</p>
<p>Spamhaus describes DBL as a list of domain names with poor
reputation. Those domains may appear in email headers, URLs, or other
message components.</p>
<p>That distinction matters.</p>
<p>Checking your IP and seeing "clean" does not mean the domains inside
your message are clean.</p>
<hr />
<h2 id="how-to-check-your-ip-and-domain-against-blocklists">How to Check
Your IP and Domain Against Blocklists</h2>
<p>I usually start with three things.</p>
<h3 id="spamhaus-reputation-checker">Spamhaus Reputation Checker</h3>
<p>Check the sending IP.</p>
<p>Check the sending domain.</p>
<p>Check important tracking and landing domains where appropriate.</p>
<p>Spamhaus provides its own IP and Domain Reputation Checker
specifically for this purpose.</p>
<h3 id="mxtoolbox">MXToolbox</h3>
<p>MXToolbox can check an IP against a large number of DNS-based
blocklists and is useful as a broad first pass.</p>
<h3 id="multirbl">MultiRBL</h3>
<p>MultiRBL is another useful way to see listings across many DNSBLs and
can also help identify reverse DNS issues.</p>
<p>Do not just count listings.</p>
<p>Ask which list you are on.</p>
<p>Ask why.</p>
<p>Ask how widely that list is used.</p>
<p>Ask whether your SMTP rejection messages reference it.</p>
<p>One serious listing is more important than twenty irrelevant
ones.</p>
<hr />
<h2 id="spamhaus-probably-knows-more-about-you-than-you-think">Spamhaus
Probably Knows More About You Than You Think</h2>
<p>There is a common assumption among senders that if they avoid obvious
spam traps, Spamhaus cannot see them.</p>
<p>I would not make that assumption.</p>
<p>Spamhaus says its reputation systems use many sources, automated
systems, heuristics, intelligence partnerships, machine learning, manual
investigations, and large-scale visibility into email activity. It
intentionally does not reveal all listing criteria or data sources.</p>
<p>There is also a common operator theory that moving traffic
exclusively to Gmail can reduce exposure to external trap networks.</p>
<p>There may be situations where a sender observes exactly that.</p>
<p>But I would <strong>not</strong> turn the observation into the
statement that Spamhaus has "no Gmail seeds."</p>
<p>Spamhaus does not publish enough information about its data
collection for anyone outside Spamhaus to responsibly guarantee
that.</p>
<p>What I am comfortable saying is this.</p>
<p><strong>A Spamhaus listing and Gmail's internal reputation are two
different things.</strong></p>
<p>You can have a Spamhaus problem and still inbox Gmail.</p>
<p>You can be completely clean on Spamhaus and spam at Gmail.</p>
<p>A public blocklist is not Gmail's internal reputation database.</p>
<hr />
<h2 id="should-you-just-mail-gmail-to-get-off-spamhaus">Should You "Just
Mail Gmail" to Get Off Spamhaus?</h2>
<p>I have seen cases where a sender stops the traffic that appears to be
triggering an external reputation system, continues sending a cleaner
subset elsewhere, and eventually sees a listing disappear.</p>
<p>Some operators interpret this as "mail Gmail until Spamhaus forgets
about you."</p>
<p>I would treat that as an observation, not a guaranteed delisting
procedure.</p>
<p>Spamhaus' official guidance is to stop the behavior causing the
listing, correct the problem, use its Reputation Checker, and follow the
removal instructions associated with the listing. Many DBL listings also
expire automatically after the activity that triggered them stops.</p>
<p>If sending only clean, wanted Gmail traffic happens to be part of
your recovery process, fine.</p>
<p>But do not assume you have discovered an invisible lane where
Spamhaus cannot observe anything you do.</p>
<p>That is too strong a claim.</p>
<hr />
<h2 id="domain-reputation-and-ip-reputation-affect-each-other">Domain
Reputation and IP Reputation Affect Each Other</h2>
<p>Another mistake is treating domain and IP reputation like completely
independent universes.</p>
<p>They are not.</p>
<p>Spamhaus explicitly notes that domain and IP reputations affect each
other.</p>
<p>Email receivers can also correlate them.</p>
<p>If ten terrible domains repeatedly appear on the same IP, what does
that say about the IP?</p>
<p>If one supposedly "clean" domain repeatedly moves from one terrible
IP to another, what does that say about the domain?</p>
<p>Reputation is relational.</p>
<p>That is another reason indiscriminate rotation is dangerous.</p>
<hr />
<h2 id="how-subdomain-reputation-actually-works">How Subdomain
Reputation Actually Works</h2>
<p>People often ask this.</p>
<blockquote>
<p>"If example.com is bad, can I just send from mail.example.com?"</p>
</blockquote>
<p>Maybe.</p>
<p>But do not think of a subdomain as a completely unrelated
identity.</p>
<p>A subdomain can build its own sending history.</p>
<p><code>news.example.com</code> can develop signals that differ from
<code>transactional.example.com</code>.</p>
<p>That functional separation can be useful.</p>
<p>But receivers can still understand that both belong to
<code>example.com</code>.</p>
<p>Google explicitly aggregates the bulk sender threshold across the
same primary domain. If <code>example.com</code> and
<code>promotions.example.com</code> together cross Gmail's bulk-sender
threshold, Google treats the primary domain as the common identity for
that calculation.</p>
<p>Spamhaus DBL also has root-domain implications. If a domain is listed
at the registrable level, subdomains of that domain can return the same
DBL result.</p>
<p>So no, a subdomain should not be viewed as a magic reputation
reset.</p>
<hr />
<h2 id="when-a-subdomain-makes-sense">When a Subdomain Makes Sense</h2>
<p>Use subdomains when you want logical separation while maintaining the
same organizational identity.</p>
<p>For example.</p>
<ul>
<li><code>news.example.com</code></li>
<li><code>offers.example.com</code></li>
<li><code>receipts.example.com</code></li>
<li><code>alerts.example.com</code></li>
</ul>
<p>This can help separate traffic streams.</p>
<p>Promotional mail behaves differently from password resets.</p>
<p>Password resets should not suffer because a marketer accidentally
sent an aggressive promotion to an old segment.</p>
<p>Google and Yahoo both recommend separating different mail types in
various ways, including IP or sending identity separation.</p>
<p>That is legitimate reputation management.</p>
<hr />
<h2 id="when-a-completely-new-domain-makes-sense">When a Completely New
Domain Makes Sense</h2>
<p>A separate domain can make sense when you genuinely want an
independently managed identity.</p>
<p>Maybe it is a different brand.</p>
<p>Maybe it is a separate business unit.</p>
<p>Maybe you need strong risk separation.</p>
<p>Maybe the original domain has sustained serious reputation
damage.</p>
<p>Maybe the root domain itself is on a domain blocklist.</p>
<p>What I would not do is create new domains every week because
yesterday's domain stopped inboxing.</p>
<p>That is exactly the operational pattern you do not want.</p>
<hr />
<h2 id="when-to-swap-an-ip">When to Swap an IP</h2>
<p><img
src="https://mattnovak.email/media/d0bbc5fd11ff4f36b705f0e3cc55bd11.webp"
alt="Is it the domain or the IP? Hold everything else constant and swap one variable to find the root cause." /></p>
<p>Do not replace an IP because one Gmail seed went to spam.</p>
<p>Diagnose first.</p>
<p>A strong indication of an IP problem looks like this.</p>
<p><strong>Domain A + IP 1 spams</strong></p>
<p><strong>Domain A + IP 2 inboxes</strong></p>
<p>Keep the following as controlled as possible.</p>
<ul>
<li>Same domain</li>
<li>Same creative</li>
<li>Same links</li>
<li>Same recipient quality</li>
<li>Same sending rate</li>
<li>Same authentication</li>
</ul>
<p>If changing only the IP consistently changes placement, the IP
becomes the obvious suspect.</p>
<p>Additional evidence includes the following.</p>
<ul>
<li>IP-specific SMTP deferrals</li>
<li>IP blocklist listings</li>
<li>Low-IP-reputation errors</li>
<li>Other domains failing on the same IP</li>
<li>The same domain performing normally through a proven clean IP</li>
</ul>
<p>At that point, moving traffic may make sense.</p>
<hr />
<h2 id="when-to-swap-a-domain">When to Swap a Domain</h2>
<p>Now reverse the test.</p>
<p><strong>Domain A + IP 1 spams</strong></p>
<p><strong>Domain A + IP 2 spams</strong></p>
<p>But this happens.</p>
<p><strong>Domain B + IP 1 inboxes</strong></p>
<p><strong>Domain B + IP 2 inboxes</strong></p>
<p>Now the domain becomes much more suspicious.</p>
<p>Before throwing it away, test plain text.</p>
<p>Remove links.</p>
<p>Remove tracking.</p>
<p>Check DKIM.</p>
<p>Check SPF.</p>
<p>Check DMARC.</p>
<p>Check the domain against Spamhaus DBL.</p>
<p>Check the root domain.</p>
<p>Check landing pages.</p>
<p>Check sending history.</p>
<p>Check whether the domain was previously used.</p>
<p>If the domain continues failing across multiple known-good IPs with
simple, clean messages while another domain works through those same
IPs, replacing the domain may be justified.</p>
<hr />
<h2 id="the-simple-domain-and-ip-diagnostic-matrix">The Simple Domain
and IP Diagnostic Matrix</h2>
<table>
<thead>
<tr class="header">
<th>Result</th>
<th>Likely Direction</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Same domain fails on several proven IPs</td>
<td>Investigate domain, content, URLs, data</td>
</tr>
<tr class="even">
<td>Same domain works immediately on another proven IP</td>
<td>Investigate original IP</td>
</tr>
<tr class="odd">
<td>Multiple domains fail on one IP</td>
<td>Investigate IP or network</td>
</tr>
<tr class="even">
<td>One domain fails while another works on the same IP</td>
<td>Investigate failing domain</td>
</tr>
<tr class="odd">
<td>Everything works until one URL is added</td>
<td>Investigate URL, redirect, landing page</td>
</tr>
<tr class="even">
<td>Plain text works but tracked HTML fails</td>
<td>Investigate content, tracking, platform footprint</td>
</tr>
<tr class="odd">
<td>Every setup fails on the same recipient segment</td>
<td>Investigate data</td>
</tr>
<tr class="even">
<td>Everything deteriorates after a volume jump</td>
<td>Investigate pacing and reputation response</td>
</tr>
</tbody>
</table>
<p>This is far more useful than guessing.</p>
<hr />
<h2 id="if-the-same-domain-fails-across-multiple-ips">If the Same Domain
Fails Across Multiple IPs</h2>
<p>Assuming your testing is controlled, I become increasingly suspicious
of the domain.</p>
<p>That includes domains related to the message, not merely the visible
From address.</p>
<p>Maybe the problem is the From domain.</p>
<p>Maybe it is the DKIM domain.</p>
<p>Maybe it is the tracking domain.</p>
<p>Maybe it is the landing domain.</p>
<p>Maybe the entire root domain has developed a bad reputation.</p>
<p>You need to isolate them.</p>
<hr />
<h2 id="if-the-domain-works-through-another-ip">If the Domain Works
Through Another IP</h2>
<p>Then the original IP deserves serious scrutiny.</p>
<p>That does not automatically mean it is permanently dead.</p>
<p>Maybe you pushed volume too fast.</p>
<p>Maybe it needs recovery.</p>
<p>Maybe it inherited history from a previous tenant.</p>
<p>Maybe another customer used the IP before you.</p>
<p>Maybe it sits inside a problematic network.</p>
<p>Maybe the reputation is temporarily low.</p>
<p>But at least you now know the domain is capable of inboxing through
other infrastructure.</p>
<p>That is valuable.</p>
<hr />
<h2 id="the-data-test-nobody-wants-to-perform">The Data Test Nobody
Wants to Perform</h2>
<p>Sometimes everything is clean.</p>
<p>Then you change the recipients.</p>
<p>Suddenly you inbox.</p>
<p>That tells you something nobody wants to hear.</p>
<p>Your infrastructure was not the problem.</p>
<p>Your list was.</p>
<p>This is why I always want a known-good engaged segment available for
testing.</p>
<p>Without that control, you can waste days moving infrastructure around
when the recipients are actually destroying the campaign.</p>
<hr />
<h2 id="my-10-rule-for-questionable-data">My 10% Rule for Questionable
Data</h2>
<p>Let me define "questionable" first.</p>
<p>I mean data where I have less confidence than I do in my established
engaged audience.</p>
<p>Maybe it is older.</p>
<p>Maybe the source is less proven.</p>
<p>Maybe it has lower historical engagement.</p>
<p>Maybe I am testing a new acquisition source.</p>
<p>I generally do not want that data suddenly becoming half of my
traffic.</p>
<p>My operating rule is roughly <strong>10%</strong>.</p>
<p>If I have a healthy established stream and I am going to test
lower-confidence data, I do not want it representing more than roughly
10% of the day's volume.</p>
<p>And 10% is a ceiling.</p>
<p>It is not a promise that you can add 10% garbage every day
forever.</p>
<p>If engagement takes a beating, stop.</p>
<p>If clicks collapse, stop.</p>
<p>If replies collapse, stop.</p>
<p>If complaints rise, stop.</p>
<p>If bounces rise, stop.</p>
<p>If Gmail placement deteriorates, stop.</p>
<p>Your reputation tells you how much experimentation it can
tolerate.</p>
<p>Also remember that provider policies and applicable laws still
matter. Google explicitly advises senders not to purchase email
addresses and not to send messages to people who did not sign up to
receive them.</p>
<p>So do not interpret my 10% rule as permission to ignore consent or
provider policies.</p>
<p>It is a reputation-risk rule for testing lower-confidence segments
within an otherwise healthy operation.</p>
<hr />
<h2 id="how-to-maintain-good-engagement">How to Maintain Good
Engagement</h2>
<p>Do not wait for a deliverability emergency before segmenting.</p>
<p>Build engagement segmentation from the beginning.</p>
<p>Useful populations can include the following.</p>
<ul>
<li>Clicked within 7 days</li>
<li>Clicked within 14 days</li>
<li>Clicked within 30 days</li>
<li>Clicked within 60 days</li>
<li>Opened within 7 days</li>
<li>Opened within 14 days</li>
<li>Opened within 30 days</li>
<li>Replied recently</li>
<li>Purchased recently</li>
<li>Converted recently</li>
<li>Signed up recently</li>
<li>Active subscribers</li>
<li>Long-term inactive recipients</li>
</ul>
<p>Clicks, replies, purchases, and conversions are generally more
meaningful to me than opens alone.</p>
<p>Open data is still useful.</p>
<p>It just needs context.</p>
<hr />
<h2 id="what-to-do-when-emails-start-going-to-spam">What to Do When
Emails Start Going to Spam</h2>
<p><img
src="https://mattnovak.email/media/51d3873b19e946869c48c9d003b6a942.webp"
alt="Recover inbox placement by tightening the engagement funnel to your most recent openers, clickers, and repliers." /></p>
<p>Do not panic and immediately change everything.</p>
<p>That destroys your ability to identify the problem.</p>
<p>Start reducing risk.</p>
<h3 id="mild-deterioration">Mild Deterioration</h3>
<p>Maybe some seeds are shifting.</p>
<p>Maybe open rates softened.</p>
<p>Maybe clicks declined.</p>
<p>Maybe Gmail placement is less consistent.</p>
<p>Tighten your segment.</p>
<p>Move toward people who engaged within the last 30 days.</p>
<p>Reduce questionable data.</p>
<p>Watch complaints.</p>
<p>Watch temporary failures.</p>
<p>Keep volume steady rather than violently turning traffic on and
off.</p>
<h3 id="clear-spam-placement">Clear Spam Placement</h3>
<p>If Gmail is clearly pushing a meaningful percentage of traffic into
spam, tighten harder.</p>
<p>Start with recent clickers, repliers, buyers, converters, and highly
engaged recipients.</p>
<p>A 14-day engagement window may make sense.</p>
<p>If the situation is serious, go tighter.</p>
<h3 id="severe-reputation-damage">Severe Reputation Damage</h3>
<p>When placement has collapsed, I want the strongest possible
audience.</p>
<p>That might mean 7-day clickers and repliers.</p>
<p>It might mean very recent purchasers.</p>
<p>It might mean active subscribers with repeated engagement.</p>
<p>The exact number of days is not sacred.</p>
<p>The principle is.</p>
<p><strong>As reputation gets worse, recipient quality needs to get
better.</strong></p>
<hr />
<h2 id="do-not-warm-a-broken-reputation-with-bad-data">Do Not "Warm" a
Broken Reputation With Bad Data</h2>
<p>This sounds obvious, yet people do it constantly.</p>
<p>They discover an IP is struggling.</p>
<p>Then they continue pushing the full database through it because "we
need volume to warm it."</p>
<p>That is not warming.</p>
<p>That is reinforcement.</p>
<p>The receiver sees more unwanted behavior and becomes increasingly
confident that its original classification was correct.</p>
<p>Warm with your best recipients.</p>
<p>Recover with your best recipients.</p>
<p>Expand only after the strongest traffic performs correctly.</p>
<hr />
<h2 id="reputation-recovery-should-be-earned">Reputation Recovery Should
Be Earned</h2>
<p>I generally think of recovery as a ladder.</p>
<ol type="1">
<li>Start with the strongest engagement cohort.</li>
<li>Send consistently.</li>
<li>Verify inbox placement.</li>
<li>Watch complaints.</li>
<li>Watch clicks or replies.</li>
<li>Watch SMTP responses.</li>
<li>Expand the audience slightly.</li>
<li>Repeat.</li>
<li>Stop expanding when metrics deteriorate.</li>
</ol>
<p>Do not decide in advance that you are increasing volume 100% every
day no matter what happens.</p>
<p>The audience decides whether you deserve the next step.</p>
<hr />
<h2 id="building-reputation-on-a-new-domain-and-ip">Building Reputation
on a New Domain and IP</h2>
<p>A completely new domain on a completely new dedicated IP is the most
difficult starting point because almost everything is unknown.</p>
<p>Before sending production traffic, make sure the technical foundation
is complete.</p>
<h3 id="preflight-checklist">Preflight Checklist</h3>
<ul>
<li>SPF passes</li>
<li>DKIM passes</li>
<li>DMARC exists</li>
<li>PTR is correct</li>
<li>Forward DNS matches</li>
<li>HELO is sensible</li>
<li>TLS works</li>
<li>Message-ID is valid</li>
<li>From alignment is correct</li>
<li>Bounce handling works</li>
<li>Complaints can be processed</li>
<li>One-click unsubscribe works when required</li>
<li>Tracking domain is clean</li>
<li>Landing domain is clean</li>
<li>IP is not blocklisted</li>
<li>Domain is not blocklisted</li>
<li>Postmaster Tools is configured</li>
<li>Sending rate is controlled</li>
<li>Feedback IDs are configured when useful</li>
</ul>
<p>Then begin with your best recipients.</p>
<p>Not random people.</p>
<p>Not your oldest segment.</p>
<p>Not the file somebody bought three years ago.</p>
<p>Your best recipients.</p>
<hr />
<h2 id="do-not-obsess-over-an-exact-warming-schedule">Do Not Obsess Over
an Exact Warming Schedule</h2>
<p>I have seen warming guides that say things like this.</p>
<p>Day 1 sends exactly X.</p>
<p>Day 2 sends exactly Y.</p>
<p>Day 3 sends exactly Z.</p>
<p>That creates false confidence.</p>
<p>A mature brand moving an established domain onto a fresh IP is very
different from a brand-new domain sending unknown data from a brand-new
IP.</p>
<p>Google does not publish a universal warming calendar.</p>
<p>It tells senders to start with low volume, send to engaged users,
increase gradually, maintain consistent rates, and monitor the
response.</p>
<p>That is how I look at warming.</p>
<p>Your ramp should be conditional.</p>
<p>If everything looks fantastic, expand.</p>
<p>If metrics weaken, hold.</p>
<p>If placement collapses, reduce.</p>
<hr />
<h2 id="consistency-is-hugely-important">Consistency Is Hugely
Important</h2>
<p>A common bad sending pattern looks like this.</p>
<p>Monday sends 20,000.</p>
<p>Tuesday sends nothing.</p>
<p>Wednesday sends 700,000.</p>
<p>Thursday sends 10,000.</p>
<p>Friday sends 1,500,000.</p>
<p>Then silence for four days.</p>
<p>That is not a stable identity.</p>
<p>Google specifically recommends consistent sending rates and warns
against burst-style sending.</p>
<p>If you expect to become a million-message-per-day sender, behave like
a sender gradually becoming a million-message-per-day sender.</p>
<p>Do not behave like someone who discovered SMTP yesterday.</p>
<hr />
<h2 id="dedicated-ips-vs-shared-ips">Dedicated IPs vs Shared IPs</h2>
<p>Neither option is automatically better.</p>
<p>They solve different problems.</p>
<h3 id="shared-ip-advantages">Shared IP Advantages</h3>
<ul>
<li>Reputation may already exist</li>
<li>Lower-volume senders do not need to establish an entire IP identity
alone</li>
<li>Provider manages much of the infrastructure</li>
<li>Easier startup</li>
<li>Less operational overhead</li>
<li>Good providers actively police the pool</li>
</ul>
<h3 id="shared-ip-disadvantages">Shared IP Disadvantages</h3>
<ul>
<li>Other customers influence the IP</li>
<li>A bad neighbor can hurt everyone</li>
<li>You cannot fully control sending patterns</li>
<li>You cannot control all remediation decisions</li>
<li>Blocklist issues may originate with someone else</li>
<li>Troubleshooting becomes more complicated</li>
</ul>
<p>Google explicitly acknowledges this tradeoff. The activity of senders
sharing an IP can affect the reputation and delivery of everyone on that
IP.</p>
<h3 id="dedicated-ip-advantages">Dedicated IP Advantages</h3>
<ul>
<li>Your behavior controls the reputation</li>
<li>Easier attribution</li>
<li>Better for consistently high volume</li>
<li>Greater infrastructure control</li>
<li>Easier IP-specific testing</li>
<li>You are not directly sharing reputation with random customers</li>
</ul>
<h3 id="dedicated-ip-disadvantages">Dedicated IP Disadvantages</h3>
<ul>
<li>You must build reputation</li>
<li>You must maintain reputation</li>
<li>Low or inconsistent volume can be problematic</li>
<li>You are responsible when something goes wrong</li>
<li>A new or recycled IP may have unknown history</li>
</ul>
<p>If you send enough legitimate volume to maintain your own reputation,
I generally prefer the control of dedicated infrastructure.</p>
<p>If you send sporadically or at low volume, a high-quality shared pool
can make more sense.</p>
<hr />
<h2 id="shared-does-not-automatically-mean-bad">Shared Does Not
Automatically Mean Bad</h2>
<p>A good shared pool can be fantastic.</p>
<p>The important word is <strong>good</strong>.</p>
<p>A competent ESP actively removes abusive customers, manages traffic,
monitors reputation, handles complaints, and protects the pool.</p>
<p>A terrible shared platform accepts anyone with a credit card and
waits until receivers punish the IPs.</p>
<p>Those are completely different products.</p>
<p>Google specifically recommends that providers remove customers who
use their infrastructure to send spam.</p>
<p>The quality of the provider matters.</p>
<hr />
<h2 id="dedicated-does-not-automatically-mean-good">Dedicated Does Not
Automatically Mean Good</h2>
<p>I can give you a dedicated IP right now.</p>
<p>That tells you nothing about whether Gmail likes it.</p>
<p>Maybe it previously belonged to a terrible sender.</p>
<p>Maybe the range has a bad history.</p>
<p>Maybe the upstream network is questionable.</p>
<p>Maybe reverse DNS is broken.</p>
<p>Maybe your domain is bad.</p>
<p>Maybe your data is terrible.</p>
<p>Ownership is not reputation.</p>
<hr />
<h2 id="sometimes-a-setup-simply-fails">Sometimes a Setup Simply
Fails</h2>
<p>This is something people rarely admit.</p>
<p>You can configure everything correctly and still have a setup that
never performs the way you expected.</p>
<p>Maybe the IP history is worse than you realized.</p>
<p>Maybe the subnet has baggage.</p>
<p>Maybe the hosting network has a poor reputation.</p>
<p>Maybe the domain had prior usage.</p>
<p>Maybe another related domain contaminated the ecosystem.</p>
<p>Maybe your landing page is the problem.</p>
<p>Maybe the reputation system made an association you cannot see.</p>
<p>Maybe your initial traffic was simply too poor.</p>
<p>Email reputation systems are probabilistic.</p>
<p>You are not going to win 100% of the time.</p>
<p>If you build enough infrastructure, some percentage of it will
underperform.</p>
<p>Your job is to diagnose rather than become emotionally attached to
the setup.</p>
<hr />
<h2 id="domain-age-is-not-magic">Domain Age Is Not Magic</h2>
<p>An old domain can have a terrible reputation.</p>
<p>A new domain can eventually build a fantastic reputation.</p>
<p>Age is context.</p>
<p>History matters more.</p>
<p>Spamhaus notes that unknown domains begin with greater risk because
they lack established history, while legitimate use over time can build
positive reputation.</p>
<p>Buying an old domain does not automatically buy good email
reputation.</p>
<p>Sometimes you buy the opposite.</p>
<hr />
<h2 id="website-and-hosting-reputation-can-matter">Website and Hosting
Reputation Can Matter</h2>
<p>Your email domain does not live in isolation.</p>
<p>Spamhaus specifically mentions hosting, DNS, infrastructure,
ownership, registration, usage patterns, and associations when
discussing domain reputation.</p>
<p>That means I care about where the domain points.</p>
<p>I care about where the website lives.</p>
<p>I care about nameservers.</p>
<p>I care about historical associations.</p>
<p>I care about the landing domain.</p>
<p>A clean sending IP does not absolve a domain living inside a sketchy
infrastructure ecosystem.</p>
<hr />
<h2 id="separate-transactional-and-marketing-traffic">Separate
Transactional and Marketing Traffic</h2>
<p>Please do not send password resets, receipts, account alerts, and
aggressive promotional campaigns through one indistinguishable
reputation stream if you can reasonably separate them.</p>
<p>Transactional mail has a very different engagement profile.</p>
<p>Recipients expect it.</p>
<p>They often need it immediately.</p>
<p>Promotional mail carries more complaint risk.</p>
<p>Separate them logically.</p>
<p>That might mean different subdomains.</p>
<p>It might mean different DKIM identities.</p>
<p>It might mean different IPs.</p>
<p>It might mean all three.</p>
<p>Google explicitly recommends separating message types when multiple
IPs are used, and Yahoo recommends segregating different mail types by
IP or DKIM domain.</p>
<p>Protect the important stream.</p>
<hr />
<h2 id="your-from-address-should-be-boring">Your From Address Should Be
Boring</h2>
<p>That is a compliment.</p>
<p>Receivers like identities they understand.</p>
<p>Recipients like identities they understand.</p>
<p>Do not constantly change this.</p>
<p><code>newsletter@example.com</code></p>
<p><code>deals@example.com</code></p>
<p><code>alerts@example.com</code></p>
<p>Those make sense.</p>
<p>Google specifically recommends using consistent From addresses for
consistent categories of mail.</p>
<p>Do not try to trick people into thinking an automated marketing blast
is part of an existing conversation.</p>
<p>Fake <code>Re:</code> subjects are not clever.</p>
<p>They are deceptive.</p>
<p>Google explicitly calls them out.</p>
<hr />
<h2 id="bounce-handling-matters">Bounce Handling Matters</h2>
<p>Hard bounces should be removed quickly.</p>
<p>Repeatedly mailing nonexistent addresses is a terrible signal.</p>
<p>Temporary failures need different handling.</p>
<p>Do not permanently suppress every temporary error immediately.</p>
<p>Temporary failures can represent rate limiting or temporary receiver
problems.</p>
<p>For Gmail-specific temporary failures, Google recommends slowing down
and using exponential backoff rather than hammering the receiver
harder.</p>
<p>Your MTA needs to understand the difference between these things.</p>
<p>A good email system does not simply say "bounce."</p>
<p>It understands the response.</p>
<hr />
<h2 id="rate-limits-are-feedback">Rate Limits Are Feedback</h2>
<p>If Gmail starts temp-failing traffic because of suspicious volume, do
not treat Gmail like an enemy firewall you need to defeat.</p>
<p>It is giving you information.</p>
<p>Slow down.</p>
<p>Google explicitly recommends stopping briefly when appropriate,
resuming at a slower rate, and maintaining a constant sending pattern
when encountering rate-related issues.</p>
<p>Receivers are telling you what they are willing to accept from your
current reputation state.</p>
<p>Listen.</p>
<hr />
<h2 id="engagement-is-not-just-about-todays-campaign">Engagement Is Not
Just About Today's Campaign</h2>
<p>Reputation is cumulative.</p>
<p>Imagine this.</p>
<p>You spend three months building beautiful engagement.</p>
<p>Then somebody finds an old database and decides to mail all of it
Friday afternoon.</p>
<p>The damage from that campaign does not necessarily end Friday
afternoon.</p>
<p>Google notes that high spam rates can continue affecting
classification and that reputation recovery takes time.</p>
<p>Reputation is an asset that can be spent.</p>
<p>Bad campaigns spend it very quickly.</p>
<hr />
<h2 id="a-good-segment-protects-a-weak-setup">A Good Segment Protects a
Weak Setup</h2>
<p>When a domain is recovering, your best users become your reputation
repair crew.</p>
<p>Send people who know you.</p>
<p>Send people who recently clicked.</p>
<p>Send people who recently replied.</p>
<p>Send people who purchased.</p>
<p>Send people who requested your content.</p>
<p>Send subscribers who consistently interact.</p>
<p>Those recipients create the behavioral evidence you need.</p>
<hr />
<h2 id="a-bad-segment-can-destroy-a-great-setup">A Bad Segment Can
Destroy a Great Setup</h2>
<p>The opposite is also true.</p>
<p>I do not care how beautiful your DNS is.</p>
<p>If Gmail users repeatedly ignore and complain about your mail, Gmail
will eventually notice.</p>
<p>Google openly says that frequent spam reports can lower a domain's
reputation and make future messages more likely to be classified as
spam.</p>
<p>Reputation follows behavior.</p>
<hr />
<h2 id="why-removing-links-can-suddenly-fix-placement">Why Removing
Links Can Suddenly Fix Placement</h2>
<p>This is one of the most useful tests you can perform.</p>
<p>You send your normal HTML creative.</p>
<p>Spam.</p>
<p>You remove every link.</p>
<p>Inbox.</p>
<p>What does that tell you?</p>
<p>It does not automatically identify the exact bad URL.</p>
<p>But it dramatically narrows the investigation.</p>
<p>Now inspect these.</p>
<ul>
<li>Tracking domain</li>
<li>Redirect domain</li>
<li>Landing page</li>
<li>Affiliate URL</li>
<li>Image domains</li>
<li>Unsubscribe URL</li>
<li>View-in-browser domain</li>
<li>Social links</li>
<li>Link shorteners</li>
</ul>
<p>Add them back gradually.</p>
<p>Find the trigger.</p>
<hr />
<h2 id="why-turning-off-tracking-can-help">Why Turning Off Tracking Can
Help</h2>
<p>Open tracking generally adds an invisible remote image.</p>
<p>Click tracking generally rewrites URLs.</p>
<p>That means enabling tracking changes the email.</p>
<p>It introduces new infrastructure.</p>
<p>If your ESP's tracking environment has reputation problems, your
perfectly good domain can suddenly become associated with questionable
URLs.</p>
<p>This does not mean tracking is inherently bad.</p>
<p>It means tracking is another reputation dependency.</p>
<p>Know what you are depending on.</p>
<hr />
<h2 id="what-about-link-shorteners">What About Link Shorteners?</h2>
<p>I generally avoid generic public URL shorteners in bulk email.</p>
<p>They are heavily abused.</p>
<p>They also hide the final destination from recipients.</p>
<p>Google explicitly says links should be clear and recipients should
understand what they are clicking.</p>
<p>Use a legitimate branded tracking domain whenever possible.</p>
<p>It is easier to understand.</p>
<p>It is easier to manage.</p>
<p>It gives you greater control over reputation.</p>
<hr />
<h2 id="look-at-the-entire-raw-message">Look at the Entire Raw
Message</h2>
<p>A deliverability person should be comfortable reading email
headers.</p>
<p>Do not depend entirely on the ESP dashboard.</p>
<p>Open the message source.</p>
<p>Look at these.</p>
<ul>
<li>Authentication-Results</li>
<li>Received</li>
<li>DKIM-Signature</li>
<li>Return-Path</li>
<li>Message-ID</li>
<li>List-Unsubscribe</li>
<li>List-Unsubscribe-Post</li>
<li>From</li>
<li>Reply-To</li>
<li>MIME boundaries</li>
<li>Content-Type</li>
<li>Source hostnames</li>
<li>Relay IPs</li>
</ul>
<p>This tells you what Gmail actually received.</p>
<p>What your platform claims it sent is less important.</p>
<hr />
<h2 id="check-the-gmail-authentication-result-yourself">Check the Gmail
Authentication Result Yourself</h2>
<p>Send a real message to Gmail.</p>
<p>Open it.</p>
<p>Use "Show original."</p>
<p>You want SPF, DKIM, and DMARC doing exactly what you think they are
doing.</p>
<p>Gmail also surfaces "mailed by" and "signed by" information to users
when authentication is working.</p>
<p>Never assume DNS configuration equals successful authentication.</p>
<p>Verify the actual message.</p>
<hr />
<h2
id="what-to-monitor-now-that-reputation-dashboards-are-going-away">What
to Monitor Now That Reputation Dashboards Are Going Away</h2>
<p>This is the real question.</p>
<p>If Google stops showing you a neat little "High / Medium / Low / Bad"
chart, what replaces it?</p>
<p>I would watch a basket of signals.</p>
<h3 id="gmail-postmaster-tools">Gmail Postmaster Tools</h3>
<p>Watch the following where available.</p>
<ul>
<li>Spam rate</li>
<li>Compliance</li>
<li>Authentication</li>
<li>Delivery errors</li>
<li>Feedback Loop</li>
<li>Encryption</li>
<li>SMTP-related problems</li>
</ul>
<p>The old reputation chart was never the entire answer anyway.</p>
<p>Google's own explanation for replacing it is that reputation changes
slowly, can be difficult to act on, and represents only one factor in
deliverability.</p>
<h3 id="smtp-responses">SMTP Responses</h3>
<p>Read them.</p>
<p>Do not simply count bounces.</p>
<h3 id="seed-placement">Seed Placement</h3>
<p>Use it directionally.</p>
<h3 id="engagement">Engagement</h3>
<p>Watch opens, clicks, replies, purchases, conversions, and
unsubscribes.</p>
<h3 id="blocklists">Blocklists</h3>
<p>Check important IP and domain lists.</p>
<h3 id="volume">Volume</h3>
<p>Know whether your traffic is stable.</p>
<h3 id="data-mix">Data Mix</h3>
<p>Know what percentage is highly engaged versus experimental or lower
confidence.</p>
<h3 id="infrastructure-changes">Infrastructure Changes</h3>
<p>Document them.</p>
<p>If placement collapses Tuesday and somebody changed your tracking
domain Monday night, that matters.</p>
<hr />
<h2 id="build-a-deliverability-change-log">Build a Deliverability Change
Log</h2>
<p>This is one of the easiest improvements any serious sender can
make.</p>
<p>Keep a record of changes.</p>
<p>For example.</p>
<table>
<thead>
<tr class="header">
<th>Date</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Monday</td>
<td>Added new IP</td>
</tr>
<tr class="even">
<td>Tuesday</td>
<td>Increased Gmail volume</td>
</tr>
<tr class="odd">
<td>Wednesday</td>
<td>Changed tracking domain</td>
</tr>
<tr class="even">
<td>Thursday</td>
<td>Added new data source</td>
</tr>
<tr class="odd">
<td>Friday</td>
<td>Complaint rate increased</td>
</tr>
</tbody>
</table>
<p>When something breaks, you have history.</p>
<p>Without a change log, every deliverability investigation starts with
this conversation.</p>
<blockquote>
<p>"Did anyone change anything?"</p>
</blockquote>
<p>Followed by ten people saying no.</p>
<p>Then three hours later someone remembers they changed the entire
sending configuration.</p>
<p>Document everything.</p>
<hr />
<h2 id="the-biggest-mistake-is-changing-too-many-variables">The Biggest
Mistake Is Changing Too Many Variables</h2>
<p>Suppose email is spamming.</p>
<p>You immediately do all of this.</p>
<ul>
<li>New IP</li>
<li>New domain</li>
<li>New tracking domain</li>
<li>New creative</li>
<li>New ESP</li>
<li>New segment</li>
<li>New sending rate</li>
</ul>
<p>Then it inboxes.</p>
<p>Congratulations.</p>
<p>You learned absolutely nothing.</p>
<p>You have no idea which change fixed the problem.</p>
<p>The next time it happens, you will have to replace everything
again.</p>
<p>Controlled testing creates knowledge.</p>
<p>Random rotation creates superstition.</p>
<hr />
<h2 id="a-practical-spam-diagnosis-workflow">A Practical Spam Diagnosis
Workflow</h2>
<p>When a campaign starts spamming, this is roughly how I think.</p>
<h3 id="step-1">Step 1</h3>
<p>Determine whether the messages are being accepted or rejected.</p>
<p>If rejected, read the SMTP response.</p>
<h3 id="step-2">Step 2</h3>
<p>Verify authentication.</p>
<p>SPF.</p>
<p>DKIM.</p>
<p>DMARC.</p>
<p>PTR.</p>
<p>TLS.</p>
<h3 id="step-3">Step 3</h3>
<p>Check the sending IP and relevant domains against important
blocklists.</p>
<h3 id="step-4">Step 4</h3>
<p>Send a plain-text message with no links and no tracking to controlled
accounts.</p>
<h3 id="step-5">Step 5</h3>
<p>Test the same domain through another known-good IP if available.</p>
<h3 id="step-6">Step 6</h3>
<p>Test another known-good domain through the original IP if
available.</p>
<h3 id="step-7">Step 7</h3>
<p>Test your strongest recipient segment.</p>
<h3 id="step-8">Step 8</h3>
<p>Introduce HTML.</p>
<h3 id="step-9">Step 9</h3>
<p>Introduce the destination URL.</p>
<h3 id="step-10">Step 10</h3>
<p>Introduce click tracking.</p>
<h3 id="step-11">Step 11</h3>
<p>Introduce the complete creative.</p>
<h3 id="step-12">Step 12</h3>
<p>Expand the recipient segment gradually.</p>
<p>You are progressively answering this question.</p>
<blockquote>
<p>"Which variable breaks inboxing?"</p>
</blockquote>
<p>That is deliverability troubleshooting.</p>
<hr />
<h2 id="my-reputation-recovery-priority-order">My Reputation Recovery
Priority Order</h2>
<p>When things go bad, I usually care about these in roughly this
order.</p>
<ol type="1">
<li>Recipient quality</li>
<li>Complaints</li>
<li>Authentication</li>
<li>Domain reputation</li>
<li>IP reputation</li>
<li>URLs and landing pages</li>
<li>Sending velocity</li>
<li>Blocklists</li>
<li>Tracking infrastructure</li>
<li>Message content</li>
<li>Platform footprint</li>
<li>Technical formatting</li>
</ol>
<p>The order can change dramatically based on evidence.</p>
<p>If Gmail is literally returning an error saying the IP is on an RBL,
investigate the IP first.</p>
<p>Use the evidence in front of you.</p>
<hr />
<h2
id="do-not-treat-spam-folder-placement-as-a-content-problem-automatically">Do
Not Treat Spam Folder Placement as a Content Problem Automatically</h2>
<p>This is another common mistake.</p>
<p>Someone sees spam placement.</p>
<p>Their first reaction is to rewrite the subject line.</p>
<p>Then they remove the word "free."</p>
<p>Then they change the button color.</p>
<p>Meanwhile the sending IP is on Spamhaus.</p>
<p>Or DKIM is broken.</p>
<p>Or the tracking domain is listed.</p>
<p>Or the last mailing generated a disastrous complaint rate.</p>
<p>Content testing has value.</p>
<p>But infrastructure problems need infrastructure fixes.</p>
<hr />
<h2 id="do-not-treat-every-problem-as-an-ip-problem-either">Do Not Treat
Every Problem as an IP Problem Either</h2>
<p>The opposite mistake is just as common.</p>
<p>Someone has a perfectly good IP.</p>
<p>Their domain reputation collapses.</p>
<p>They replace the IP.</p>
<p>The new IP works for a few hours or days.</p>
<p>Then placement collapses again.</p>
<p>They replace it again.</p>
<p>Eventually they have burned through an entire range.</p>
<p>The domain followed them everywhere.</p>
<p>Diagnose the identity that is actually causing the problem.</p>
<hr />
<h2 id="a-reputation-problem-can-live-outside-your-email-domain">A
Reputation Problem Can Live Outside Your Email Domain</h2>
<p>Remember the ecosystem.</p>
<p>You can send from this.</p>
<p><code>newsletter@example.com</code></p>
<p>Sign with this.</p>
<p><code>mail.example.com</code></p>
<p>Track through this.</p>
<p><code>click.otherdomain.com</code></p>
<p>Redirect through this.</p>
<p><code>affiliate-network.example</code></p>
<p>And finally land here.</p>
<p><code>landing-page.example</code></p>
<p>Which domain is "your domain reputation?"</p>
<p>All of them may be relevant in different ways.</p>
<p>This is why modern deliverability work requires looking beyond the
From address.</p>
<hr />
<h2 id="one-bad-shared-asset-can-contaminate-multiple-campaigns">One Bad
Shared Asset Can Contaminate Multiple Campaigns</h2>
<p>Suppose five brands use the same tracking domain.</p>
<p>Four have fantastic data.</p>
<p>One sends garbage.</p>
<p>The shared tracking identity can become a common reputation
dependency.</p>
<p>The same applies to shared IPs, bounce domains, redirect services,
and other infrastructure.</p>
<p>Shared infrastructure creates efficiency.</p>
<p>It also creates shared risk.</p>
<hr />
<h2
id="gmails-reputation-dashboard-going-away-might-actually-be-good-for-operators">Gmail's
Reputation Dashboard Going Away Might Actually Be Good for
Operators</h2>
<p>I understand why people are upset about losing it.</p>
<p>I liked it too.</p>
<p>But the dashboard encouraged bad habits.</p>
<p>People would look at "High" and conclude everything was fine even
when placement was deteriorating.</p>
<p>Or they would see "Low" and immediately replace infrastructure
without understanding why.</p>
<p>Google itself says the reputation labels could be misleading because
reputation is only one of many deliverability factors.</p>
<p>The better question was never this.</p>
<blockquote>
<p>"What color does Google say my reputation is?"</p>
</blockquote>
<p>The better question is this.</p>
<blockquote>
<p>"What behavior is Gmail observing from my traffic right now?"</p>
</blockquote>
<p>That is what you need to learn to measure.</p>
<hr />
<h2 id="the-real-reputation-dashboard-is-your-traffic">The Real
Reputation Dashboard Is Your Traffic</h2>
<p>Your best reputation dashboard consists of what your traffic is
telling you.</p>
<p>Are messages accepted?</p>
<p>Are they inboxing?</p>
<p>Are recipients opening?</p>
<p>Are humans clicking?</p>
<p>Are people replying?</p>
<p>Are they buying?</p>
<p>Are they unsubscribing?</p>
<p>Are they complaining?</p>
<p>Are addresses bouncing?</p>
<p>Is Gmail slowing you down?</p>
<p>Are you on meaningful blocklists?</p>
<p>Does plain text inbox?</p>
<p>Does the same domain work on another IP?</p>
<p>Does the same IP work with another domain?</p>
<p>Does removing the tracking domain solve the problem?</p>
<p>Those answers are far more actionable than "Medium."</p>
<hr />
<h2 id="final-thoughts">Final Thoughts</h2>
<p>Google can take away Domain Reputation.</p>
<p>Google can take away IP Reputation.</p>
<p>They can remove every colorful reputation graph from Postmaster Tools
tomorrow.</p>
<p>Your sender reputation will still exist.</p>
<p>Your domain will still have history.</p>
<p>Your IP will still have history.</p>
<p>Your links will still have history.</p>
<p>Your platform will still leave a footprint.</p>
<p>Your recipients will still vote on your mail through their
behavior.</p>
<p>Spamhaus will still evaluate domains and IPs.</p>
<p>Gmail will still throttle suspicious traffic.</p>
<p>Bad data will still destroy good infrastructure.</p>
<p>Good engagement will still protect it.</p>
<p>And no amount of SPF, DKIM, DMARC, IP rotation, domain rotation, or
creative rewriting can permanently compensate for sending email that
recipients consistently demonstrate they do not want.</p>
<p>That is the part of deliverability that has not changed.</p>
<p>The technology has become more complicated.</p>
<p>The visibility has become less convenient.</p>
<p>The reputation systems have become much better at connecting
different signals.</p>
<p>But the fundamental equation remains remarkably simple.</p>
<p><strong>Build a recognizable identity.</strong></p>
<p><strong>Send consistently.</strong></p>
<p><strong>Authenticate everything.</strong></p>
<p><strong>Use clean infrastructure.</strong></p>
<p><strong>Protect your domains.</strong></p>
<p><strong>Protect your IPs.</strong></p>
<p><strong>Know every link inside your messages.</strong></p>
<p><strong>Know where those links eventually go.</strong></p>
<p><strong>Understand your sending platform's footprint.</strong></p>
<p><strong>Watch your data quality.</strong></p>
<p><strong>Send your best traffic when reputation weakens.</strong></p>
<p><strong>Do not create fifty domains to solve a problem one good
domain could solve.</strong></p>
<p><strong>Do not rotate infrastructure just because you
can.</strong></p>
<p><strong>Test one variable at a time.</strong></p>
<p><strong>And above everything else, send email to people who
demonstrate that they actually want it.</strong></p>
<p>That is sender reputation.</p>
<p>You just might not get a graph showing it anymore.</p>]]></content:encoded>
    </item>
    
    <item>
      <title>Warming a domain on real bizop content: Postmaster crashed, delivery didn&#39;t, and the test still wasn&#39;t clean enough to be conclusive</title>
      <link>https://mattnovak.email/warming-a-domain-on-bizop-content/</link>
      <guid isPermaLink="true">https://mattnovak.email/warming-a-domain-on-bizop-content/</guid>
      <pubDate>Sun, 23 Aug 2026 07:04:23 +0000</pubDate>
      <dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Matt Novak</dc:creator>
      <category>deliverability</category><category>warmup</category><category>gmail</category><category>postmaster-tools</category><category>experiments</category>
      <description>Google Postmaster Tools graded a domain LOW → MEDIUM → LOW → BAD across three weeks. In the same window, our Gmail acceptance rate held above 99.9%. Here is what the data actually says, why it&#39;s more decoupled than you&#39;d expect, and why we still don&#39;t feel we&#39;ve earned a real answer.</description>
      <content:encoded><![CDATA[<h2>The hypothesis</h2>
<p>The received wisdom in cold-email warmup is that you throw
inbox-to-inbox babble at a fresh sender for a few weeks, let Gmail form
an opinion, and only <em>then</em> start sending real campaign content. The
babble is often template-generated, reply-heavy, and semantically empty.
It teaches Gmail that the sender is &quot;conversational&quot; without ever putting
real offer copy in front of the classifier.</p>
<p>We wanted to test something more direct. <strong>What happens if you skip the
babble and warm the domain with real business-opportunity (&quot;bizop&quot;) content
from day one?</strong> In principle, if we warm the classifier on the exact
content we intend to send in production, we would arrive at a healthy
reputation without ever having to swap in the &quot;real&quot; copy later.</p>
<p>We ran this test through <a href="https://postmasterops.com/">postmasterops.com</a>,
on a single Linode IP and a fresh sending domain. The domain is
anonymized in this writeup as <strong>the test domain</strong>.</p>
<hr />
<h2>The setup</h2>
<div class="table-scroll"><table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>MTA</strong></td>
<td>Postfix on a fresh Linode, single IP, single tenant</td>
</tr>
<tr>
<td><strong>Domain</strong></td>
<td>Fresh registration, no prior sending history, all DNS aligned (SPF pass, DKIM signed with a 2048-bit key, DMARC <code>p=none</code>, MX to our own store)</td>
</tr>
<tr>
<td><strong>Content</strong></td>
<td>Genuine bizop offers, remixed daily via LLM variant generation. Same voice a real customer would send.</td>
</tr>
<tr>
<td><strong>Tracking</strong></td>
<td>Every link wrapped through our own tracking domain: <code>https://track.&lt;domain&gt;/c/...</code> → 302 redirect → the actual offer URL</td>
</tr>
<tr>
<td><strong>Warmup service</strong></td>
<td>postmasterops.com managed the send ramp</td>
</tr>
<tr>
<td><strong>Volume</strong></td>
<td>Conservative — never above 1,700 Gmail-consumer sends per day</td>
</tr>
</tbody>
</table></div>
<p>The IP and domain were both new; there was no prior reputation to
inherit, good or bad.</p>
<hr />
<h2>Google Postmaster reputation trajectory</h2>
<p>Postmaster started reporting on the domain on <strong>07-27</strong> and gave us
data (with a few skipped days here and there) all the way through
today. Domain reputation, day by day:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Date</th>
<th>Google Postmaster domain reputation</th>
</tr>
</thead>
<tbody>
<tr>
<td>07-27 → 08-04</td>
<td>LOW (nine days, steady)</td>
</tr>
<tr>
<td><strong>08-05 → 08-08</strong></td>
<td><strong>MEDIUM</strong> (four days of the graph climbing)</td>
</tr>
<tr>
<td>08-10 → 08-13</td>
<td>LOW (four days back down)</td>
</tr>
<tr>
<td><strong>08-14 → today</strong></td>
<td><strong>BAD</strong> (stuck for over a week and counting)</td>
</tr>
</tbody>
</table></div>
<p><strong>IP reputation on the sending IP</strong> tracked the same curve exactly one
day behind the domain grade, ending at BAD from 08-14 onward.</p>
<p>The interesting part of the shape is that first climb. Under continuous
bizop content, the domain actually improved from LOW to MEDIUM and held
there for four days. Something about the sender identity was earning
trust, at least for a moment. Then it slid back down and eventually
collapsed.</p>
<hr />
<h2>Gmail acceptance during the same window</h2>
<p>Here is where it stops matching the story you'd tell if you looked at
Postmaster alone. Daily Gmail consumer sends / bounces / accept rate:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Date</th>
<th>Sent to Gmail</th>
<th>Bounced</th>
<th>Accept %</th>
</tr>
</thead>
<tbody>
<tr>
<td>07-29</td>
<td>1,456</td>
<td>1</td>
<td>99.9%</td>
</tr>
<tr>
<td>07-31</td>
<td>1,565</td>
<td>1</td>
<td>99.9%</td>
</tr>
<tr>
<td>08-02</td>
<td>1,588</td>
<td>2</td>
<td>99.9%</td>
</tr>
<tr>
<td>08-05</td>
<td>1,708</td>
<td>3</td>
<td>99.9%</td>
</tr>
<tr>
<td>08-08</td>
<td>1,541</td>
<td>2</td>
<td>99.9%</td>
</tr>
<tr>
<td><strong>08-11</strong></td>
<td>1,645</td>
<td><strong>453</strong></td>
<td><strong>78.4%</strong></td>
</tr>
<tr>
<td>08-12</td>
<td>1,554</td>
<td>15</td>
<td>99.9%</td>
</tr>
<tr>
<td><strong>08-14</strong></td>
<td>1,548</td>
<td>23</td>
<td>99.9% <em>(same day Postmaster went BAD)</em></td>
</tr>
<tr>
<td>08-17</td>
<td>1,525</td>
<td>23</td>
<td>99.9%</td>
</tr>
<tr>
<td>08-20</td>
<td>8,035</td>
<td>23</td>
<td>100.0%</td>
</tr>
<tr>
<td>08-22 (today)</td>
<td>1,111</td>
<td>12</td>
<td>100.0%</td>
</tr>
</tbody>
</table></div>
<p><strong>Acceptance held above 99.8% for almost the entire window,</strong> with one
bad day (08-11: 78.4%, 453 bounces) that looked like it might be the
start of something and then wasn't. Even after Postmaster graded the
domain BAD on 08-14, individual message acceptance stayed at 99.9%. As
of today the domain is at 100% accept on ~1,100 sends per day.</p>
<p>Whatever Postmaster's grading model is scoring, <strong>it is not directly
gating delivery on this domain.</strong> The classifier that decides whether
to accept the SMTP transaction is clearly running on different signals
than the classifier that assigns the reputation grade you see in
Postmaster Tools. In this test they moved in opposite directions for
weeks.</p>
<hr />
<h2>The confounders (there are three of them, and they matter enough to make this test inconclusive)</h2>
<p>I want to be honest before drawing any conclusions, because there are
three variables that moved at roughly the same time as the Postmaster
collapse. Any of them could have contributed. Probably all of them did,
which is why we don't feel we've earned a real answer here.</p>
<h3>Confounder 1 — the final URL landed on Spamhaus mid-test</h3>
<p>Our tracking chain was:</p>
<pre><code>email body
    &lt;a href=&quot;https://track.&lt;domain&gt;/c/abc123&quot;&gt;Learn more&lt;/a&gt;
        302 →  the affiliate/offer landing page
</code></pre>
<p>The tracking domain itself was clean. It was on our infrastructure,
wrapped through nginx, TLS-terminated, no listing exposure. <strong>But the
final offer URL — the destination after the 302 redirect — got listed
on Spamhaus during the test.</strong></p>
<p>Gmail's content classifier is well-documented to follow redirects when
scoring messages for reputation. So even though the <em>visible</em> href in
the message body was our clean tracking domain, the classifier followed
the chain and saw the Spamhaus-listed destination. That is almost
certainly counting against us. The question is how much.</p>
<p>We do not have a clean way to isolate this from the content signal.
When the Postmaster collapse happened, the URL was on Spamhaus AND the
content was pure bizop. Those two variables moved together, and we
cannot pull them apart from this run.</p>
<h3>Confounder 2 — one day of about 1,000 4xx errors from Gmail</h3>
<p>Separately, on one specific day during the test, we saw a burst of
roughly 1,000 4xx (transient) errors from Gmail's MX, fleet-wide, not
just this domain. This was almost certainly a Gmail-side issue: the
errors cleared by the next day and every other domain sending at the
same time saw comparable rejections.</p>
<p>But 4xx retries are still visible to Gmail's reputation model. On a
domain that was already trending down, adding 1,000 transient failures
across a single day accelerates the slide. This one is fully outside
our control. It is in the frame anyway.</p>
<h3>Confounder 3 — the reputation kept getting worse <em>after</em> the content stopped</h3>
<p>This is the one I cannot fully explain. Under a &quot;content is the sole
driver&quot; hypothesis, killing the bad content should let the reputation
stabilize and eventually recover. <strong>Ours got worse in the days after
we stopped sending the offending creatives.</strong> That does not fit.</p>
<p>It might indicate that Gmail had already committed to a reputation
classification and was carrying it forward regardless of what we sent
next. It might mean the Spamhaus listing was the dominant signal all
along, and cleaning up the <em>content</em> didn't matter because the
<em>destination</em> was still poisoning every message that included a link.
It might be that Postmaster's grade lags actual signal by so much that
we're still seeing the tail of the earlier damage. We do not have a
clean way to disaggregate.</p>
<hr />
<h2>What the data unambiguously shows</h2>
<p>There are two things we can say with confidence:</p>
<ol>
<li>
<p><strong>The Postmaster grade and the delivery gate are decoupled.</strong> For
almost the entire window Postmaster was flashing red and Gmail was
still accepting 99.9% of messages. Whatever Postmaster is measuring
(spam-complaint ratio? aggregate content classifier signal? user
engagement decay?), it is not the same thing that the SMTP-accept
classifier is measuring. If you optimize for one, you are not
automatically optimizing for the other.</p>
</li>
<li>
<p><strong>The domain earned a genuine Postmaster grade improvement on bizop
content.</strong> LOW to MEDIUM over four days of continuous real-content
sending is a positive signal. Not something the &quot;content is toxic
until you've banked reputation&quot; narrative would predict. Something
about the sender identity was accruing trust, at least for a while.</p>
</li>
</ol>
<h2>What we cannot say</h2>
<p>We cannot say whether the eventual Postmaster collapse was driven by:</p>
<ul>
<li>The content itself</li>
<li>The Spamhaus-listed final URL in the tracking chain</li>
<li>The 4xx storm</li>
<li>Some ordering interaction between all three</li>
<li>Something else we didn't observe</li>
</ul>
<p><strong>The Spamhaus confounder alone would be enough to void this test as
evidence about bizop content warming.</strong> The next run has to isolate the
URL variable: same content strategy, tracking chain that terminates on
a URL with no Spamhaus history and no chance of one appearing mid-test.</p>
<hr />
<h2>What we're doing next</h2>
<p>Two things.</p>
<p><strong>The immediate operational move</strong>: we are back to babble-first warmup
for every new managed sender on <a href="https://postmasterops.com/">postmasterops.com</a>.
Not because this test proved babble is better — it did not prove that — but
because babble isolates every one of the variables that made this test
unreadable. No tracking chain, no offer URL, no content-classification
weight, no possibility of a mid-test Spamhaus event on a page we don't
control. When we come back to test bizop-first warming, we will do it
with those variables held constant.</p>
<p><strong>The next writeup</strong>: the babble pattern that works, the specific
inbox-to-inbox behaviors we simulate, and the operational signals we
use to catch a reputation slide before it turns into a collapse.
Postmaster grade alone is not one of them, for the reason this test
made obvious.</p>
<hr />
<p><em>The test domain is still sending, still at 100% accept, still graded
BAD in Postmaster. It is a good live example of the disconnect. If you
want the short version of the lesson from this run: your delivery
number and your Postmaster grade are not the same signal, and you
should not stop watching either one just because the other one looks
green.</em></p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Hyprland on the CHUWI MiniBook X (N150, 12 GB)</title>
      <link>https://mattnovak.email/hyprland-on-the-chuwi-minibook-x/</link>
      <guid isPermaLink="true">https://mattnovak.email/hyprland-on-the-chuwi-minibook-x/</guid>
      <pubDate>Sat, 22 Aug 2026 22:39:00 +0000</pubDate>
      <dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Matt Novak</dc:creator>
      <category>linux</category><category>hyprland</category><category>wayland</category><category>hidpi</category><category>debugging</category>
      <description>A complete Wayland desktop for a 10.5-inch convertible: Debian 13 &#34;trixie&#34;, Hyprland 0.55.2, 216 DPI, and a long evening of things failing quietly.</description>
      <content:encoded><![CDATA[<blockquote>
<p><strong>The code.</strong> Every config file described here lives in one repo, ready to
clone: <a href="https://github.com/mattnovakemail/minibook-x-hyprland">github.com/mattnovakemail/minibook-x-hyprland</a>.</p>
</blockquote>
<h2>The machine</h2>
<p>This writeup is specific to one laptop. Most of what follows is driven by its
particular quirks — a rotated DSI panel, an extremely dense screen, a 6 W CPU,
and firmware that blocks hibernation — so here it is in full:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Model</strong></td>
<td>CHUWI MiniBook X (BIOS <code>DNN20 V2.50</code>)</td>
</tr>
<tr>
<td><strong>Form factor</strong></td>
<td>10.5&quot; convertible, 360° hinge, touchscreen</td>
</tr>
<tr>
<td><strong>CPU</strong></td>
<td>Intel N150 (Alder Lake-N) — 4 cores / 4 threads, 700 MHz–3.6 GHz, 6 MiB L3, 6 W</td>
</tr>
<tr>
<td><strong>RAM</strong></td>
<td><strong>12 GB LPDDR5-6400</strong>, soldered — 4 × 3 GB rows across two channels</td>
</tr>
<tr>
<td><strong>GPU</strong></td>
<td>Intel Graphics (Alder Lake-N), <code>8086:46d4</code></td>
</tr>
<tr>
<td><strong>Panel</strong></td>
<td>1200×1920 DSI, mounted rotated → 1920×1200 landscape via <code>transform 3</code></td>
</tr>
<tr>
<td><strong>Density</strong></td>
<td>141 × 226 mm → 10.5&quot; diagonal → <strong>~216 DPI</strong></td>
</tr>
<tr>
<td><strong>Touch</strong></td>
<td>Goodix capacitive touchscreen (<code>GDIX1002</code>) over i2c</td>
</tr>
<tr>
<td><strong>Wi-Fi</strong></td>
<td>Intel CNVi <code>8086:54f0</code></td>
</tr>
<tr>
<td><strong>Audio</strong></td>
<td>Alder Lake-N PCH HD Audio <code>8086:54c8</code></td>
</tr>
<tr>
<td><strong>Storage</strong></td>
<td>476 GB NVMe → LUKS → LVM (463 GB root, 11.7 GB swap)</td>
</tr>
<tr>
<td><strong>Battery</strong></td>
<td>3800 mAh @ 7.6 V nominal, reporting 100% health</td>
</tr>
<tr>
<td><strong>Firmware</strong></td>
<td>Secure Boot <strong>enabled</strong>, kernel lockdown at <code>integrity</code></td>
</tr>
</tbody>
</table></div>
<p>Two details from that table cause more trouble than the rest combined.</p>
<p><strong>The panel is 2.25× denser than a normal display.</strong> A conventional desktop
monitor is around 96 DPI; this one is 216. Anything rendered 1:1 comes out
roughly half the size it should be. That single number explains the unreadable
Firefox, the 24-point terminal font, and an entire section of scaling work
below.</p>
<p><strong>The CPU is a 6 W part.</strong> The N150 is genuinely capable for its class, but it
has no thermal headroom to waste. That drives the choice of hardware video
encoding, the power tuning, and the decision to shell into a bigger machine for
compilation rather than build locally.</p>
<p>A note on the RAM figure, since it looks inconsistent: <code>dmidecode</code> reports
4 × 3 GB = <strong>12 GB installed</strong>, while <code>/proc/meminfo</code> shows <code>MemTotal: 11986544 kB</code> ≈ 11.4 GiB. The difference is firmware reservation — normal, and
not a fault. It's a 12 GB machine.</p>
<hr />
<h2>The starting point</h2>
<p>Debian 13 was installed — deliberately minimal. At the installer's software
selection step only <strong>standard system utilities</strong> and <strong>SSH server</strong> were
ticked; every desktop task was left unchecked. The disk was set up with the
installer's <strong>guided encrypted LVM</strong> option, so root and swap both sit inside
LUKS. Hyprland had been installed from backports, and that was about it.</p>
<p>The entire Hyprland config was thirteen lines. This is the <em>starting</em> state —
the finished version is reproduced in full at the end of this post:</p>
<pre><code class="language-ini">monitor = DSI-1, preferred, auto, 1, transform, 3

$mod = SUPER

exec-once = waybar
exec-once = mako

input {
	kb_layout = us

	touchpad {
		natural_scroll = true
		tap-to-click = true
	}
}

general {
	gaps_in = 3
	gaps_out = 5
	border_size = 2
}

decoration {
	rounding = 6
}

bind = $mod, Return, exec, foot
</code></pre>
<p>One keybinding. You could open a terminal. You could not close a window, move
one, switch workspaces, take a screenshot, lock the screen, or quit the
compositor. Waybar was listed in <code>exec-once</code> but had no config file at all.</p>
<p>The goal was a complete daily-driver desktop, tuned for battery life, on a
machine intended mostly for SSH-ing into a beefier build box, taking
screenshots, and light development.</p>
<p>What follows is everything that got built, and — more usefully — everything
that broke along the way. The interesting through-line turned out not to be
the configuration. It was this:</p>
<blockquote>
<p><strong>Almost every bug in this stack failed silently.</strong> Not one of them printed
an error where anyone would look. A keybinding that did nothing. A stylesheet
that vanished. Icons that rendered as blank space. A wallpaper daemon that
logged one cryptic line and gave up. Precision that was invented out of thin
air. The work wasn't writing config — it was noticing the absence of things.</p>
</blockquote>
<hr />
<h2>Where it started, in numbers</h2>
<p>Facts gathered rather than assumed, before changing anything:</p>
<ul>
<li>CPU governor: <code>powersave</code> on the <code>intel_pstate</code> driver, EPP at
<code>balance_performance</code></li>
<li>Idle draw: <strong>3.68 W</strong> (0.5 A × 7.36 V), projecting ~7.6 h from full</li>
<li>Sleep states offered: <code>freeze mem</code> — and, unusually for a recent Intel
laptop, <code>mem_sleep</code> offering both <code>[s2idle]</code> <strong>and</strong> <code>deep</code>. Many Alder Lake-N
machines only expose s2idle; this one still has working S3, which turns out
to matter a lot</li>
<li>Nerd Fonts installed: <strong>zero</strong></li>
<li>Display manager: none</li>
<li>Wi-Fi: a hand-started <code>wpa_supplicant</code> plus <code>dhcpcd</code>, no NetworkManager</li>
</ul>
<hr />
<h2>Part 1 — The compositor, and a version that moved underneath us</h2>
<p>Hyprland 0.55.2 is recent enough that a large amount of community
documentation is simply wrong for it. Three concrete breakages surfaced within
minutes of writing a normal-looking config.</p>
<h3>Window rules changed shape entirely</h3>
<p>The syntax everyone writes:</p>
<pre><code class="language-ini">windowrule = float, class:^(pavucontrol)$
</code></pre>
<p>produces, in 0.55:</p>
<pre><code>Config error: invalid field float: missing a value
</code></pre>
<p>The rule system was rebuilt around explicit key/value fields with a <code>match:</code>
prefix for the selector. The working form is:</p>
<pre><code class="language-ini">windowrule = float = true, match:class = ^(pavucontrol)$
windowrule = suppress_event = maximize, match:class = .*
windowrule = size = 60% 55%, match:class = ^(foot-float)$
</code></pre>
<p>I found this by probing the live compositor with <code>hyprctl keyword windowrule</code>
until something returned <code>ok</code>, rather than guessing. The error messages were
genuinely helpful here — <code>float = true, class = ...</code> complained about <code>class</code>
specifically, which pointed straight at needing <code>match:class</code>.</p>
<h3><code>togglesplit</code> no longer exists</h3>
<pre><code>Invalid dispatcher, requested &quot;togglesplit&quot; does not exist
</code></pre>
<p>It moved into the layout message system:</p>
<pre><code class="language-ini">bind = $mod, Y, layoutmsg, togglesplit
</code></pre>
<p>Similarly, <code>dwindle:pseudotile</code> is gone as a config option (the <code>pseudo</code>
dispatcher still exists), and <code>misc:vfr</code> moved to <code>debug:vfr</code>.</p>
<h3>Gestures were rewritten</h3>
<p>The familiar <code>gestures { workspace_swipe = true }</code> block returns
<code>no such option</code>. 0.51 replaced it with a top-level keyword:</p>
<pre><code class="language-ini">gesture = 3, horizontal, workspace
gesture = 4, up, special, magic
</code></pre>
<h3>The one that parses fine and does nothing</h3>
<p>This is the first silent failure of the evening.</p>
<pre><code class="language-ini">workspace = workspace = 1, monitor = DSI-1, persistent = true
</code></pre>
<p><code>hyprctl configerrors</code> returns empty. The rule is accepted. The workspace is
never created. I tried every syntax variant I could construct — <code>match:id</code>,
bare <code>1,</code>, with and without a monitor — and eventually spun up a <strong>nested
Hyprland instance</strong> with a throwaway config to test in isolation. None of them
work in 0.55.2.</p>
<p>Digging into the binary's strings revealed the internal function
<code>ensurePersistentWorkspacesPresent</code> and its failure mode
(<code>couldn't resolve monitor for {}, skipping</code>), which suggested the rule needs a
monitor — but supplying one changed nothing.</p>
<p>So I deleted the dead configuration rather than leave four lines that look
meaningful and aren't. Waybar's own <code>persistent-workspaces</code> setting displays
workspaces 1–5 permanently regardless, which is the actual user-visible
behaviour anyone wants from that feature.</p>
<p><strong>Lesson:</strong> &quot;it parses&quot; and &quot;it works&quot; are unrelated claims. Verify the effect,
not the syntax.</p>
<h3>What got built</h3>
<p>Roughly 80 keybindings, in themed groups:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>Keys</th>
<th>Function</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Super+Q</code></td>
<td>close window</td>
</tr>
<tr>
<td><code>Super+F</code> / <code>Super+Shift+F</code> / <code>Super+Ctrl+F</code></td>
<td>fullscreen / maximize / fake-fullscreen</td>
</tr>
<tr>
<td><code>Super+hjkl</code> + arrows</td>
<td>move focus</td>
</tr>
<tr>
<td><code>Super+Shift+hjkl</code></td>
<td>move the window</td>
</tr>
<tr>
<td><code>Super+Alt+hjkl</code></td>
<td>resize (key-repeating via <code>binde</code>)</td>
</tr>
<tr>
<td><code>Super</code> + left/right drag</td>
<td>move / resize with the mouse (<code>bindm</code>)</td>
</tr>
<tr>
<td><code>Super+1..5</code></td>
<td>five workspaces</td>
</tr>
<tr>
<td><code>Super+Shift+1..5</code> / <code>Super+Ctrl+1..5</code></td>
<td>send window there, following / staying</td>
</tr>
<tr>
<td><code>Super+Tab</code>, <code>Super</code>+scroll, 3-finger swipe</td>
<td>cycle workspaces</td>
</tr>
<tr>
<td><code>Super+S</code> / <code>Super+Alt+S</code></td>
<td>scratchpad toggle / send to scratchpad</td>
</tr>
</tbody>
</table></div>
<p>Plus hardware keys (volume, brightness, mic mute) via <code>wpctl</code> and
<code>brightnessctl</code>, and a lid switch binding.</p>
<p>One small thing that mattered on a touchscreen device: the panel is rotated
<code>transform 3</code>, but the touchscreen input had no matching transform, so taps
landed in the wrong place. Wayland doesn't infer this:</p>
<pre><code class="language-ini">input {
    touchdevice {
        transform = 3
    }
}
</code></pre>
<hr />
<h2>Part 2 — The bar, and a font that wasn't there</h2>
<p>Waybar was in <code>exec-once</code> but had <strong>no config whatsoever</strong>, so it was running
with compiled-in defaults. Building a real one was straightforward: workspaces,
window title, wireplumber volume, backlight, network, battery, clock, tray,
all in a Tokyo Night palette.</p>
<p>Then every icon rendered as blank space.</p>
<p>The cause is a naming trap. Debian's <code>fonts-font-awesome</code> package installs a
font whose family name is literally <strong><code>FontAwesome</code></strong> — the legacy FA4 family —
not <code>Font Awesome 6 Free</code>, which is what every modern config snippet lists. The
CSS font stack silently matched nothing and fell through to a font with no
glyphs at those codepoints.</p>
<p>Worse, the glyphs I'd chosen weren't all <em>in</em> FA4. <code>U+F5DE</code> (a brightness icon
in newer sets) simply doesn't exist there. So I checked coverage directly
before picking anything:</p>
<pre><code class="language-bash">fc-list &quot;:charset=f185&quot; family    # → FontAwesome
</code></pre>
<p>and rebuilt the icon set from codepoints verified present. The final stack:</p>
<pre><code class="language-css">font-family: &quot;JetBrains Mono&quot;, &quot;FontAwesome&quot;, monospace;
</code></pre>
<h3>The glyphs that vanished in transit</h3>
<p>A subtler problem, and one that recurred all evening: <strong>three-byte
private-use-area characters get stripped</strong> when written through certain
tooling. Codepoints like <code>U+E0B0</code> (the powerline separator) and <code>U+E0A0</code> (the
git branch symbol) arrived at the file as empty strings, while four-byte
supplementary-plane glyphs like <code>U+F0509</code> survived intact.</p>
<p>The result is config that looks correct and renders nothing:</p>
<pre><code class="language-ini">[](fg:iris)      # ← there is supposed to be a glyph inside those brackets
</code></pre>
<p>The fix is to inject them programmatically rather than typing them:</p>
<pre><code class="language-python">LCAP = chr(0xe0b6)   #  rounded left cap
s = s.replace('[](fg:iris)', '[%s](fg:iris)' % LCAP)
</code></pre>
<p>From that point on, every glyph in this project was written with <code>chr()</code> and
then <strong>audited by reading the file back</strong> and checking that the symbol fields
were non-empty. That audit caught two more empty symbols later in the starship
config that would otherwise have shipped as blank space.</p>
<hr />
<h2>Part 3 — Screenshots</h2>
<p>A small script, <code>~/.config/scripts/screenshot</code>, wrapping <code>grim</code> and <code>slurp</code>:</p>
<ul>
<li>always copies to the clipboard <strong>and</strong> saves to <code>~/Pictures/Screenshots</code></li>
<li><code>region</code> uses <code>slurp -d</code>; pressing Escape exits silently rather than erroring</li>
<li><code>window</code> reads the focused window's geometry from <code>hyprctl activewindow -j</code>
via <code>jq</code></li>
<li><code>--edit</code> opens the capture in <code>swappy</code> for cropping, arrows and redaction
before saving</li>
</ul>
<p>Bound to <code>Super+Shift+S</code> (region), <code>Print</code> (screen), <code>Shift+Print</code> (window),
and <code>Super+Ctrl+Shift+S</code> (region with annotation).</p>
<p>Note the window-geometry line, which is the sort of thing that's fragile if you
write it with <code>grep</code> and <code>awk</code>:</p>
<pre><code class="language-bash">geom=$(hyprctl activewindow -j | jq -r '&quot;\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])&quot;')
</code></pre>
<hr />
<h2>Part 4 — Lock, idle, wallpaper, clipboard — and more version drift</h2>
<p>Four packages: <code>hyprlock</code>, <code>hypridle</code>, <code>hyprpaper</code>, <code>cliphist</code>. Two of them
had moved their config format, and both failed quietly.</p>
<h3>hyprpaper 0.8.4 removed <code>preload</code> entirely</h3>
<p>The universally documented form:</p>
<pre><code class="language-ini">preload = ~/Pictures/wallpaper.png
wallpaper = DSI-1, ~/Pictures/wallpaper.png
</code></pre>
<p>produces no error, no warning, and exactly one line of log:</p>
<pre><code>Monitor DSI-1 has no target: no wp will be created
</code></pre>
<p>No wallpaper appears. Searching the binary for strings showed <code>preload</code> was
absent from it altogether, while <code>monitor</code>, <code>path</code>, <code>fit_mode</code> and <code>order</code>
were present — implying a block-structured format. The working config:</p>
<pre><code class="language-ini">splash = false

wallpaper {
	monitor = DSI-1
	path = /home/YOUR-USER/Pictures/wallpaper.png
	fit_mode = cover
}
</code></pre>
<p>Also: <code>~</code> is not expanded, so paths must be absolute.</p>
<h3>hyprlock moved <code>grace</code> out of <code>general</code></h3>
<pre><code>Config error: config option &lt;general:grace&gt; does not exist
</code></pre>
<p>In 0.9.5 it's a top-level key. I found this by testing candidate placements
against a throwaway config using <code>hyprlock --display no-such-display</code>, which
parses the file and then fails to connect — a config check that never locks
your screen. That trick was worth its weight later.</p>
<h3>There was no wallpaper at all</h3>
<p>No image existed on the machine, and neither ImageMagick nor Python's PIL was
installed. Rather than pull in a dependency, I generated a 1920×1200 Tokyo
Night gradient by writing the PNG by hand — zlib-compressed scanlines, CRC'd
chunks, ordered dithering to prevent banding on an 8-bit gradient:</p>
<pre><code class="language-python">def chunk(tag, data):
    return (struct.pack('&gt;I', len(data)) + tag + data
            + struct.pack('&gt;I', zlib.crc32(tag + data) &amp; 0xffffffff))

png = (b'\x89PNG\r\n\x1a\n'
       + chunk(b'IHDR', struct.pack('&gt;IIBBBBB', W, H, 8, 2, 0, 0, 0))
       + chunk(b'IDAT', zlib.compress(bytes(rows), 9))
       + chunk(b'IEND', b''))
</code></pre>
<p>118 KB, no dependencies, and it became the basis for the lock screen and
greeter backgrounds too.</p>
<h3>Verifying the lock screen safely</h3>
<p>Before letting anything auto-lock the machine, two checks:</p>
<ol>
<li><code>/etc/pam.d/hyprlock</code> exists and includes the <code>login</code> stack — if PAM were
misconfigured, the first idle lock would strand the user permanently.</li>
<li>A deliberate, timed test-lock to confirm it renders and releases.</li>
</ol>
<p>Both passed. That second check is also where this post's biggest mistake
originates, but that comes later.</p>
<hr />
<h2>Part 5 — The idle ladder incident</h2>
<p>This is the most instructive failure of the evening, because nothing was
broken. Everything worked exactly as configured. The configuration was just
wrong for a human being.</p>
<p>The initial <code>hypridle</code> ladder:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>idle</th>
<th>action</th>
</tr>
</thead>
<tbody>
<tr>
<td>4 min</td>
<td><code>brightnessctl -s set 10%</code></td>
</tr>
<tr>
<td>8 min</td>
<td>lock</td>
</tr>
<tr>
<td>10 min</td>
<td>screen off</td>
</tr>
<tr>
<td>30 min</td>
<td><code>systemctl suspend</code></td>
</tr>
</tbody>
</table></div>
<p>I started it live and moved on without saying the timers were now armed.</p>
<p>Four minutes later, while reading my own summary on screen, the backlight
dropped to 10%. On this panel that reads as a <strong>dead screen</strong>, not a dim one.
The reasonable response to a laptop that appears to have died is to switch to a
different TTY — which is exactly what happened: <code>Ctrl+Alt+F2</code>, log in, start a
second Hyprland session.</p>
<p>The consequences compounded:</p>
<ol>
<li><strong>Two live sessions.</strong> The abandoned tty1 session kept running, along with
its waybar, mako, hyprpaper — and its <code>hypridle</code>.</li>
<li><strong>An abandoned session is permanently idle.</strong> Its 30-minute
<code>systemctl suspend</code> timer was still counting, and <code>systemctl suspend</code>
suspends <em>the whole machine</em>, not a session. It would have fired while the
user was working in the new session.</li>
<li><strong>Two hypridle daemons</strong> were now driving global hardware — one backlight,
one DPMS state, one suspend command — from two different idle clocks.</li>
</ol>
<p>I also discovered I'd left <strong>two orphaned nested Hyprland instances</strong> running
from the persistent-workspace experiment; <code>timeout 12</code> had sent SIGTERM and
they'd survived it, running for seventeen minutes.</p>
<h3>The fixes</h3>
<p>The immediate hazards were killed with about four minutes to spare before the
next session's dim/lock cycle. Then the policy changed:</p>
<pre><code class="language-ini"># 10 min -- dim, but stay clearly readable. Any input restores.
listener {
	timeout = 600
	on-timeout = brightnessctl -s set 30%
	on-resume = brightnessctl -r
}

# 20 min -- screen off
listener {
	timeout = 1200
	on-timeout = hyprctl dispatch dpms off
	on-resume = hyprctl dispatch dpms on
}

# No auto-lock and no auto-suspend. Lock manually with Super+Escape.
</code></pre>
<p>30% instead of 10% — dim, never dead-looking. Ten minutes instead of four. No
auto-suspend at all.</p>
<p>And a structural fix so this class of problem can't recur:</p>
<pre><code class="language-ini">exec-once = pgrep -x hypridle &gt;/dev/null || hypridle    # singleton: global hardware
</code></pre>
<p><code>hypridle</code> touches global hardware, so it must be a singleton across sessions.
Waybar and hyprpaper are legitimately per-session; hypridle is not.</p>
<p><strong>Lessons:</strong></p>
<ul>
<li>Never arm timers on someone's live machine without telling them the clock is
running.</li>
<li>A dim that looks like failure <em>is</em> a failure, regardless of intent.</li>
<li>Daemons that control global hardware need singleton guards, because sessions
can and do stack up.</li>
<li><code>timeout</code> sending SIGTERM does not guarantee a process dies. Verify.</li>
</ul>
<hr />
<h2>Part 6 — The binding that did nothing</h2>
<p><code>Super+Shift+S</code> produced no selector, no error, no notification. Nothing.</p>
<p>The binding was registered correctly — <code>hyprctl binds</code> showed
<code>mods=65 key=S -&gt; exec screenshot region</code>. The script existed and was
executable. Running it by hand worked perfectly.</p>
<p>The environment Hyprland's <code>exec</code> inherits is not your login shell's:</p>
<pre><code>PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
</code></pre>
<p>No <code>~/.local/bin</code>. The shell couldn't resolve <code>screenshot</code>, and the exec failed
into the void — Hyprland doesn't surface a failed <code>exec</code> anywhere visible.</p>
<p>Every earlier test of mine had passed because I'd been exporting
<code>PATH=&quot;$HOME/.local/bin:$PATH&quot;</code> in my own shell before running things. I had
never once tested the environment the <em>binding</em> runs in.</p>
<p>The fix, belt and braces:</p>
<pre><code class="language-ini">env = PATH,/home/YOUR-USER/.config/scripts:/home/YOUR-USER/.local/bin:/usr/local/bin:/usr/bin:/bin:...
$scripts = /home/YOUR-USER/.config/scripts
bind = $mod SHIFT, S, exec, $scripts/screenshot region
</code></pre>
<p>Both the <code>env</code> line <em>and</em> absolute paths, so the bindings work even if the env
line is ever lost. Verified by making Hyprland itself report its environment:</p>
<pre><code class="language-bash">hyprctl dispatch exec &quot;sh -c 'echo PATH=$PATH &gt; /tmp/probe.txt'&quot;
</code></pre>
<p><strong>Lesson:</strong> test in the environment the code will actually run in, not the one
that's convenient.</p>
<hr />
<h2>Part 7 — HiDPI: 216 DPI and the scale that fixes everything</h2>
<p>Firefox launched with unreadably small UI. The instinct is to hunt for a
Firefox font setting. That would have been wrong — the same problem afflicted
every GTK app, every dialog, every file picker, because it isn't a font problem
at all.</p>
<p>At 216 DPI with no scaling, everything renders at roughly half its intended
physical size. That's also why the terminal font had been set to 24pt: at 216
DPI, 24pt lands at a <em>normal-looking</em> ~10.7pt physically.</p>
<p>The fix is compositor scaling:</p>
<pre><code class="language-ini">monitor = DSI-1, preferred, auto, 1.5, transform, 3
</code></pre>
<p>Scale 1.5 was chosen deliberately over 1.25, 1.75 and 2.0 because
<strong>1920/1.5 = 1280 and 1200/1.5 = 800 are both exact integers</strong>. Fractional
scales that don't divide evenly (1.75 gives 1097.14 × 685.71) force resampling
and soften text. Effective density becomes ~144 DPI.</p>
<p>Then everything already tuned for the unscaled screen had to be rebalanced so
it stayed the same <em>physical</em> size:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th></th>
<th>before</th>
<th>after</th>
<th>physical result</th>
</tr>
</thead>
<tbody>
<tr>
<td>foot font</td>
<td>24pt @ 1.0</td>
<td>16pt @ 1.5</td>
<td>unchanged</td>
</tr>
<tr>
<td>waybar height</td>
<td>42px @ 1.0</td>
<td>28px @ 1.5</td>
<td>unchanged</td>
</tr>
<tr>
<td>waybar font</td>
<td>20px @ 1.0</td>
<td>13px @ 1.5</td>
<td>unchanged</td>
</tr>
</tbody>
</table></div>
<p>Both running apps reported <code>xwayland=false</code>, so they scale natively rather than
going through XWayland's blurry path.</p>
<h3>The 25-second bar</h3>
<p>After the reboot that followed, waybar took <strong>25 seconds</strong> to appear. Its log:</p>
<pre><code>[info] Unable to receive desktop appearance: Timeout was reached
</code></pre>
<p>Waybar asks the XDG desktop portal for the light/dark preference at startup and
waits out a full D-Bus timeout when nobody answers.</p>
<p>Nobody answered because <code>xdg-desktop-portal-gtk</code> had <strong>failed to start</strong>, and
it had failed because the systemd user manager outlives individual login
sessions and was still advertising <code>WAYLAND_DISPLAY=wayland-1</code> — the display of
the session that got abandoned during the idle incident. The GTK portal started,
couldn't reach a display that no longer existed, and exited 1.</p>
<pre><code class="language-ini">exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE HYPRLAND_INSTANCE_SIGNATURE
exec-once = systemctl --user restart xdg-desktop-portal-gtk.service
</code></pre>
<p>Startup went from 25 seconds to <strong>1 second</strong>. The same fix repairs file-picker
dialogs and screen sharing, which route through the identical portal and were
broken in exactly the same way.</p>
<hr />
<h2>Part 8 — Battery</h2>
<p>Baseline: 3.68 W idle, projecting ~7.6 hours from full. Decent for an N150 —
but several cheap wins were sitting unclaimed.</p>
<p><strong>Energy performance preference.</strong> <code>intel_pstate</code> was at
<code>balance_performance</code>. A systemd oneshot pins it:</p>
<pre><code class="language-ini">ExecStart=/bin/sh -c 'for f in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo power &gt; &quot;$f&quot;; done'
</code></pre>
<p><strong>Bluetooth was running with zero paired devices</strong> — a daemon and a radio
powered for nothing. Disabled.</p>
<p><strong>thermald</strong> wasn't installed. On a 6 W part, Intel's thermal daemon is worth
having.</p>
<p><strong>powertop</strong> <em>was</em> installed but nothing ran <code>--auto-tune</code> at boot. Now a
oneshot unit does, and I verified afterwards that the touchpad, keyboards and
touchscreen were all still enumerated — powertop's aggressive runtime PM has a
reputation for breaking input devices.</p>
<p><strong>Wi-Fi power saving</strong> was off; a unit enables it via <code>iw</code>.</p>
<p>Result: 3.68 W → <strong>3.57 W</strong>, plus a much lower-power sleep state (below).</p>
<hr />
<h2>Part 9 — The network, and the worst mistake of the evening</h2>
<p>The machine's Wi-Fi was managed by a hand-started <code>wpa_supplicant</code> plus a
hand-started <code>dhcpcd</code> — no NetworkManager, no <code>nmtui</code>, no roaming, and the
waybar network module's click action pointed at <code>nmtui</code>, which wasn't
installed. Another silent no-op.</p>
<p>I decided to migrate to NetworkManager, and got two things badly wrong.</p>
<h3>Mistake one: reading a permission error as an empty file</h3>
<pre><code class="language-bash">cat /etc/network/interfaces    # printed nothing
</code></pre>
<p>I concluded the file was empty and announced that the Wi-Fi config wasn't
persistent and wouldn't survive a reboot. In fact the file is <strong>363 bytes, mode
0600</strong> — the <code>cat</code> had failed with permission denied, and I'd read the empty
output as an empty file. The user's reboot disproved me directly.</p>
<p>Worse, that file contained the PSK. Reading it properly would have let me
configure NetworkManager non-interactively from the start, without ever
needing to ask for a password.</p>
<h3>Mistake two: a teardown with no completion guarantee</h3>
<p>The migration ran as a plain foreground command:</p>
<pre><code class="language-bash">pkill -f 'wpa_supplicant ...'
pkill dhcpcd
systemctl enable --now NetworkManager    # ← never reached
</code></pre>
<p>The command was interrupted between the <code>pkill</code> and the <code>enable</code>. The old stack
was dead; the new one had never started. The user came back from a shower to a
machine with no networking, and after a suspend/resume cycle it still had none —
because nothing was left running to restore it.</p>
<h3>The fix: make it atomic and self-healing</h3>
<p>The rewritten migration is a script that cannot leave a half-state:</p>
<ol>
<li>Read SSID and PSK from <code>/etc/network/interfaces</code></li>
<li><strong>Write the NetworkManager profile first</strong>, before touching anything</li>
<li>Comment the interface out of ifupdown's control</li>
<li>Stop the old stack</li>
<li>Start NetworkManager</li>
<li>Poll for up to 60 s for genuine connectivity — an IP <em>and</em> a successful ping</li>
<li><strong>On failure, roll back automatically</strong>: restore the backup, restart
<code>ifup@</code>, and report</li>
</ol>
<p>It also logs to <code>/var/log/net-switch.log</code> so a failure is diagnosable rather
than mysterious. It ran clean: <code>SWITCH-OK</code>, same IP, and it survived a
suspend/resume intact.</p>
<p><strong>Lessons:</strong></p>
<ul>
<li>A silent command failure and an empty result look identical. Check exit
status, or run privileged reads privileged.</li>
<li>Any operation that dismantles working infrastructure before building its
replacement must be atomic and must roll itself back. &quot;I'll just run these
four commands&quot; is not a plan.</li>
</ul>
<hr />
<h2>Part 10 — Sleep: measuring instead of guessing</h2>
<p>The direct question was: <em>what happens when I close the lid — will suspend
break this machine?</em></p>
<p>Rather than opine, I wrote a test script that suspends via <code>rtcwake</code> with a
hardware timer wake, so the machine returns on its own <strong>even if the resume
path is completely broken</strong>, and logs everything <em>before</em> sleeping so a
non-returning machine still leaves evidence.</p>
<h3>First result: it works, but the test was worthless</h3>
<pre><code>rtcwake rc=0  asleep for 5s (asked for 20s)
battery before: 22%   battery after: 23%
</code></pre>
<p>Suspend and resume worked — clean resume, network intact, no hang. But the
battery went <em>up</em>: the machine was on AC, so the drain figure measured nothing.
And it slept for 3.3 seconds instead of 20, because charging generates a
constant stream of EC events, any of which can wake an s2idle system.</p>
<h3>Second result, on battery, and a lesson in fake precision</h3>
<p>Ten minutes in each state:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>mode</th>
<th>10 minutes asleep</th>
<th>woke early?</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>s2idle</strong></td>
<td>one full 1% step consumed</td>
<td>no — slept 602 s</td>
</tr>
<tr>
<td><strong>deep (S3)</strong></td>
<td>below the detection floor</td>
<td>no — slept 604 s</td>
</tr>
</tbody>
</table></div>
<p>The script had confidently printed <strong>&quot;5.98 %/hour&quot;</strong>. That number is
fabricated precision, and I'd written the code that produced it.</p>
<p>This battery reports <code>charge_now</code> in <strong>38000 µAh quanta — exactly 1%</strong>. Every
reading in both tests is a multiple of it: 950000, 912000, 874000, 836000. A
ten-minute test that consumes one step tells you the true rate is somewhere
around 3–12 %/hour. It does not tell you 5.98.</p>
<p>The script now reports step counts and an explicit quantisation range:</p>
<pre><code>drain: 38000 uAh over 602s = 1 reporting step(s)
       midpoint 227 mAh/h (6.0 %/h, ~17h from full)
       range    0.0-12.0 %/h  &lt;- quantisation, not noise
       only 1 step(s) resolved; test 3-4x longer for a firm number
</code></pre>
<p>The comparison still stands on its own: same duration, same conditions, one
state consumed a measurable step and the other didn't. <code>deep</code> wins
unambiguously, even without a precise ratio.</p>
<h3>Making it permanent, without touching the bootloader</h3>
<p>I had earlier described switching to S3 as a kernel-commandline change
requiring a GRUB edit and a reboot. That was wrong: <strong><code>/sys/power/mem_sleep</code> is
writable at runtime.</strong> So the whole thing is a sysfs write:</p>
<pre><code class="language-ini">ExecStart=/bin/sh -c 'grep -qw deep /sys/power/mem_sleep &amp;&amp; echo deep &gt; /sys/power/mem_sleep'
</code></pre>
<p>Same effect as <code>mem_sleep_default=deep</code>, no bootloader risk, and
<code>systemctl disable mem-sleep-deep</code> fully reverts it.</p>
<h3>Hibernate: blocked, and I got this wrong first</h3>
<p>I initially reported hibernate as &quot;95% configured, needs testing not building&quot;,
pointing at the correctly-sized 11.7 GB encrypted swap and the <code>RESUME=</code> entry
already present in the initramfs.</p>
<p>Then I actually looked at <code>/sys/power/state</code>:</p>
<pre><code>freeze mem
</code></pre>
<p>No <code>disk</code>. Hibernation is unavailable. The reason:</p>
<pre><code>lockdown:   none [integrity] confidentiality
secureboot: SecureBoot enabled
</code></pre>
<p><strong>Secure Boot puts the kernel in <code>integrity</code> lockdown, which disables
hibernation outright</strong> — regardless of <code>CONFIG_HIBERNATION=y</code>, swap sizing, or
resume configuration. The kernel refuses because it can't verify the resume
image hasn't been tampered with.</p>
<p>Everything I'd said about the storage side was correct. I simply hadn't checked
the one thing that actually gated it. With S3 now working, hibernate's
advantage shrank enough that keeping Secure Boot is the better trade.</p>
<hr />
<h2>Part 11 — A graphical greeter</h2>
<p>There was no display manager at all. Login was a text console, after which
Hyprland had to be started by hand.</p>
<p>The chosen stack: <strong>greetd → Hyprland (minimal) → gtkgreet</strong>.</p>
<p>Running gtkgreet inside a tiny Hyprland instance rather than under <code>cage</code> is
deliberate: this panel needs <code>transform 3</code> and scale 1.5, and cage can't rotate
an output. A greeter compositor config solves it in two lines:</p>
<pre><code class="language-ini">monitor = DSI-1, preferred, auto, 1.5, transform, 3
exec-once = gtkgreet -l -b /usr/share/backgrounds/login.png -s /etc/greetd/gtkgreet.css -c start-hyprland; hyprctl dispatch exit
</code></pre>
<p>greetd is deliberately on <strong>VT 7</strong>, leaving tty1–6 as plain console logins — the
escape hatch if the greeter ever fails.</p>
<h3>Hardening before shipping something unverified</h3>
<p>I could not fully verify the greeter before the user rebooted: their live
Hyprland session held DRM master, so the greeter's compositor could never
acquire the display while it was running.</p>
<p>Shipping something unverified means hardening the failure path. greetd ships
<code>Restart=always</code> with <code>RestartSec=1</code>, so a broken greeter would respawn every
second, seizing VT 7 each time and making it genuinely difficult to reach a
console. A drop-in caps it:</p>
<pre><code class="language-ini">[Unit]
StartLimitIntervalSec=60
StartLimitBurst=3
</code></pre>
<p>Three failures in sixty seconds and it gives up, leaving you on a TTY.</p>
<h3>Two bugs found by looking</h3>
<p><strong>The greeter user got no groups.</strong> This:</p>
<pre><code class="language-bash">usermod -aG video,input,render,seat _greetd
</code></pre>
<p>failed <em>entirely</em> because the <code>seat</code> group doesn't exist on this system —
<code>usermod</code> aborts the whole call rather than adding the three valid groups. It
had to be split into separate invocations. A greeter with no <code>video</code> group
cannot open the DRM device.</p>
<p><strong>The wallpaper was unreachable.</strong> A home directory is <code>drwx------</code>, so <code>_greetd</code>
could never have read a background from the user's home. The image had to go to
<code>/usr/share/backgrounds/</code>.</p>
<h3>GTK CSS silently discarding the entire stylesheet</h3>
<p>Styling gtkgreet, I squeezed the form inward with:</p>
<pre><code class="language-css">box#body {
    margin-left: 34%;
    margin-right: 34%;
}
</code></pre>
<p>The greeter rendered in <strong>default GTK</strong> — stock blue button, wrong fonts,
nothing themed. Not partially styled. Entirely unstyled.</p>
<pre><code>Theme parsing error: gtkgreet.css:23:19: Junk at end of value for margin-left
style loading failed: gtkgreet.css:23:19
</code></pre>
<p><strong>GTK CSS does not support percentage margins</strong>, and a single invalid
declaration causes GTK to discard the <em>whole stylesheet</em>. One bad line, and
every rule in the file evaporates.</p>
<p>This is the same failure shape as the empty icons and the unresolvable PATH:
the system does exactly nothing, and the nothing looks like a design choice.
Every render after that checked for <code>style loading failed</code> explicitly.</p>
<h3>The error that flashed at boot</h3>
<p>The greeter came up on the first real reboot — with something flashing briefly
in the top-right. That's Hyprland's error overlay, and the cause:</p>
<pre><code>WARNING: Hyprland is being launched without start-hyprland. This is highly advised against.
</code></pre>
<p>greetd was invoking <code>Hyprland</code> directly rather than the <code>start-hyprland</code>
watchdog wrapper. The fix passes the config through the wrapper:</p>
<pre><code class="language-toml">command = &quot;start-hyprland -- --config /etc/greetd/hyprland.conf&quot;
</code></pre>
<p>I had that exact warning in my very first nested test log and dismissed the
neighbouring line — <code>Creating the Error Overlay!</code> — as routine object
construction. It wasn't routine. The overlay was being built <em>because of</em> that
warning. A user glancing at their screen for one second found what I'd had in a
log file and misread.</p>
<hr />
<h2>Part 12 — Typography and a prompt</h2>
<h3>The font</h3>
<p>The machine had <strong>zero Nerd Fonts</strong> — only legacy <code>FontAwesome</code> and plain
JetBrains Mono. Four styles of <strong>MesloLGS NF</strong> went into
<code>~/.local/share/fonts</code> (user-level, no root).</p>
<p>Rather than replace the typeface, foot uses a fallback chain:</p>
<pre><code class="language-ini">font=MesloLGS NF:size=16, JetBrains Mono:size=16
</code></pre>
<p>foot falls through to later fonts for missing glyphs, so text renders in the
primary face and only icons come from the fallback.</p>
<h3>The prompt</h3>
<p>Starship, two lines, self-contained &quot;pills&quot;, Tokyo Night, rendering in <strong>27 ms</strong>:</p>
<pre><code> 󰊠  …/demo    master 󰋗1   󰎙 v20.19.2   󱎫 45s
→ npm test
</code></pre>
<p>The first version was a continuous powerline chain. It looked good in a git
repo, and left <strong>two orphaned arrows dangling</strong> in a plain directory, because
chained background colours assume every segment renders. Self-contained pills —
each with its own rounded caps — degrade correctly in every case. Tested across
plain directory, clean repo, dirty repo, and repo-with-node.</p>
<p>Two other decisions:</p>
<ul>
<li><strong><code>right_format</code> doesn't work in bash at all</strong> (fish/zsh/nushell only). I'd
written battery and clock into it before checking. They were dropped rather
than faked with <code>$fill</code> — waybar already shows both, and at 24pt there are
only ~50 columns in a half-tiled window.</li>
<li>Segments appear only when relevant: git pill only in a repo, language pill
only in a project, duration only past 2 s, exit code only on failure.</li>
</ul>
<h3>Finding a glyph, properly</h3>
<p>Asked for a ghost icon, I guessed six codepoints and rendered them at 30pt.
They turned out to be a gift box, the Dropbox logo, an image placeholder, a
keyboard, a fishhook and a lightning bolt. <code>F02A1</code>, which I'd tried, is one
past the real ghost.</p>
<p>So I stopped guessing and fetched the authoritative mapping:</p>
<pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/ryanoasis/nerd-fonts/master/glyphnames.json
</code></pre>
<p>Eight glyphs match &quot;ghost&quot;. Rendering them revealed two more traps:
<code>nf-dev-ghost</code> is the Ghost <em>blogging platform</em> wordmark, and <code>nf-fa-ghost</code>
(<code>U+EEFE</code>) <strong>isn't present in MesloLGS NF at all</strong> — it renders as a tofu box.
That one would have shipped as an empty rectangle on the strength of its name.</p>
<p>The winner: <code>nf-md-ghost</code>, <code>U+F02A0</code>.</p>
<hr />
<h2>Part 13 — The launcher, and matching the lock screen to it</h2>
<h3>fuzzel</h3>
<p><code>Super+D</code> had no configuration. It now has a translucent <code>#1a1b26</code> panel, a 2px
iris border with 14px radius, MesloLGS NF, Adwaita app icons, and a magnifier
prompt glyph.</p>
<p>Two settings matter beyond looks:</p>
<pre><code class="language-ini">dpi-aware=no      # scale with the compositor's 1.5x, don't compute our own DPI
match-mode=fzf    # subsequence matching: &quot;tgd&quot; finds Telegram Desktop
</code></pre>
<p>The first render used <code>selection=2f3549</code>, which was almost indistinguishable
from the <code>1a1b26</code> background — the highlighted row was effectively invisible.
<code>3b4261</code> matches waybar's hover state and reads clearly. That's the kind of
thing only a screenshot tells you.</p>
<h3>hyprlock, and a measurement that overturned an assumption</h3>
<p>Restyling the lock screen to match used hyprlock's <code>shape</code> element to draw the
same panel.</p>
<p>The first attempt came out visibly undersized. Measuring the screenshot
explained why: a panel declared <code>440</code> wide rendered <strong>440 physical pixels on a
1920-pixel screen</strong>. <strong>hyprlock draws in physical pixels and ignores the
compositor's 1.5 scale</strong> — unlike fuzzel, waybar, and every normal Wayland
client. Every dimension in that config is therefore ~1.5× its equivalent
elsewhere, and the file says so in a comment so the next person doesn't have to
rediscover it.</p>
<p>Final panel: 780×430, chosen to match fuzzel's measured ~787 px width so the
two read as one design.</p>
<hr />
<h2>Part 14 — Lockdead</h2>
<p>The worst outage of the evening, and entirely self-inflicted.</p>
<p>To preview the restyled lock screen I ran, twice in quick succession:</p>
<pre><code class="language-bash">setsid timeout -s TERM 15 hyprlock --grace 0 &amp;
</code></pre>
<p>hyprlock's own log tells the whole story:</p>
<pre><code>Locking session
onLockFinished called. Seems we got yeeten. Is another lockscreen running?
corrupted double-linked list (not small)
</code></pre>
<p>A second hyprlock started while the first still held the session lock. Wayland
refused it, and it then hit heap corruption and <strong>aborted while the session was
locked</strong>. When a session locker dies without releasing the lock, the compositor
deliberately stays locked — a security property, not a bug. The screen was
stuck with no locker. The user had to power-cycle.</p>
<h3>Three fixes</h3>
<p><strong>1. Stop testing that way.</strong> SIGTERM-ing a session locker was never safe.
Previews now happen inside a <strong>nested Hyprland instance</strong>, which has its own
session lock and cannot touch the real one. For a true-scale preview, the real
monitor can be dropped to scale 1.0 briefly so a fullscreen nested compositor
is genuinely 1920×1200 — reversible with a single <code>hyprctl reload</code>.</p>
<p><strong>2. Make lockdead recoverable.</strong></p>
<pre><code class="language-ini">misc {
    allow_session_lock_restore = true
}
</code></pre>
<p>A freshly launched hyprlock can now <strong>adopt an orphaned lock</strong> instead of
requiring a reboot. Recovery is documented in the keybinding cheatsheet:
<code>Ctrl+Alt+F2</code>, log in, <code>hyprctl -i 0 dispatch exec hyprlock</code>.</p>
<p><strong>3. A near-miss worth recording.</strong> While cleaning up leftover nested
instances I wrote <code>pkill -x Hyprland</code>, which would have killed the user's <em>real
session</em> along with the strays. It was blocked before it ran. The correct
approach — identifying the nested instance by PID from <code>ps -eo pid,tty,args</code>
and killing exactly that — takes ten more seconds and cannot destroy anything.</p>
<p>Earlier in the same session I'd also written
<code>pkill -f 'start-hyprland -- --config'</code>, which matched <strong>its own command line</strong>
and killed the shell executing it, silently skipping every subsequent step.</p>
<p><code>pkill</code> with broad patterns is a loaded gun pointed at whatever happens to
match, including yourself.</p>
<hr />
<h2>Part 15 — Applications</h2>
<p>Thunderbird and Telegram Desktop, with one non-obvious correction.</p>
<p><strong>Telegram is a Qt5 application, and Qt5 defaults to the <code>xcb</code> backend</strong> — it
would have run through XWayland and looked soft at 1.5 scale, the same class of
problem as the tiny Firefox. <code>qtwayland5</code> was already installed; it just needed
telling:</p>
<pre><code class="language-ini">env = QT_QPA_PLATFORM,wayland;xcb
</code></pre>
<p>The <code>;xcb</code> suffix is a deliberate fallback so any Qt app still starts if the
Wayland plugin fails, rather than refusing to launch. Verified:
<code>xwayland=false</code> on both apps.</p>
<p>They also both opened onto workspace 1, quartering the terminal workspace into
628×374 boxes. On a 1280×800 logical screen that's unusable, so applications
got homes:</p>
<pre><code class="language-ini">windowrule = workspace = 2, match:class = ^(firefox|firefox-esr)$
windowrule = workspace = 3, match:class = ^(thunderbird)$
windowrule = workspace = 4, match:class = ^(org\.telegram\.desktop)$
</code></pre>
<hr />
<h2>Part 16 — Screen recording</h2>
<p><code>wf-recorder</code>, with two decisions that matter more than usual on this hardware.</p>
<p><strong>Hardware encoding.</strong> <code>vainfo</code> reports H.264 and HEVC <code>EncSliceLP</code> — low-power
encode — on <code>renderD128</code>. Software-encoding 1920×1200 on a 6 W N150 would have
destroyed both framerate and battery:</p>
<pre><code class="language-bash">wf-recorder -c h264_vaapi -d /dev/dri/renderD128 --codec-param=qp=24 -m mp4
</code></pre>
<p><strong>Signals, again.</strong> wf-recorder must be stopped with <strong>SIGINT</strong>, not SIGTERM or
SIGKILL: it writes the MP4 <code>moov</code> atom on interrupt, and a harder kill leaves an
unplayable file. Given how the evening had gone, this was verified rather than
assumed — a test capture was parsed for its top-level boxes:</p>
<pre><code>top-level boxes: ['ftyp', 'free', 'mdat', 'moov']
moov present (finalised): True
</code></pre>
<p>Recording is a toggle (<code>Super+Alt+R</code> screen, <code>+Shift</code> region, <code>Super+Alt+A</code>
with system audio), a red <strong>REC</strong> pill appears in waybar while active and is
hidden otherwise, and the file path lands in the clipboard on stop.</p>
<p>Audio defaults to <strong>off</strong>, and <code>--audio</code> captures the default sink's <code>.monitor</code>
— system audio, what you hear — not the microphone. Mic capture is a separate
explicit <code>--mic</code> flag, so nothing records you by accident.</p>
<hr />
<hr />
<h2>Part 17 — The console, where none of the fonts exist</h2>
<p>The prompt looked wrong outside Hyprland. Icons missing, colours flat. The
instinct is to install the font on the console too. That instinct is wrong, and
usefully so.</p>
<p><strong>The Linux virtual console does not use fontconfig.</strong> It loads a PSF font
directly into the video hardware, and the format is hard-capped at <strong>512
glyphs</strong>. Nerd Font icons live in the Private Use Area — <code>U+E000</code>–<code>U+F8FF</code> and
the supplementary block at <code>U+F0000+</code>. They cannot be represented in a console
font at all. There is no font you can install that fixes this, because the VT
never consults the font system that would find it.</p>
<p>The console also has no truecolor. Hex values like <code>#bb9af7</code> have nowhere to go;
you get the 16 ANSI colours.</p>
<p>So the console gets a different prompt, chosen automatically:</p>
<pre><code class="language-bash">if command -v starship &gt;/dev/null 2&gt;&amp;1; then
    if [ &quot;$TERM&quot; = &quot;linux&quot; ]; then
        export STARSHIP_CONFIG=&quot;$HOME/.config/starship-tty.toml&quot;
    fi
    eval &quot;$(starship init bash)&quot;
fi
</code></pre>
<p><code>starship-tty.toml</code> carries the same information with pure ASCII symbols and
named ANSI colours — <code>git:main</code>, <code>~1 ?1</code> for modified and untracked,
<code>node:v20.19.2</code>, <code>&gt;</code> for the character:</p>
<pre><code>user@host .../system git:main ~1 ?1 node:v20.19.2 took 4s !NOTFOUND
&gt;
</code></pre>
<p>Rather than squint at it, the check was programmatic — strip the escape
sequences and assert that nothing above ASCII survives:</p>
<pre><code class="language-python">txt = re.sub(r'\x1b\[[0-9;]*m', '', out)
bad = [(c, hex(ord(c))) for c in txt if ord(c) &gt; 127]
#  → NONE
</code></pre>
<h3>The bigger console problem nobody mentioned</h3>
<p>At 216 DPI, the default <code>Fixed 8x16</code> console font is <strong>5.3 points physically</strong>.
Not small — unreadable.</p>
<p>That matters more than it sounds, because on this machine the TTY is the
documented escape hatch: it is where you go when the greeter fails to start, and
where you go to recover a dead lock screen. Twice in one evening it was the
recovery path. An escape hatch you cannot read is not an escape hatch.</p>
<pre><code> 8x16 glyph  →  5.3pt physical   unusable
16x32 glyph  → 10.7pt physical   comfortable
</code></pre>
<p>Debian ships Terminus at that size, though the filenames are a trap:
<code>Lat15-Terminus32x16.psf.gz</code> is <strong>16 wide by 32 tall</strong> — the name lists height
first. Verified by reading the PSF2 header rather than trusting the ordering:</p>
<pre><code class="language-python">h = struct.unpack('&lt;I', d[24:28])[0]   # 32
w = struct.unpack('&lt;I', d[28:32])[0]   # 16
</code></pre>
<pre><code class="language-ini">FONTFACE=&quot;TerminusBold&quot;
FONTSIZE=&quot;16x32&quot;
</code></pre>
<p>120 × 37 cells on the panel, at a size you can actually read.</p>
<h3>And one last silent no-op, right on cue</h3>
<p>Applying it:</p>
<pre><code class="language-bash">sudo setupcon --force
</code></pre>
<p>Exit status <strong>0</strong>. Nothing changed. The cached loader still read:</p>
<pre><code class="language-sh">setfont '/usr/share/consolefonts/Lat15-Fixed16.psf.gz'
</code></pre>
<p>with timestamps untouched from that morning's install. <code>--force</code> does not
regenerate the cache; <code>setupcon --save --force</code> does. The only way to catch it
was to read the generated script and compare file timestamps — the command
itself reported success either way.</p>
<h3>What was deliberately left broken</h3>
<p>The LUKS passphrase prompt at boot is still 8×16, because Debian's
<code>console-setup</code> package <strong>ships no initramfs hook</strong> — the hooks directory has
<code>keymap</code> and a pile of <code>cryptroot</code> scripts, but nothing for the font. Getting a
console font into the initramfs means hand-writing a hook, and on a machine that
boots from an encrypted root, a broken initramfs hook is an unbootable machine.</p>
<p>A larger password prompt is not worth that risk. Documented as a known
limitation instead of quietly attempted.</p>
<hr />
<h2>Part 18 — Files: one need that was already met, one that wasn't</h2>
<p>Two requirements that sound like the same thing and aren't: a working file
picker for browser uploads, and something to browse files in.</p>
<h3>The picker needed nothing at all</h3>
<p>The instinct is to install a file manager so the browser has something to open.
That is not how it works. Firefox and Chromium get their upload dialog from
GTK, or from the <strong>XDG desktop portal</strong> — <code>org.freedesktop.portal.FileChooser</code>,
implemented here by <code>xdg-desktop-portal-gtk</code>. No file manager is involved.</p>
<p>Rather than assume it worked, the exact call the browser makes was invoked
directly over D-Bus:</p>
<pre><code class="language-bash">gdbus call --session \
  --dest org.freedesktop.portal.Desktop \
  --object-path /org/freedesktop/portal/desktop \
  --method org.freedesktop.portal.FileChooser.OpenFile \
  &quot;&quot; &quot;Upload a file&quot; &quot;{}&quot;
</code></pre>
<p>A real dialog appeared — Wayland-native, dark-themed, with Recent / Home /
Other Locations. Nothing to install.</p>
<p>Worth noting <em>why</em> it worked, because it very nearly didn't: this is the same
portal stack that was silently broken earlier by a stale <code>WAYLAND_DISPLAY</code> in
the systemd user environment. The <code>dbus-update-activation-environment</code> fix that
took waybar's startup from 25 seconds to 1 also repaired file pickers and
screen sharing, which route through the identical backend. One environment
variable, three unrelated-looking symptoms.</p>
<h3>The browser: pcmanfm, and two silent config traps</h3>
<p>For actually browsing files with image previews, the candidates by installed
footprint:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th></th>
<th>size</th>
<th>direct deps</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>nnn</code></td>
<td>171 KB</td>
<td>5</td>
</tr>
<tr>
<td><code>thunar</code></td>
<td>1238 KB</td>
<td>20</td>
</tr>
<tr>
<td><code>pcmanfm</code></td>
<td>1588 KB</td>
<td>11</td>
</tr>
<tr>
<td><code>lf</code></td>
<td>5610 KB</td>
<td>0 (static Go)</td>
</tr>
</tbody>
</table></div>
<p>Thunar is nominally smaller than pcmanfm but drags in twenty XFCE
dependencies. pcmanfm won on total weight.</p>
<p>On previews specifically: no lightweight GTK file manager has a hover-preview
pane. What pcmanfm does instead is better in practice — <strong>persistent thumbnails
in thumbnail view</strong>, so every image in a directory shows its content at once
rather than one at a time under the cursor.</p>
<p>Getting those thumbnails working took two corrections, both silent, both mine.</p>
<p><strong>Keys in the wrong section are discarded without a word.</strong> I wrote
<code>thumbnail_local</code> and <code>thumbnail_max</code> under <code>[ui]</code>. libfm expects them in
<code>[config]</code>. The size key is <code>thumbnail_size</code>, not <code>thumbnail_icon_size</code>. First
launch showed generic mimetype icons, no error, no warning, empty thumbnail
cache. The only way I found the correct names was to read the file <strong>pcmanfm
itself had rewritten</strong>, which spelled out the schema it actually uses.</p>
<p><strong>The config file is autogenerated and overwritten on exit:</strong></p>
<pre><code># Configuration file for the libfm version 1.4.0.
# Autogenerated file, don't edit, your changes will be overwritten.
</code></pre>
<p>My first edit was reverted the instant pcmanfm launched. It has to be written
while pcmanfm is not running — which is a rule the file politely states and
which is very easy to skim past.</p>
<p>One more default worth changing: <code>thumbnail_max=2048</code> is a size cap in KB.
Images larger than 2 MB silently get no thumbnail. Current screenshots are
~200 KB so it wasn't the blocker, but a photo or a full-resolution capture
would have quietly lost its preview later. Set to <code>0</code> for no limit.</p>
<pre><code class="language-ini">[config]
thumbnail_local=1
thumbnail_max=0

[ui]
thumbnail_size=160
show_thumbnail=1
</code></pre>
<p>Bound to <code>Super+E</code>, and registered as the <code>inode/directory</code> handler so &quot;open
containing folder&quot; works from other applications.</p>
<hr />
<h2>Part 19 — Camera and microphone, and 60 dB of stacked gain</h2>
<p>&quot;Does the webcam work?&quot; is a question with two very different answers: <em>does
the device enumerate</em>, and <em>does it produce usable output</em>. Only the second one
matters before a video call, and only the second one requires actually
capturing something.</p>
<h3>The camera enumerated, then proved itself</h3>
<pre><code>/dev/video0, /dev/video1, /dev/media0
uvcvideo loaded
Hy-Usb2.0-1*MIC: Hy-UXGA(8101)-   1600x1200 / 960x720 / 640x480
</code></pre>
<p>Enumeration is not evidence. The real test is a frame:</p>
<pre><code class="language-bash">v4l2-ctl -d /dev/video0 --stream-mmap --stream-count=1 --stream-to=frame.raw
</code></pre>
<p>168 KB for a 1600×1200 capture is far too small for raw YUYV (which would be
3.8 MB), so it should be MJPEG — confirmed by the magic bytes:</p>
<pre><code class="language-python">d[:2] == b'\xff\xd8'   # → JPEG
</code></pre>
<p>A real, compressed, correctly-sized image. That's the camera working.</p>
<p>I deliberately did not open the file. Format, size and a successful capture
prove the device works; the contents are a photograph of somebody's room.
Verification does not require looking at it, and it was deleted along with the
audio test.</p>
<h3>The microphone &quot;worked&quot; and was completely unusable</h3>
<p>The first recording captured audio on the first try. It was also garbage:</p>
<pre><code>6.0s   peak 0.00000 dBFS   rms -1.3 dBFS
non-zero samples: 285689/285696  (100.0%)
</code></pre>
<p>A microphone in a quiet room does not produce an RMS of −1.3 dBFS. That is a
saturated square wave — the input pegged against the rail for six solid
seconds. &quot;The mic works&quot; and &quot;the mic is usable on a call&quot; were, again,
unrelated claims.</p>
<p>The codec's mixer explained it immediately:</p>
<pre><code>Capture            63/63  [100%]  [+30.00 dB]
Internal Mic Boost  3/3   [100%]  [+30.00 dB]
</code></pre>
<p><strong>Sixty decibels of gain stacked on an internal microphone.</strong> Two independent
controls, each at maximum, each contributing +30 dB. Nothing warns you; the
device simply reports beautifully loud audio that happens to be clipped beyond
recognition.</p>
<p>Dropping <code>Internal Mic Boost</code> to 0 dB and the PipeWire source to 40%:</p>
<pre><code>peak -0.3 dBFS   rms -17.6 dBFS   clipped samples: 0
</code></pre>
<p>Persisted with <code>alsactl store</code> so it survives reboots.</p>
<h3>The measurement that nearly went wrong</h3>
<p>My second attempt still showed clipping, and the obvious move was to keep
cutting gain until it stopped. That would have been wrong.</p>
<p><code>wpctl status</code> showed a live stream:</p>
<pre><code>Streams:
    66. Firefox
         68. output_FL  &gt; ALC269VC Analog:playback_FL  [active]
</code></pre>
<p>Firefox was playing audio <strong>through the speakers</strong>, and the microphone was
faithfully recording it. I was about to tune a microphone against the machine's
own output. Muting the sink for the duration of the measurement — and unmuting
afterwards — isolated the actual room:</p>
<pre><code class="language-bash">wpctl set-mute @DEFAULT_AUDIO_SINK@ 1
#   ... record ...
wpctl set-mute @DEFAULT_AUDIO_SINK@ 0
</code></pre>
<p>The lesson generalises well beyond audio: before tuning against a measurement,
check what else is feeding into it.</p>
<h3>The device name that lies</h3>
<p>The USB module identifies itself as <code>Hy-Usb2.0-1*MIC</code>, which reads
unambiguously as &quot;this is where the microphone is&quot;. It is not:</p>
<pre><code>Port 005: Dev 002, If 0, Class=Video, Driver=uvcvideo
Port 005: Dev 002, If 1, Class=Video, Driver=uvcvideo
</code></pre>
<p>Two interfaces, both <strong>Video</strong>. No USB audio class interface at all, and
<code>/proc/asound/cards</code> lists exactly one card — the Intel HDA codec. The
microphone is the built-in analog input; the camera module has no microphone
despite its name. Worth knowing before hunting for a device in a
conferencing app's dropdown that does not exist.</p>
<h3>For video calls specifically</h3>
<p>Camera access in Firefox and Chromium goes straight through V4L2 — no portal
involved, and the user needs to be in the <code>video</code> group (or hold the logind
ACL). Screen sharing <em>does</em> go through the portal, which means
<code>xdg-desktop-portal-hyprland</code> has to be alive — the same portal stack that
serves file pickers, and the same one that was silently dead earlier over a
stale <code>WAYLAND_DISPLAY</code>.</p>
<p>Final advice worth writing down: after setting levels by measurement, confirm
them with the conferencing app's own audio test and its live level meter. A
number that looks right for room noise still has to be right for a speaking
voice, and that is not something a script can measure for you.</p>
<hr />
<h2>Part 20 — The small pieces that make it a daily driver</h2>
<p>Not everything worth doing needs a section of its own. These are the pieces
that turn a configured compositor into a machine you actually work on.</p>
<h3>Remote development, on a laptop that suspends</h3>
<p>This machine is not for compiling. It is for shelling into something that is.
That changes which tools matter.</p>
<p><code>mosh</code> is the important one, and the reason is specific to a laptop: <strong>an SSH
session dies when the machine suspends or changes network.</strong> Close the lid,
walk to a café, open it — the SSH connection is gone and whatever it was
running went with it. mosh survives both suspend and roaming, reconnects
silently, and keeps local echo responsive over bad links. Paired with <code>tmux</code> on
the remote end, closing the lid mid-build costs nothing.</p>
<p>The SSH config is tuned for the same reality:</p>
<pre><code>Host *
    ServerAliveInterval 20
    ServerAliveCountMax 3
    TCPKeepAlive no
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
    AddKeysToAgent yes
    IdentityFile ~/.ssh/id_ed25519
</code></pre>
<p><code>ServerAliveInterval</code> with a low count means dead connections <strong>fail fast</strong>
instead of hanging for minutes after a resume. <code>ControlMaster</code> with
<code>ControlPersist 10m</code> multiplexes subsequent connections over the first one, so
the second and third <code>ssh</code> to the same host are instant — noticeable on a
machine where you open a lot of short-lived sessions.</p>
<h3>Default applications, so things open where you expect</h3>
<p>A desktop without mime associations is a desktop where double-clicking does
nothing. The set here is deliberately minimal — one lightweight tool per type:</p>
<pre><code class="language-ini">[Default Applications]
inode/directory=pcmanfm.desktop
image/png=imv.desktop
image/jpeg=imv.desktop
application/pdf=org.pwmt.zathura.desktop
text/html=firefox-esr.desktop
x-scheme-handler/https=firefox-esr.desktop
</code></pre>
<p><code>imv</code> is a Wayland-native image viewer, <code>zathura</code> a keyboard-driven PDF reader.
Both are small, both start instantly, and neither pulls in a desktop
environment. This matters more than it sounds on a screenshot-heavy workflow:
the screenshot tool saves a file, and you want looking at it to be free.</p>
<h3>Clipboard history</h3>
<p><code>cliphist</code> with two watchers — one for text, one for images:</p>
<pre><code class="language-ini">exec-once = pgrep -f &quot;type text --watch cliphist&quot; &gt;/dev/null || wl-paste --type text --watch cliphist store
exec-once = pgrep -f &quot;type image --watch cliphist&quot; &gt;/dev/null || wl-paste --type image --watch cliphist store
</code></pre>
<p><code>Super+Shift+V</code> pipes the history through fuzzel and copies the selection back.
Image entries are stored too, which pairs neatly with a screenshot tool that
always copies to the clipboard — the last several screenshots stay retrievable
rather than being overwritten by the next <code>Ctrl+C</code>.</p>
<p>The <code>pgrep</code> guards are the same singleton pattern used for <code>hypridle</code>: these
are per-user daemons, and a second login session should not start a second
copy.</p>
<h3>A cheatsheet, because eighty bindings is too many to remember</h3>
<p><code>Super+/</code> opens a formatted reference in a floating terminal — apps, window
management, workspaces, screenshots, recording, clipboard, and the recovery
steps for a hung lock screen. It is a shell script with <code>printf</code>, which means
it costs nothing and cannot itself break.</p>
<h3>Everything in version control</h3>
<p>All of it lives in a git repo in <code>~/.config</code>, including copies of the files
that live outside it — the four custom systemd units, the greeter config in
<code>/etc/greetd</code>, <code>~/.bashrc</code> and <code>~/.profile</code>, and <code>/etc/default/console-setup</code> —
each with a README noting the live path, since editing the copy does nothing.</p>
<p>Fifteen commits, each one explaining <em>why</em> rather than what. That matters more
than usual here, because a surprising amount of this configuration looks
arbitrary until you know which silent failure it exists to prevent.</p>
<hr />
<h2>MiniBook X quirks worth knowing</h2>
<p>Things specific to this hardware that cost time, and that anyone else running
Linux on an N150 MiniBook X will meet.</p>
<h3>The panel is mounted rotated</h3>
<p>The DSI panel is physically 1200×1920 portrait, mounted sideways. Everything
must be rotated in software:</p>
<pre><code class="language-ini">monitor = DSI-1, preferred, auto, 1.5, transform, 3
</code></pre>
<p>The kernel console needs telling separately — this machine already had
<code>fbcon=rotate:1</code> on the kernel command line, which is why the boot text and TTYs
are the right way up. Without it, your escape hatch is sideways.</p>
<p><strong>The touchscreen does not inherit the rotation.</strong> Wayland won't infer it, so
taps land in the wrong place until you match the transform explicitly:</p>
<pre><code class="language-ini">input {
    touchdevice {
        transform = 3
    }
}
</code></pre>
<p>Any greeter or lock screen also needs its own rotation, which is why the greeter
here runs gtkgreet inside a minimal Hyprland rather than under <code>cage</code> — cage
can't rotate an output.</p>
<h3>Goodix touchscreen throws an i2c error on every resume</h3>
<pre><code>Goodix-TS i2c-GDIX1002:00: Error reading 1 bytes from 0x8047: -121
</code></pre>
<p><code>-121</code> is <code>EREMOTEIO</code>, and it fires on <strong>every</strong> wake from suspend. It looks
alarming in <code>dmesg --level=err</code> and it is entirely cosmetic: the device never
re-probes, stays registered on the same input node, and touch works normally
afterwards. Verified by resuming and using it.</p>
<h3><code>DSI link not ready</code> on resume, and atomic-update warnings in normal use</h3>
<pre><code>i915 0000:00:02.0: [drm] *ERROR* DSI link not ready
i915 0000:00:02.0: [drm] *ERROR* Atomic update failure on pipe A ...
</code></pre>
<p>Both are logged at error level and neither indicates a real fault. Check the
uptime stamps: the atomic-update messages appear mid-session, nowhere near a
suspend, so they're routine scanline-timing warnings on a DSI panel. The link
message appears at resume and the display recovers immediately.</p>
<h3>The internal microphone ships with +60 dB of gain</h3>
<p>Out of the box, the ALC269VC codec has both capture controls at maximum:</p>
<pre><code>Capture            63/63  [+30.00 dB]
Internal Mic Boost  3/3   [+30.00 dB]
</code></pre>
<p>The result is audio clipped into a square wave — it records, and it is unusable
on a call. Fix, then persist it:</p>
<pre><code class="language-bash">amixer -c 0 sset &quot;Internal Mic Boost&quot; 0
wpctl set-volume @DEFAULT_AUDIO_SOURCE@ 0.40
sudo alsactl store
</code></pre>
<h3>The camera module is named &quot;MIC&quot; and has no microphone</h3>
<p>The webcam enumerates as <code>Hy-Usb2.0-1*MIC</code>, which reads like a combined
camera-and-microphone device. <code>lsusb -t</code> shows two interfaces, both <code>Class=Video</code>,
and <code>/proc/asound/cards</code> lists only the Intel HDA codec. There is no
camera-side microphone. The camera itself is fine: MJPEG at 1600×1200, 960×720
and 640×480.</p>
<h3>The firmware has broken ACPI tables</h3>
<p>Every boot logs exactly six ACPI errors:</p>
<pre><code>ACPI BIOS Error (bug): Failure creating named object [\_SB.PC00.RP09.PXSX._DSD], AE_ALREADY_EXISTS
ACPI BIOS Error (bug): Could not resolve symbol [\_SB.PC00.TXHC.RHUB.SS01], AE_NOT_FOUND
Bluetooth: hci0: No support for _PRR ACPI method
</code></pre>
<p>Six on this boot, six on the previous, six on the one before. They are CHUWI
firmware bugs, they are harmless, and they will alarm you if you go looking at
<code>journalctl -p 3</code> for the first time after changing something. Count them
across boots before blaming your own work.</p>
<h3>The firmware refuses Secure Boot dbx updates</h3>
<p><code>fwupd</code> offers a Microsoft UEFI revocation-database update and then reports:</p>
<pre><code>Update Error: System firmware cannot accept DBX updates
</code></pre>
<p>So there is effectively nothing installable via fwupd on this machine. Worth
setting up anyway, but don't expect it to do anything.</p>
<h3>Secure Boot blocks hibernation</h3>
<pre><code>lockdown:   none [integrity] confidentiality
secureboot: SecureBoot enabled
</code></pre>
<p>Secure Boot puts the kernel in <code>integrity</code> lockdown, and lockdown disables
hibernation outright. <code>/sys/power/state</code> shows <code>freeze mem</code> with <strong>no <code>disk</code></strong>,
no matter how correctly your swap is sized or how carefully <code>RESUME=</code> is set in
the initramfs. Your options are to disable Secure Boot in firmware or to do
without hibernate.</p>
<h3>But S3 deep sleep does work — and it's worth switching to</h3>
<p>This is the good news, and it's unusual for a recent Intel laptop:</p>
<pre><code>/sys/power/mem_sleep:  [s2idle] deep
</code></pre>
<p><code>deep</code> is offered and functional. Measured over ten minutes each on battery,
s2idle consumed a full 1% battery step while deep stayed below the measurement
floor entirely. Since <code>/sys/power/mem_sleep</code> is writable at runtime, switching
needs no bootloader edit at all:</p>
<pre><code class="language-ini">ExecStart=/bin/sh -c 'grep -qw deep /sys/power/mem_sleep &amp;&amp; echo deep &gt; /sys/power/mem_sleep'
</code></pre>
<p>With hibernate unavailable, this is the single most valuable power change on
the machine.</p>
<h3>Battery reports charge in 1% quanta</h3>
<p><code>charge_now</code> on this battery only moves in <strong>38000 µAh steps — exactly 1%</strong> of
its 3800 mAh design capacity. Any drain measurement shorter than an hour
resolves one or two steps and cannot support more than one significant figure.
Do not let a script print you &quot;5.98 %/hour&quot; from a ten-minute sample; it is
arithmetic, not measurement.</p>
<h3>The N150 has hardware video encoding — use it</h3>
<pre><code>VAProfileH264Main:  VAEntrypointEncSliceLP
VAProfileHEVCMain:  VAEntrypointEncSliceLP
</code></pre>
<p><code>EncSliceLP</code> is Intel's low-power encode path, available on <code>/dev/dri/renderD128</code>
once <code>intel-media-va-driver</code> is installed. On a 6 W part, software-encoding a
1920×1200 screen recording is not viable; VAAPI is close to free.</p>
<hr />
<h2>The machine itself: worth it</h2>
<p>This post is a long list of things that broke, so it would be easy to come away
with the wrong impression. Let me correct that, because the MiniBook X is a
genuinely excellent little computer and most of the problems above were
software drift, not hardware faults.</p>
<p><strong>It is completely silent, and it stays cold.</strong> There are <strong>zero fans</strong> in this
chassis — <code>/sys/class/hwmon/*/fan1_input</code> returns nothing to enumerate. Under a
load average of ~1.0 with Firefox, Thunderbird and Telegram all running, the
package temperature sits at <strong>34 °C</strong> against an ambient of 27.8 °C. Six
degrees over room temperature. You can work with it on your lap indefinitely,
and there is no fan noise because there is no fan.</p>
<p><strong>It is quick in the ways you actually notice.</strong> The N150 is a 6 W part, and on
paper that sounds like a compromise. In practice: <code>graphical.target</code> is reached
<strong>4.3 seconds</strong> into userspace, the shell prompt renders in <strong>27 ms</strong>, the
status bar starts in <strong>1 second</strong>, and applications launch without the pause
you brace for on low-power hardware. LPDDR5-6400 across two channels is doing
real work here — this is not the sluggish Atom experience the &quot;N&quot; prefix might
suggest.</p>
<p><strong>The battery genuinely lasts.</strong> Measured on battery with the full application
stack running: <strong>3.83 W</strong>, projecting <strong>7.6 hours</strong> from full on a 3800 mAh
cell. Idle at the desktop it drops to 3.57 W. And with S3 deep sleep enabled,
a closed lid consumed less than the battery's own 1% measurement resolution
across ten minutes — you can shut it and walk away without watching the charge
evaporate.</p>
<p><strong>The screen is the standout feature.</strong> 1920×1200 on a 10.5-inch panel works
out to <strong>216 PPI</strong>. For context:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>display</th>
<th>PPI</th>
</tr>
</thead>
<tbody>
<tr>
<td>CHUWI MiniBook X</td>
<td><strong>216</strong></td>
</tr>
<tr>
<td>MacBook Air 13&quot;</td>
<td>225</td>
</tr>
<tr>
<td>MacBook Pro 14&quot;</td>
<td>254</td>
</tr>
<tr>
<td>typical 24&quot; 1080p desktop monitor</td>
<td>92</td>
</tr>
</tbody>
</table></div>
<p>It is within four percent of a MacBook Air's density, on a machine that costs a
fraction as much. Text at 1.5× scale is genuinely crisp — the entire scaling
section of this post exists precisely <em>because</em> the panel is too good to run
unscaled. The 3:2 aspect ratio is also the right choice for a small screen:
more vertical space for code and documents than a 16:9 panel of the same
diagonal.</p>
<p><strong>The build quality is not what the price suggests.</strong> Aluminium chassis, a
360° hinge with no flex, a keyboard that is genuinely usable at this size, and
a touchscreen that works properly once you tell Wayland the panel is rotated.
It feels like a considerably more expensive machine than it is.</p>
<h3>So: should you buy one?</h3>
<p>Yes — with clear eyes about what you are buying.</p>
<p>The issues catalogued in this post are worth reading before you commit, and
they are almost all one-time setup costs rather than daily friction:</p>
<ul>
<li>Secure Boot blocks hibernation. <strong>S3 deep sleep works</strong>, which covers most of
the same ground, but you should know before you plan around hibernate.</li>
<li>The internal microphone ships with +60 dB of stacked gain and must be turned
down before it is usable on a call. One command, once.</li>
<li>The camera module is misleadingly named and has no microphone in it.</li>
<li>The firmware logs six harmless ACPI errors on every boot and refuses dbx
updates.</li>
<li>The panel is mounted rotated, so anything that draws to the screen — greeter,
lock screen, console — needs to be told about it.</li>
</ul>
<p>None of that is a defect in daily use. Every one of them is a thing you
configure once and never think about again. What you get in return is a
fanless, silent, genuinely portable machine with a near-Retina screen and
seven-plus hours of real battery life, running a full Wayland desktop
comfortably on 6 watts.</p>
<p>For SSH-ing into a build box, taking screenshots, writing, and light
development — the exact workload this was set up for — it is hard to think of
anything better at the price. It is a fantastic little machine and it is well
worth what it costs.</p>
<hr />
<h2>What the finished system looks like</h2>
<pre><code>~/.config/                          15 commits, 39 tracked files
├── hypr/
│   ├── hyprland.conf               277 lines — compositor, 80+ binds
│   ├── hyprlock.conf               109 lines — lock screen
│   ├── hypridle.conf                28 lines — idle policy
│   └── hyprpaper.conf                     — wallpaper
├── waybar/{config.jsonc,style.css}  bar + Tokyo Night CSS
├── fuzzel/fuzzel.ini                launcher
├── starship.toml                   214 lines — prompt (Nerd Font, truecolor)
├── starship-tty.toml               ASCII/16-colour prompt for the VT
├── foot/foot.ini                    terminal
├── mako/config                      notifications
├── swappy/config                    screenshot annotation
├── pcmanfm/, libfm/                 file manager + thumbnail settings
├── gtk-3.0/, gtk-4.0/               GTK settings (no gsettings on this box)
├── scripts/
│   ├── screenshot                   grim/slurp/swappy → clipboard + file
│   ├── record                       wf-recorder toggle, VAAPI
│   ├── hypr-keys                    keybinding cheatsheet (Super+/)
│   ├── sleep-test.sh                rtcwake harness with honest drain math
│   └── net-switch.sh                atomic ifupdown→NM migration w/ rollback
├── systemd-system/                  copies of 4 custom units, for VCS
├── greetd-system/                   copies of the greeter config, for VCS
├── home-dotfiles/                   copies of ~/.bashrc, ~/.profile
└── etc-console-setup/               copy of /etc/default/console-setup
</code></pre>
<p>Custom systemd units: <code>epp-power</code>, <code>powertop</code>, <code>wifi-powersave</code>,
<code>mem-sleep-deep</code>.</p>
<p><strong>Measured outcomes:</strong> 3.68 W → 3.57 W idle; s2idle → deep (S3) sleep; waybar
startup 25 s → 1 s; prompt renders in 27 ms; every application confirmed
Wayland-native.</p>
<hr />
<h2>The pattern</h2>
<p>Nineteen distinct problems were solved over the course of this build. Almost
none of them announced themselves.</p>
<ul>
<li>A binding that did nothing, because <code>PATH</code> lacked one directory</li>
<li>Icons that rendered as blank space, because a font family had a different
name than every tutorial claims</li>
<li>Glyphs stripped to empty strings in transit, twice</li>
<li>A stylesheet entirely discarded over one unsupported unit</li>
<li>A wallpaper daemon that logged one line and gave up</li>
<li>A workspace rule that parses cleanly and does nothing</li>
<li>A menu item pointing at a program that isn't installed</li>
<li>A 25-second startup delay caused by a display name from a session that no
longer existed</li>
<li>Fabricated precision from a quantised sensor</li>
<li>A permission error read as an empty file</li>
<li>A <code>usermod</code> that added no groups because one name was invalid</li>
<li>A <code>pkill</code> that killed the shell running it</li>
<li>A <code>setupcon --force</code> that exited 0 and regenerated nothing</li>
<li>Config keys placed in the wrong INI section, discarded without comment</li>
<li>A config file silently overwritten by the program that reads it</li>
<li>A microphone at +60 dB reporting healthy, fully-clipped audio</li>
<li>A USB device whose name advertises a microphone it does not have</li>
</ul>
<p>The tools were not lying. They were saying nothing at all, and nothing is
indistinguishable from success until you go looking.</p>
<p>Three habits did most of the work:</p>
<p><strong>Verify the effect, not the syntax.</strong> <code>hyprctl configerrors</code> being empty means
the parser was satisfied. It says nothing about whether the workspace exists,
the wallpaper drew, or the binding fired. Every claim in this post that
survived was one where the <em>outcome</em> was observed — a screenshot taken, a
process listed, a file's byte structure parsed.</p>
<p><strong>Test in the real environment.</strong> The screenshot binding worked every time I
tested it, because I was testing in my shell and not in Hyprland's. The
greeter's compositor could never be verified while a live session held DRM
master. Where the real environment couldn't be reached, the honest move was to
say so and harden the failure path instead of claiming success.</p>
<p><strong>When you can see it, look at it.</strong> fuzzel, gtkgreet and hyprlock are all
Wayland clients that can be screenshotted. Every visual decision here — the
invisible selection colour, the crushed background gradient, the orphaned
powerline arrows, the undersized lock panel, the fake ghost glyphs — was caught
by rendering the thing and looking at it, not by reasoning about the config.</p>
<p>The single most expensive mistake, the lockdead reboot, came from breaking that
last rule in the most direct way possible: I tested a lock screen by locking a
screen someone was using.</p>
<hr />
<h2>What is still outstanding</h2>
<p>In the spirit of the rest of this post, the things that are <em>not</em> done:</p>
<p><strong>There is no off-machine backup.</strong> The configuration is in git — fifteen
commits — but <code>git remote</code> returns nothing. It lives on exactly one encrypted
NVMe. Every silent failure catalogued here was recoverable; a dead disk would
not be. This is the highest-value remaining task and it takes about a minute.</p>
<p><strong>No automatic security updates.</strong> <code>unattended-upgrades</code> is not installed, on a
laptop that roams onto networks it does not control.</p>
<p><strong>No on-screen keyboard, on a 360° convertible.</strong> Fold it into tablet mode and
there is no way to type — including at the greeter and the lock screen, which
would strand you. <code>wvkbd</code> is the Wayland answer, and it needs wiring into
hyprlock and gtkgreet specifically, not just the desktop session.</p>
<p><strong>The sleep drain figure is one significant figure at best.</strong> Ten-minute
samples resolve a single 1% battery step. An hour-long test would give a real
number; the comparison between s2idle and deep stands regardless, because both
ran under identical conditions.</p>
<p><strong>Secure Boot versus hibernation is an open decision</strong>, deliberately left to the
owner. With S3 working, the case for disabling Secure Boot is much weaker than
it was.</p>
<p><strong>hyprlock looks fragile in this build.</strong> It aborted with
<code>corrupted double-linked list</code> when a second instance was refused the session
lock — and it aborted with the same message during a harmless config parse
check with no display attached. It locks and unlocks reliably in normal use,
and <code>allow_session_lock_restore</code> now makes a failure recoverable rather than
terminal, but it is worth knowing.</p>
<p><strong>The LUKS passphrase prompt is still 8×16.</strong> Deliberate: Debian ships no
console-setup initramfs hook, and hand-writing one on an encrypted-root machine
risks an unbootable system.</p>
<p>Two things were checked and needed nothing: <code>fstrim.timer</code> is enabled and
active (correct, given <code>discard</code> on the LUKS volume), and the firmware has no
installable updates.</p>
<hr />
<h2>Before you copy anything: what to substitute</h2>
<p>Paths and device names in this post are from one machine. Four things need
changing, and one of them is not optional.</p>
<p><strong><code>/home/YOUR-USER</code></strong> appears in <code>hyprland.conf</code> and <code>hyprpaper.conf</code>. These
must be <strong>absolute paths</strong> — Hyprland does not expand <code>$HOME</code> in <code>env =</code> or in
<code>$variable</code> definitions, and hyprpaper 0.8.4 does not expand <code>~</code> in a
<code>wallpaper { path = }</code> block either. That last one fails silently: you get one
log line about the monitor having no target, and no wallpaper. Substitute your
actual username rather than trying to make <code>~</code> work.</p>
<p>(<code>hyprlock.conf</code> is the exception — it <em>does</em> expand <code>~</code> in its <code>background</code>
path.)</p>
<p><strong><code>DSI-1</code></strong> is this panel's output name. It is the same on any MiniBook X, but
on other hardware run <code>hyprctl monitors</code> and use whatever <code>Monitor &lt;name&gt;</code>
reports.</p>
<p><strong><code>wlp0s20f3</code></strong> is this machine's wireless interface, referenced in the
<code>wifi-powersave</code> unit. Find yours with <code>ip -br link</code>.</p>
<p><strong><code>card0</code> / <code>/dev/dri/renderD128</code></strong> are the ALSA card index and the DRM render
node used in the microphone and screen-recording commands. Both are index 0 on
a single-GPU, single-sound-card laptop, which this is; confirm with
<code>aplay -l</code> and <code>ls /dev/dri/</code>.</p>
<p>Everything else — usernames in prompt examples, hostnames — is cosmetic.</p>
<hr />
<h2>Appendix — reproducing this from scratch</h2>
<p>An ordered runbook. Everything here was done on this machine; the parts that
predate the desktop build (the Debian install itself, backports, the console
rotation) are included because without them the rest does not work.</p>
<h3>1. The Debian install</h3>
<p>Debian 13 &quot;trixie&quot;, installed from the netinst image. At the <strong>software
selection</strong> step, everything graphical is deselected:</p>
<pre><code>[ ] Debian desktop environment
[ ] ... GNOME / KDE / Xfce / etc
[*] SSH server
[*] standard system utilities
</code></pre>
<p>That is the whole point — no display manager, no desktop, no half of GNOME
arriving as a dependency. You get a text console and a network stack, and every
graphical component that ends up on the machine is one you chose.</p>
<p>For partitioning: <strong>Guided — use entire disk and set up encrypted LVM</strong>. That
produces the layout this machine runs:</p>
<pre><code>nvme0n1
├─nvme0n1p1   976M  vfat         /boot/efi
├─nvme0n1p2   977M  ext4         /boot          (unencrypted; required for LUKS root)
└─nvme0n1p3   475G  crypto_LUKS
  └─nvme0n1p3_crypt  LVM2 volume group &quot;host-vg&quot;
    ├─host--vg-root    463.2G  ext4   /
    └─host--vg-swap_1   11.7G  swap   [SWAP]
</code></pre>
<p><code>/boot</code> has to sit outside the encrypted volume so the bootloader can read a
kernel and initramfs; everything else is inside. The resulting <code>crypttab</code> entry
matters later:</p>
<pre><code>nvme0n1p3_crypt UUID=... none luks,discard,x-initrd.attach
</code></pre>
<p><code>discard</code> enables TRIM through the LUKS layer (pair it with <code>fstrim.timer</code>,
which Debian enables by default), and <code>x-initrd.attach</code> unlocks the volume
early — which is what would make hibernate-to-encrypted-swap possible, if
Secure Boot were not blocking hibernation.</p>
<h3>2. Console rotation, before anything else</h3>
<p>This panel is mounted rotated. Until the kernel console is rotated, the text
console — your only interface at this point, and your escape hatch forever
after — is sideways.</p>
<pre><code class="language-bash">sudo nano /etc/default/grub
</code></pre>
<pre><code class="language-ini">GRUB_CMDLINE_LINUX_DEFAULT=&quot;quiet fbcon=rotate:1&quot;
</code></pre>
<pre><code class="language-bash">sudo update-grub
</code></pre>
<p><code>fbcon=rotate:1</code> rotates the framebuffer console 90° clockwise. It is
independent of anything Wayland does later — Hyprland's <code>transform = 3</code> handles
the graphical session, and this handles TTYs, the GRUB menu's aftermath, and
the boot messages.</p>
<p>Reboot and confirm the console is readable before continuing.</p>
<h3>3. A readable console font</h3>
<p>At 216 DPI the default <code>Fixed 8x16</code> is 5.3 points. Fix it now, while the
console is still the primary interface:</p>
<pre><code class="language-bash">sudo nano /etc/default/console-setup
</code></pre>
<pre><code class="language-ini">FONTFACE=&quot;TerminusBold&quot;
FONTSIZE=&quot;16x32&quot;
</code></pre>
<pre><code class="language-bash">sudo setupcon --save --force     # --force alone silently does nothing
</code></pre>
<h3>4. Enable backports</h3>
<p>Hyprland is not in Debian stable:</p>
<pre><code class="language-bash">echo 'deb http://deb.debian.org/debian trixie-backports main' \
    | sudo tee /etc/apt/sources.list.d/trixie-backports.list
sudo apt update
</code></pre>
<h3>5. Install the compositor and the desktop</h3>
<p><code>hyprland</code> exists only in backports, so it would resolve there on its own. Pass
<code>-t trixie-backports</code> anyway: the flag is what allows its <strong>dependencies</strong> to be
satisfied from backports too. mesa, PipeWire, WirePlumber and libxkbcommon all
exist in stable at older versions, and without the flag you get a new compositor
on an old graphics stack:</p>
<pre><code class="language-bash">sudo apt install -t trixie-backports hyprland xdg-desktop-portal-hyprland
</code></pre>
<p>Then everything else — see the install appendix below for the full command and
which packages resolve to backports on their own.</p>
<h3>6. Group membership</h3>
<pre><code class="language-bash">sudo usermod -aG video $USER      # camera and DRM access
</code></pre>
<p>Log out and back in for it to take effect. Note that <code>usermod -aG a,b,c</code> aborts
entirely if any one group does not exist — issue them separately if unsure.</p>
<h3>7. Fonts</h3>
<pre><code class="language-bash">mkdir -p ~/.local/share/fonts &amp;&amp; cd ~/.local/share/fonts
base=https://github.com/romkatv/powerlevel10k-media/raw/master
for style in Regular Bold Italic &quot;Bold%20Italic&quot;; do
    curl -fsSL &quot;$base/MesloLGS%20NF%20${style}.ttf&quot; \
         -o &quot;MesloLGS NF ${style//%20/ }.ttf&quot;
done
fc-cache -f ~/.local/share/fonts
</code></pre>
<h3>8. The four systemd units</h3>
<p>None of these ship with anything; all are hand-written to
<code>/etc/systemd/system/</code>, then <code>sudo systemctl enable --now &lt;name&gt;</code>.</p>
<pre><code class="language-ini"># epp-power.service — intel_pstate energy preference
[Unit]
Description=Set intel_pstate energy_performance_preference to power
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'for f in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo power &gt; &quot;$f&quot;; done'
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
</code></pre>
<pre><code class="language-ini"># mem-sleep-deep.service — S3 instead of s2idle
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'grep -qw deep /sys/power/mem_sleep &amp;&amp; echo deep &gt; /sys/power/mem_sleep'
RemainAfterExit=yes
</code></pre>
<pre><code class="language-ini"># wifi-powersave.service
[Service]
Type=oneshot
ExecStart=/usr/sbin/iw dev wlp0s20f3 set power_save on
RemainAfterExit=yes
</code></pre>
<pre><code class="language-ini"># powertop.service — runtime PM for PCI/SATA/audio
[Service]
Type=oneshot
ExecStart=/usr/sbin/powertop --auto-tune
RemainAfterExit=yes
</code></pre>
<p>Also <code>sudo systemctl disable --now bluetooth</code> if you have no paired devices,
and <code>sudo systemctl enable --now thermald</code>.</p>
<h3>9. The greeter</h3>
<p>Four files, all under <code>/etc/greetd/</code>, plus a systemd drop-in:</p>
<ul>
<li><code>config.toml</code> — <code>vt = 7</code>, and
<code>command = &quot;start-hyprland -- --config /etc/greetd/hyprland.conf&quot;</code>.
Launching <code>Hyprland</code> directly makes it raise a warning into its on-screen
error overlay.</li>
<li><code>hyprland.conf</code> — a minimal compositor carrying the same
<code>monitor = DSI-1, preferred, auto, 1.5, transform, 3</code>,
<code>input:touchdevice:transform = 3</code>, and <code>env = GTK_THEME,Adwaita:dark</code>, whose
only <code>exec-once</code> runs gtkgreet and then exits.</li>
<li><code>gtkgreet.css</code> — theming. Remember GTK CSS rejects percentage units and
discards the entire stylesheet on one bad declaration.</li>
<li><code>environments</code> — one line: <code>start-hyprland</code>.</li>
</ul>
<pre><code class="language-bash">sudo usermod -aG video _greetd
sudo usermod -aG input _greetd
sudo usermod -aG render _greetd
</code></pre>
<p>The background must live somewhere <code>_greetd</code> can read — <code>/usr/share/backgrounds/</code>,
not your home directory, which is mode 0700.</p>
<p>And the safety net, at
<code>/etc/systemd/system/greetd.service.d/10-restart-limit.conf</code>:</p>
<pre><code class="language-ini">[Unit]
StartLimitIntervalSec=60
StartLimitBurst=3
[Service]
RestartSec=2
</code></pre>
<p>greetd defaults to <code>Restart=always</code> with a one-second delay; without this, a
broken greeter respawns forever and seizes VT 7 each time, making it very hard
to reach a console to fix it.</p>
<h3>10. Networking</h3>
<p>If the installer left you on ifupdown (a hand-rolled <code>wpa_supplicant</code> plus
<code>dhcpcd</code>), migrating to NetworkManager gives you roaming, <code>nmtui</code>, and a
working click target in the status bar. The PSK is already on disk in
<code>/etc/network/interfaces</code> — <strong>mode 0600, so read it with <code>sudo</code></strong>; a plain
<code>cat</code> fails with permission denied and prints nothing, which is easy to
misread as an empty file.</p>
<p>Write the NetworkManager profile <em>before</em> tearing down the old stack, and have
the migration roll itself back if connectivity does not return.</p>
<h3>11. The microphone</h3>
<p>Non-negotiable before any video call on this hardware:</p>
<pre><code class="language-bash">amixer -c 0 sset &quot;Internal Mic Boost&quot; 0
wpctl set-volume @DEFAULT_AUDIO_SOURCE@ 0.40
sudo alsactl store
</code></pre>
<h3>12. Verify, rather than assume</h3>
<pre><code class="language-bash">hyprctl configerrors                       # empty
cat /sys/power/mem_sleep                   # s2idle [deep]
cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference   # power
systemctl --failed                         # 0 units
systemctl is-active greetd thermald powertop mem-sleep-deep
wpctl status                               # a sink and a source
v4l2-ctl -d /dev/video0 --stream-mmap --stream-count=1 --stream-to=/tmp/f.raw
</code></pre>
<p>Then reboot once and confirm the greeter comes up on its own. Everything in
this post that broke, broke because something was assumed rather than checked.</p>
<hr />
<h2>Appendix — everything installed, and the backports situation</h2>
<h3>Why backports is involved at all</h3>
<p>Debian 13 &quot;trixie&quot; is a stable release, and stable does not carry Hyprland or
its satellite tools. They live in <strong>trixie-backports</strong> — packages built from
testing/unstable and recompiled against stable's libraries.</p>
<p>On this machine backports was already enabled before the desktop build started,
so nothing in this post required editing apt sources. For anyone reproducing it
from a clean Debian 13, this is the setup:</p>
<pre><code class="language-bash">echo 'deb http://deb.debian.org/debian trixie-backports main' \
    | sudo tee /etc/apt/sources.list.d/trixie-backports.list
sudo apt update
</code></pre>
<p>The full source list on this machine, for reference:</p>
<pre><code># /etc/apt/sources.list
deb http://deb.debian.org/debian/ trixie main non-free-firmware
deb http://security.debian.org/debian-security trixie-security main non-free-firmware
deb http://deb.debian.org/debian/ trixie-updates main non-free-firmware

# /etc/apt/sources.list.d/trixie-backports.list
deb http://deb.debian.org/debian trixie-backports main
</code></pre>
<h3>The mechanic worth understanding: priority 100</h3>
<p>Backports is marked <code>NotAutomatic</code>, which apt translates into <strong>priority 100</strong>
— below the default 500 of the normal archives:</p>
<pre><code>100 http://deb.debian.org/debian trixie-backports/main amd64 Packages
    release o=Debian Backports,a=stable-backports,n=trixie-backports,...
500 http://deb.debian.org/debian trixie-updates/main amd64 Packages
</code></pre>
<p>That has two consequences, and they are easy to get backwards:</p>
<p><strong>Enabling backports will not silently upgrade anything.</strong> Nothing already
installed from stable gets pulled forward. <code>fwupd</code> on this machine exists in
<em>both</em> archives and stayed on the stable version (<code>2.0.20-1~deb13u1</code>) precisely
because 500 beats 100. This is the whole point of the design — you opt in per
package, not wholesale.</p>
<p><strong>But you do not always need <code>-t</code>.</strong> If a package exists <em>only</em> in backports,
its version is the only candidate and apt installs it without argument. That is
why <code>apt install hyprlock</code> worked here with no flags: there is no <code>hyprlock</code> in
trixie main at all.</p>
<p>You need <code>-t trixie-backports</code> when the package exists in both and you want the
newer one — and critically, that flag also lets apt satisfy <strong>dependencies</strong>
from backports. That is how the graphics stack on this machine ended up
modernised: installing Hyprland from backports dragged mesa 26.1.2, PipeWire
1.4.9, WirePlumber 0.5.12 and libxkbcommon 1.13.1 forward with it, all of which
do exist in stable at older versions.</p>
<p>Forty-eight packages on this system now come from backports. The load-bearing
ones:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>package</th>
<th>version</th>
<th>why</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>hyprland</code></td>
<td>0.55.2+ds-1~bpo13+1</td>
<td>not in stable</td>
</tr>
<tr>
<td><code>hyprlock</code></td>
<td>0.9.5-1~bpo13+1</td>
<td>not in stable</td>
</tr>
<tr>
<td><code>hypridle</code></td>
<td>0.1.7-2~bpo13+1</td>
<td>not in stable</td>
</tr>
<tr>
<td><code>hyprpaper</code></td>
<td>0.8.4-1~bpo13+1</td>
<td>not in stable</td>
</tr>
<tr>
<td><code>xdg-desktop-portal-hyprland</code></td>
<td>1.3.12-1~bpo13+1</td>
<td>not in stable</td>
</tr>
<tr>
<td><code>telegram-desktop</code></td>
<td>5.7.2+ds-2~bpo13+1</td>
<td>not in stable</td>
</tr>
<tr>
<td><code>mesa-*</code></td>
<td>26.1.2-1~bpo13+1</td>
<td>pulled as a Hyprland dependency</td>
</tr>
<tr>
<td><code>pipewire</code>, <code>wireplumber</code></td>
<td>1.4.9 / 0.5.12</td>
<td>pulled as a dependency</td>
</tr>
<tr>
<td><code>libxkbcommon*</code></td>
<td>1.13.1-1~bpo13+1</td>
<td>pulled as a dependency</td>
</tr>
</tbody>
</table></div>
<p>This is also, indirectly, the reason so much of this post is about version
drift: running a compositor from backports on a stable base means the software
is considerably newer than most of the documentation written about it.</p>
<h3>The full install, in one command</h3>
<p>Everything added during this build. Debian shipped <code>hyprland</code>, <code>waybar</code>,
<code>mako</code>, <code>foot</code>, <code>fuzzel</code>, <code>grim</code>, <code>slurp</code>, <code>wl-clipboard</code>, <code>brightnessctl</code>,
<code>powertop</code>, <code>jq</code>, <code>git</code>, <code>mosh</code>, <code>tmux</code> and <code>rsync</code> already:</p>
<pre><code class="language-bash">sudo apt update &amp;&amp; sudo apt install -y \
    hyprlock hypridle hyprpaper cliphist \
    greetd gtkgreet \
    network-manager thermald fwupd iw \
    imv zathura swappy pcmanfm \
    wf-recorder vainfo intel-media-va-driver \
    thunderbird telegram-desktop \
    starship \
    util-linux-extra v4l-utils alsa-utils libglib2.0-bin
</code></pre>
<p>Of those, four resolve to backports automatically because they have no stable
version — <code>hyprlock</code>, <code>hypridle</code>, <code>hyprpaper</code> and <code>telegram-desktop</code>. The rest
come from trixie main. No <code>-t</code> flag is needed for any of them.</p>
<p>The last line is diagnostics that earned their place during the build:</p>
<div class="table-scroll"><table>
<thead>
<tr>
<th>package</th>
<th>provides</th>
<th>used for</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>util-linux-extra</code></td>
<td><code>rtcwake</code></td>
<td>timed-wake suspend testing</td>
</tr>
<tr>
<td><code>v4l-utils</code></td>
<td><code>v4l2-ctl</code></td>
<td>proving the camera captures frames</td>
</tr>
<tr>
<td><code>alsa-utils</code></td>
<td><code>amixer</code>, <code>alsactl</code></td>
<td>finding and fixing +60 dB of mic gain</td>
</tr>
<tr>
<td><code>libglib2.0-bin</code></td>
<td><code>gdbus</code></td>
<td>invoking the FileChooser portal directly</td>
</tr>
</tbody>
</table></div>
<p>If you would rather not install the diagnostics permanently, everything except
<code>alsa-utils</code> can be removed afterwards — but <code>alsactl</code> is what persists the
mixer settings across reboots, so that one should stay.</p>
<h3>Fonts, which are not packaged</h3>
<p>MesloLGS NF is not in Debian. Installed per-user, no root required:</p>
<pre><code class="language-bash">mkdir -p ~/.local/share/fonts &amp;&amp; cd ~/.local/share/fonts
base=https://github.com/romkatv/powerlevel10k-media/raw/master
for style in Regular Bold Italic &quot;Bold%20Italic&quot;; do
    curl -fsSL &quot;$base/MesloLGS%20NF%20${style}.ttf&quot; \
         -o &quot;MesloLGS NF ${style//%20/ }.ttf&quot;
done
fc-cache -f ~/.local/share/fonts
</code></pre>
<p>Debian's <code>fonts-font-awesome</code> is worth having as well, but note the trap from
Part 2: it installs a family named <code>FontAwesome</code>, not <code>Font Awesome 6 Free</code>.</p>
<hr />
<h2>Appendix — the finished <code>hyprland.conf</code></h2>
<p>The thirteen lines at the top of this post grew into this. Reproduced in full,
because most of the comments exist to record <em>why</em> a line is the way it is —
which silent failure it prevents, or which 0.55 syntax change it survives. This
file and every other one below are in the repo:
<a href="https://github.com/mattnovakemail/minibook-x-hyprland">github.com/mattnovakemail/minibook-x-hyprland</a>.</p>
<pre><code class="language-ini">###############################################################################
# MONITOR  --  1200x1920 DSI panel, rotated 270 deg -&gt; 1920x1200 landscape
#
# The panel is 10.5&quot; at 1920x1200 = ~216 DPI, i.e. 2.25x a normal 96 DPI
# display, which is why everything renders tiny at scale 1. Scale 1.5 gives
# 1280x800 logical (an exact integer division, so no blurry resampling) and
# ~144 DPI effective. Every Wayland client scales with this -- GTK apps,
# Firefox, dialogs, cursors -- not just text.
###############################################################################
monitor = DSI-1, preferred, auto, 1.5, transform, 3

# Hyprland does not inherit the login shell's PATH, so ~/.local/bin is absent
# and any bind calling a script there silently does nothing.
# Telegram is Qt5, which defaults to xcb -&gt; XWayland -&gt; soft at 1.5x scale.
# &quot;;xcb&quot; is a fallback so a Qt app still starts if the wayland plugin fails.
env = QT_QPA_PLATFORM,wayland;xcb
env = QT_WAYLAND_DISABLE_WINDOWDECORATION,1
env = QT_AUTO_SCREEN_SCALE_FACTOR,1

env = XCURSOR_SIZE,24
env = HYPRCURSOR_SIZE,24
env = PATH,/home/YOUR-USER/.config/scripts:/home/YOUR-USER/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games

$scripts  = /home/YOUR-USER/.config/scripts
$mod      = SUPER
$terminal = foot
$menu     = fuzzel
$browser  = firefox

###############################################################################
# AUTOSTART
###############################################################################
# The systemd/dbus user manager outlives a session and keeps the OLD
# WAYLAND_DISPLAY, which makes xdg-desktop-portal-gtk fail to start and costs
# waybar a 25s timeout on every launch. Hand it the live session's values first.
exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE HYPRLAND_INSTANCE_SIGNATURE
exec-once = systemctl --user restart xdg-desktop-portal-gtk.service

exec-once = hyprpaper
exec-once = waybar
exec-once = mako
exec-once = pgrep -x hypridle &gt;/dev/null || hypridle    # singleton: global hardware
exec-once = pgrep -f &quot;type text --watch cliphist&quot; &gt;/dev/null || wl-paste --type text --watch cliphist store
exec-once = pgrep -f &quot;type image --watch cliphist&quot; &gt;/dev/null || wl-paste --type image --watch cliphist store

###############################################################################
# INPUT
###############################################################################
input {
	kb_layout = us
	follow_mouse = 1
	sensitivity = 0

	touchpad {
		natural_scroll = true
		tap-to-click = true
		drag_lock = true
		disable_while_typing = true
		scroll_factor = 0.6
	}

	# Touchscreen must match the panel rotation, or taps land in the wrong spot.
	touchdevice {
		transform = 3
	}
}

# Three fingers sideways = change desktop; four fingers = scratchpad.
gesture = 3, horizontal, workspace
gesture = 4, up, special, magic

###############################################################################
# LOOK
###############################################################################
general {
	gaps_in = 3
	gaps_out = 5
	border_size = 2
	col.active_border = rgba(7aa2f7ff) rgba(bb9af7ff) 45deg
	col.inactive_border = rgba(2a2f41aa)
	resize_on_border = true
	layout = dwindle
}

decoration {
	rounding = 6

	blur {
		enabled = true
		size = 4
		passes = 2
	}

	shadow {
		enabled = true
		range = 12
		render_power = 3
		color = rgba(00000055)
	}
}

animations {
	enabled = true
	bezier = snap, 0.05, 0.9, 0.1, 1.05
	animation = windows, 1, 4, snap, popin 80%
	animation = border, 1, 8, default
	animation = fade, 1, 5, default
	animation = workspaces, 1, 4, snap, slide
}

dwindle {
	preserve_split = true
	smart_resizing = true
}

misc {
	# If the lock screen ever dies while holding the session lock, Hyprland
	# normally leaves the session locked with no locker (&quot;lockdead&quot;) and the
	# only way out is a reboot. This lets a freshly launched hyprlock adopt the
	# orphaned lock instead. Costs nothing; saves a forced reboot.
	allow_session_lock_restore = true

	force_default_wallpaper = 0
	disable_hyprland_logo = true
	background_color = rgb(1a1b26)
	focus_on_activate = true
}

###############################################################################
# WINDOW RULES
###############################################################################
# Hyprland 0.55 rule syntax: &lt;field&gt; = &lt;value&gt;, match:&lt;field&gt; = &lt;value&gt;
windowrule = suppress_event = maximize, match:class = .*
windowrule = float = true, match:class = ^(pavucontrol|nm-connection-editor|blueman-manager)$
windowrule = float = true, match:title = ^(Open File|Save File|Choose Files|Open Folder)$
windowrule = float = true, match:class = ^(foot-float)$
windowrule = size = 60% 55%, match:class = ^(foot-float)$
windowrule = center = true, match:class = ^(foot-float)$
# Give the big apps their own desktops -- at 1280x800 logical, tiling four
# windows on one workspace leaves each ~628x374, which is unusable.
windowrule = workspace = 2, match:class = ^(firefox|firefox-esr)$
windowrule = workspace = 3, match:class = ^(thunderbird)$
windowrule = workspace = 4, match:class = ^(org\.telegram\.desktop)$

# Stop empty XWayland drag-shadow windows from stealing focus
windowrule = no_focus = true, match:class = ^$, match:title = ^$, match:xwayland = true, match:float = true, match:fullscreen = false, match:pin = false

###############################################################################
# KEYBINDS -- apps
###############################################################################
bind = $mod, Return, exec, $terminal
bind = $mod, D, exec, $menu
bind = $mod, B, exec, $browser
bind = $mod, E, exec, pcmanfm        # file manager
bind = $mod SHIFT, Return, exec, $terminal --app-id=foot-float

###############################################################################
# KEYBINDS -- window management
###############################################################################
bind = $mod, Q, killactive                          # close the focused window
bind = $mod, F, fullscreen, 0                       # true fullscreen
bind = $mod SHIFT, F, fullscreen, 1                 # maximize (keeps bar + gaps)
bind = $mod CTRL, F, fullscreenstate, -1, 2         # fake fullscreen (games)
bind = $mod, V, togglefloating
bind = $mod SHIFT, space, togglefloating
bind = $mod, P, pseudo
bind = $mod, Y, layoutmsg, togglesplit
bind = $mod, C, centerwindow
bind = $mod, T, pin                                 # keep floating window on top

# Move focus
bind = $mod, left,  movefocus, l
bind = $mod, right, movefocus, r
bind = $mod, up,    movefocus, u
bind = $mod, down,  movefocus, d
bind = $mod, H, movefocus, l
bind = $mod, K, movefocus, u
bind = $mod, L, movefocus, r
bind = $mod, J, movefocus, d

# Move the window itself
bind = $mod SHIFT, left,  movewindow, l
bind = $mod SHIFT, right, movewindow, r
bind = $mod SHIFT, up,    movewindow, u
bind = $mod SHIFT, down,  movewindow, d
bind = $mod SHIFT, H, movewindow, l
bind = $mod SHIFT, K, movewindow, u
bind = $mod SHIFT, L, movewindow, r
bind = $mod SHIFT, J, movewindow, d

# Resize (held down = repeats)
binde = $mod ALT, left,  resizeactive, -40 0
binde = $mod ALT, right, resizeactive, 40 0
binde = $mod ALT, up,    resizeactive, 0 -40
binde = $mod ALT, down,  resizeactive, 0 40
binde = $mod ALT, H, resizeactive, -40 0
binde = $mod ALT, K, resizeactive, 0 -40
binde = $mod ALT, L, resizeactive, 40 0
binde = $mod ALT, J, resizeactive, 0 40

# Mouse: SUPER + left-drag moves, SUPER + right-drag resizes
bindm = $mod, mouse:272, movewindow
bindm = $mod, mouse:273, resizewindow

###############################################################################
# KEYBINDS -- 5 desktops
###############################################################################
bind = $mod, 1, workspace, 1
bind = $mod, 2, workspace, 2
bind = $mod, 3, workspace, 3
bind = $mod, 4, workspace, 4
bind = $mod, 5, workspace, 5

# Send the window to a desktop and follow it
bind = $mod SHIFT, 1, movetoworkspace, 1
bind = $mod SHIFT, 2, movetoworkspace, 2
bind = $mod SHIFT, 3, movetoworkspace, 3
bind = $mod SHIFT, 4, movetoworkspace, 4
bind = $mod SHIFT, 5, movetoworkspace, 5

# Send the window to a desktop and stay put
bind = $mod CTRL, 1, movetoworkspacesilent, 1
bind = $mod CTRL, 2, movetoworkspacesilent, 2
bind = $mod CTRL, 3, movetoworkspacesilent, 3
bind = $mod CTRL, 4, movetoworkspacesilent, 4
bind = $mod CTRL, 5, movetoworkspacesilent, 5

# Cycle desktops
bind = $mod, Tab, workspace, e+1
bind = $mod SHIFT, Tab, workspace, e-1
bind = $mod CTRL, right, workspace, e+1
bind = $mod CTRL, left, workspace, e-1
bind = $mod, grave, workspace, previous
bind = $mod, mouse_down, workspace, e+1
bind = $mod, mouse_up, workspace, e-1

# Scratchpad
bind = $mod, S, togglespecialworkspace, magic
bind = $mod ALT, S, movetoworkspace, special:magic

###############################################################################
# KEYBINDS -- screenshots (region -&gt; clipboard AND ~/Pictures/Screenshots)
###############################################################################
bind = $mod SHIFT, S, exec, $scripts/screenshot region
bind = $mod CTRL SHIFT, S, exec, $scripts/screenshot region --edit    # annotate before saving
bind = , Print, exec, $scripts/screenshot screen
bind = SHIFT, Print, exec, $scripts/screenshot window
bind = $mod, Print, exec, $scripts/screenshot window

###############################################################################
# KEYBINDS -- hardware keys
###############################################################################
bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+
bindel = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
bindl  = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
bindl  = , XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle
bindel = , XF86MonBrightnessUp, exec, brightnessctl -e4 -n2 set 5%+
bindel = , XF86MonBrightnessDown, exec, brightnessctl -e4 -n2 set 5%-

# Lid closed -&gt; lock + screen off, lid open -&gt; screen on
bindl = , switch:on:Lid Switch, exec, loginctl lock-session &amp;&amp; hyprctl dispatch dpms off
bindl = , switch:off:Lid Switch, exec, hyprctl dispatch dpms on

###############################################################################
# KEYBINDS -- session
###############################################################################
bind = $mod, Escape, exec, loginctl lock-session      # lock the screen
bind = $mod SHIFT, V, exec, cliphist list | fuzzel --dmenu --width 70 | cliphist decode | wl-copy
bind = $mod CTRL SHIFT, V, exec, cliphist wipe &amp;&amp; notify-send &quot;Clipboard history cleared&quot;
# Screen recording (toggle -- press again to stop and finalise the file)
bind = $mod ALT, R, exec, $scripts/record screen
bind = $mod ALT SHIFT, R, exec, $scripts/record region
bind = $mod ALT, A, exec, $scripts/record screen --audio

bind = $mod SHIFT, R, exec, hyprctl reload
bind = $mod SHIFT, Q, exit                          # quit Hyprland
bind = $mod SHIFT, E, exit
bind = $mod, slash, exec, $terminal --app-id=foot-float -e $scripts/hypr-keys
</code></pre>
<hr />
<h2>Appendix — every remaining config file</h2>
<p>The full <code>hyprland.conf</code> is in the appendix above. These are the rest, verbatim
from the running system, so the guide is self-contained. Remember to substitute
<code>/home/YOUR-USER</code> where it appears.</p>
<h3>Compositor</h3>
<p><strong><code>~/.config/hypr/hyprlock.conf</code></strong></p>
<pre><code class="language-ini">###############################################################################
# hyprlock -- lock screen
#
# Styled to match the fuzzel launcher (Super+D): a translucent #1a1b26 panel
# with a 2px iris border and 14px radius, MesloLGS NF throughout, purple
# accents.
#
# NOTE: hyprlock draws in PHYSICAL pixels and ignores the compositor's 1.5x
# scale -- measured, a 440px panel came out 440px wide on the 1920px panel.
# So every size here is ~1.5x what the same element would be in a scaled
# Wayland client like fuzzel or waybar.
###############################################################################

grace = 2                        # 2s to abort the lock by moving/typing

general {
	hide_cursor = true
	ignore_empty_input = true
}

auth {
	pam {
		enabled = true
	}
}

background {
	monitor =
	path = ~/Pictures/wallpaper.png
	blur_passes = 3
	blur_size = 8
	brightness = 0.7
	contrast = 0.9
	vibrancy = 0.17
}

# The panel, matching fuzzel's frame. Declared first so everything else
# renders on top of it.
shape {
	monitor =
	size = 780, 430
	color = rgba(1a1b26f2)
	rounding = 14
	border_size = 2
	border_color = rgba(bb9af7ff)
	position = 0, 0
	halign = center
	valign = center
}

# Clock
label {
	monitor =
	text = cmd[update:1000] date +&quot;%H:%M&quot;
	color = rgba(c0caf5ff)
	font_size = 92
	font_family = MesloLGS NF Bold
	position = 0, 108
	halign = center
	valign = center
}

# Date
label {
	monitor =
	text = cmd[update:60000] date +&quot;%A, %d %B&quot;
	color = rgba(bb9af7ff)
	font_size = 22
	font_family = MesloLGS NF
	position = 0, 34
	halign = center
	valign = center
}

# Who you are, and how much charge is left
label {
	monitor =
	text = cmd[update:30000] printf '%s  ·  %s%%' &quot;$USER&quot; &quot;$(cat /sys/class/power_supply/BAT0/capacity)&quot;
	color = rgba(565f89ff)
	font_size = 16
	font_family = MesloLGS NF
	position = 0, 2
	halign = center
	valign = center
}

# Password field -- fuzzel's input frame: iris border, 14px radius,
# translucent #1a1b26 fill
input-field {
	monitor =
	size = 580, 66
	outline_thickness = 2
	dots_size = 0.24
	dots_spacing = 0.32
	dots_center = true
	outer_color = rgba(bb9af7ff)
	inner_color = rgba(1a1b26f2)
	font_color = rgba(c0caf5ff)
	font_family = MesloLGS NF
	fade_on_empty = false
	placeholder_text = &lt;span foreground=&quot;##565f89&quot;&gt;type to unlock&lt;/span&gt;
	fail_color = rgba(f7768eff)
	fail_text = &lt;span foreground=&quot;##f7768e&quot;&gt;$FAIL  ($ATTEMPTS)&lt;/span&gt;
	check_color = rgba(7aa2f7ff)
	rounding = 14
	position = 0, -110
	halign = center
	valign = center
}
</code></pre>
<p><strong><code>~/.config/hypr/hypridle.conf</code></strong></p>
<pre><code class="language-ini">###############################################################################
# hypridle -- idle management
#
# Deliberately gentle. A dim that looks like a dead screen, or a surprise
# lock, is worse than a slightly warmer battery.
###############################################################################
general {
	lock_cmd = pidof hyprlock || hyprlock          # never stack two lockers
	before_sleep_cmd = loginctl lock-session
	after_sleep_cmd = hyprctl dispatch dpms on
}

# 10 min -- dim, but stay clearly readable. Any input restores.
listener {
	timeout = 600
	on-timeout = brightnessctl -s set 30%
	on-resume = brightnessctl -r
}

# 20 min -- screen off
listener {
	timeout = 1200
	on-timeout = hyprctl dispatch dpms off
	on-resume = hyprctl dispatch dpms on
}

# No auto-lock and no auto-suspend. Lock manually with Super+Escape.
# Closing the lid still locks (see hyprland.conf).
</code></pre>
<p><strong><code>~/.config/hypr/hyprpaper.conf</code></strong></p>
<pre><code class="language-ini">splash = false

wallpaper {
	monitor = DSI-1
	path = /home/YOUR-USER/Pictures/wallpaper.png
	fit_mode = cover
}
</code></pre>
<h3>Bar, launcher, notifications</h3>
<p><strong><code>~/.config/waybar/config.jsonc</code></strong></p>
<pre><code class="language-jsonc">{
	&quot;layer&quot;: &quot;top&quot;,
	&quot;position&quot;: &quot;top&quot;,
	&quot;height&quot;: 28,
	&quot;spacing&quot;: 6,

	&quot;modules-left&quot;:   [&quot;hyprland/workspaces&quot;, &quot;hyprland/submap&quot;],
	&quot;modules-center&quot;: [&quot;hyprland/window&quot;],
	&quot;modules-right&quot;:  [&quot;custom/recording&quot;, &quot;tray&quot;, &quot;wireplumber&quot;, &quot;backlight&quot;, &quot;network&quot;, &quot;battery&quot;, &quot;clock&quot;],

	&quot;hyprland/workspaces&quot;: {
		&quot;format&quot;: &quot;{icon}&quot;,
		&quot;format-icons&quot;: {
			&quot;1&quot;: &quot;1&quot;, &quot;2&quot;: &quot;2&quot;, &quot;3&quot;: &quot;3&quot;, &quot;4&quot;: &quot;4&quot;, &quot;5&quot;: &quot;5&quot;,
			&quot;special&quot;: &quot;&quot;
		},
		&quot;persistent-workspaces&quot;: { &quot;*&quot;: 5 },
		&quot;on-click&quot;: &quot;activate&quot;,
		&quot;on-scroll-up&quot;: &quot;hyprctl dispatch workspace e+1&quot;,
		&quot;on-scroll-down&quot;: &quot;hyprctl dispatch workspace e-1&quot;
	},

	&quot;hyprland/window&quot;: {
		&quot;format&quot;: &quot;{title}&quot;,
		&quot;max-length&quot;: 60,
		&quot;separate-outputs&quot;: true
	},

	&quot;custom/recording&quot;: {
		&quot;exec&quot;: &quot;pgrep -x wf-recorder &gt;/dev/null &amp;&amp; echo '{\&quot;text\&quot;:\&quot;REC\&quot;,\&quot;tooltip\&quot;:\&quot;recording -- Super+Alt+R to stop\&quot;}' || echo '{}'&quot;,
		&quot;return-type&quot;: &quot;json&quot;,
		&quot;interval&quot;: 2,
		&quot;format&quot;: &quot;{}&quot;,
		&quot;on-click&quot;: &quot;/home/YOUR-USER/.config/scripts/record&quot;
	},

	&quot;tray&quot;: { &quot;icon-size&quot;: 14, &quot;spacing&quot;: 8 },

	&quot;wireplumber&quot;: {
		&quot;format&quot;: &quot;{icon}  {volume}%&quot;,
		&quot;format-muted&quot;: &quot;  muted&quot;,
		&quot;format-icons&quot;: [&quot;&quot;, &quot;&quot;, &quot;&quot;],
		&quot;on-click&quot;: &quot;wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle&quot;,
		&quot;on-scroll-up&quot;: &quot;wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+&quot;,
		&quot;on-scroll-down&quot;: &quot;wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-&quot;
	},

	&quot;backlight&quot;: {
		&quot;device&quot;: &quot;intel_backlight&quot;,
		&quot;format&quot;: &quot;  {percent}%&quot;,
		&quot;on-scroll-up&quot;: &quot;brightnessctl -e4 -n2 set 5%+&quot;,
		&quot;on-scroll-down&quot;: &quot;brightnessctl -e4 -n2 set 5%-&quot;
	},

	&quot;network&quot;: {
		&quot;format-wifi&quot;: &quot;  {signalStrength}%&quot;,
		&quot;format-ethernet&quot;: &quot;  {ipaddr}&quot;,
		&quot;format-disconnected&quot;: &quot;  off&quot;,
		&quot;tooltip-format-wifi&quot;: &quot;{essid}  ({signalStrength}%)\n{ipaddr}&quot;,
		&quot;on-click&quot;: &quot;foot -e nmtui&quot;
	},

	&quot;battery&quot;: {
		&quot;states&quot;: { &quot;warning&quot;: 25, &quot;critical&quot;: 10 },
		&quot;format&quot;: &quot;{icon}  {capacity}%&quot;,
		&quot;format-charging&quot;: &quot;  {capacity}%&quot;,
		&quot;format-plugged&quot;: &quot;  {capacity}%&quot;,
		&quot;format-icons&quot;: [&quot;&quot;, &quot;&quot;, &quot;&quot;, &quot;&quot;, &quot;&quot;],
		&quot;tooltip-format&quot;: &quot;{timeTo}  ({power:.1f} W)&quot;
	},

	&quot;clock&quot;: {
		&quot;format&quot;: &quot;{:%a %d %b  %H:%M}&quot;,
		&quot;tooltip-format&quot;: &quot;&lt;tt&gt;&lt;small&gt;{calendar}&lt;/small&gt;&lt;/tt&gt;&quot;,
		&quot;calendar&quot;: {
			&quot;mode&quot;: &quot;month&quot;,
			&quot;format&quot;: { &quot;today&quot;: &quot;&lt;span color='#7aa2f7'&gt;&lt;b&gt;{}&lt;/b&gt;&lt;/span&gt;&quot; }
		}
	}
}
</code></pre>
<p><strong><code>~/.config/waybar/style.css</code></strong></p>
<pre><code class="language-css">* {
	font-family: &quot;JetBrains Mono&quot;, &quot;FontAwesome&quot;, monospace;
	font-size: 13px;
	min-height: 0;
	border: none;
	border-radius: 0;
}

window#waybar {
	background: rgba(26, 27, 38, 0.92);
	color: #c0caf5;
}

#workspaces button {
	padding: 0 11px;
	color: #565f89;
	background: transparent;
}

#workspaces button.active {
	color: #1a1b26;
	background: #7aa2f7;
	border-radius: 6px;
}

#workspaces button.urgent {
	color: #1a1b26;
	background: #f7768e;
	border-radius: 6px;
}

#workspaces button:hover {
	background: #2a2f41;
	color: #c0caf5;
	border-radius: 6px;
}

#window {
	color: #a9b1d6;
}

window#waybar.empty #window {
	background: transparent;
}

#clock,
#battery,
#backlight,
#network,
#wireplumber,
#tray {
	padding: 0 10px;
	color: #c0caf5;
}

#clock       { color: #7aa2f7; font-weight: bold; }
#backlight   { color: #e0af68; }
#wireplumber { color: #9ece6a; }
#network     { color: #7dcfff; }
#battery     { color: #9ece6a; }

#battery.charging { color: #73daca; }

#battery.warning:not(.charging) { color: #e0af68; }

#battery.critical:not(.charging) {
	color: #f7768e;
	animation: blink 1s steps(2, start) infinite;
}

@keyframes blink {
	to { color: #c0caf5; }
}

#custom-recording {
	color: #1a1b26;
	background: #f7768e;
	padding: 0 9px;
	margin: 4px 2px;
	border-radius: 6px;
	font-weight: bold;
	animation: blink 1.4s steps(2, start) infinite;
}
</code></pre>
<p><strong><code>~/.config/fuzzel/fuzzel.ini</code></strong></p>
<pre><code class="language-ini"># fuzzel -- Super+D launcher, Tokyo Night to match waybar / hyprlock / starship
[main]
font=MesloLGS NF:size=13
# The compositor runs at 1.5x. dpi-aware=no makes fuzzel scale with the output
# scale factor rather than computing its own DPI, which is what we want here.
dpi-aware=no
icon-theme=Adwaita
icons-enabled=yes
terminal=foot -e
# fzf-style subsequence matching: &quot;tgd&quot; finds Telegram Desktop
match-mode=fzf
use-bold=yes
prompt=&quot;󰍉  &quot;
placeholder=type to search
lines=9
width=44
horizontal-pad=26
vertical-pad=20
inner-pad=14
line-height=26
layer=overlay
exit-on-keyboard-focus-loss=yes

[colors]
background=1a1b26f2
text=a9b1d6ff
prompt=bb9af7ff
placeholder=565f89ff
input=c0caf5ff
match=7aa2f7ff
selection=3b4261ff
selection-text=c0caf5ff
selection-match=bb9af7ff
counter=565f89ff
border=bb9af7ff

[border]
width=2
radius=14
</code></pre>
<p><strong><code>~/.config/mako/config</code></strong></p>
<pre><code class="language-ini">font=JetBrains Mono 10
background-color=#1a1b26f2
text-color=#c0caf5
border-color=#7aa2f7
border-size=2
border-radius=6
padding=10
margin=8
default-timeout=5000
anchor=top-right
max-visible=5

[urgency=critical]
border-color=#f7768e
default-timeout=0
</code></pre>
<h3>Terminal and prompt</h3>
<p><strong><code>~/.config/foot/foot.ini</code></strong></p>
<pre><code class="language-ini">[main]
# MesloLGS NF is the typeface and supplies its own Nerd Font glyphs.
# JetBrains Mono stays on as a fallback for anything Meslo lacks.
font=MesloLGS NF:size=16, JetBrains Mono:size=16
pad=8x6

[colors]
alpha=1.0
background=1a1b26
foreground=c0caf5
regular0=15161e
regular1=f7768e
regular2=9ece6a
regular3=e0af68
regular4=7aa2f7
regular5=bb9af7
regular6=7dcfff
regular7=a9b1d6
bright0=414868
bright1=f7768e
bright2=9ece6a
bright3=e0af68
bright4=7aa2f7
bright5=bb9af7
bright6=7dcfff
bright7=c0caf5
selection-background=283457
selection-foreground=c0caf5

[cursor]
color=1a1b26 c0caf5
</code></pre>
<p><strong><code>~/.config/starship.toml</code></strong></p>
<pre><code class="language-toml"># ~/.config/starship.toml -- Tokyo Night, powerline, two-line.
# Designed for a ~100 column terminal at a large font: the top line carries the
# context, the bottom line is always a clean full-width runway for the command.

add_newline = true
palette = &quot;tokyonight&quot;
command_timeout = 900

format = &quot;&quot;&quot;
[](fg:abyss)\
$os\
$username\
[](bg:steel fg:abyss)\
$directory\
[](fg:steel)\
$git_branch$git_status\
$nodejs$python$rust$golang$lua$zig$java$docker_context\
$status$jobs\
$cmd_duration\
$line_break\
$character&quot;&quot;&quot;

[palettes.tokyonight]
iris   = &quot;#bb9af7&quot;
brand  = &quot;#4f9bff&quot;   # brand accent blue (unused while the pill is iris)
steel  = &quot;#3b4261&quot;
slate  = &quot;#2f3549&quot;
abyss  = &quot;#24283b&quot;
ink    = &quot;#1a1b26&quot;
snow   = &quot;#c0caf5&quot;
haze   = &quot;#565f89&quot;
azure  = &quot;#7aa2f7&quot;
mint   = &quot;#9ece6a&quot;
amber  = &quot;#e0af68&quot;
coral  = &quot;#ff9e64&quot;
rose   = &quot;#f7768e&quot;
sky    = &quot;#7dcfff&quot;
jade   = &quot;#73daca&quot;

###############################################################################
# Identity
###############################################################################
[os]
disabled = false
style = &quot;bg:abyss fg:iris&quot;
format = &quot;[ $symbol ]($style)&quot;

[os.symbols]
Debian = &quot;󰊠&quot;   # nf-md-ghost U+F02A0
#Debian = &quot;󰔉&quot;  # site accent glyph (nf-md-terrain U+F0509)
Ubuntu = &quot;󰕈&quot;
Arch = &quot;󰣇&quot;
Linux = &quot;󰊠&quot;
Macos = &quot;󰀵&quot;

[username]
show_always = false          # only speaks up when it matters: root, or over ssh
style_user = &quot;bg:abyss fg:iris&quot;
style_root = &quot;bg:rose fg:ink bold&quot;
format = &quot;[$user ]($style)&quot;

[hostname]
ssh_only = true
style = &quot;bg:iris fg:ink&quot;
format = &quot;[@$hostname ]($style)&quot;

###############################################################################
# Where you are
###############################################################################
[directory]
style = &quot;bg:steel fg:snow&quot;
format = &quot;[ $path ]($style)[$read_only]($read_only_style)&quot;
truncation_length = 3
truncation_symbol = &quot;…/&quot;
read_only = &quot; 󰌾&quot;
read_only_style = &quot;bg:steel fg:rose&quot;
home_symbol = &quot;󰋜&quot;

[directory.substitutions]
&quot;Documents&quot; = &quot;󰈙&quot;
&quot;Downloads&quot; = &quot;󰉍&quot;
&quot;Pictures&quot;  = &quot;󰋩&quot;
&quot;Music&quot;     = &quot;󰝚&quot;
&quot;Videos&quot;    = &quot;󰕧&quot;
&quot;.config&quot;   = &quot;󰒓&quot;
&quot;system&quot;    = &quot;󰒓&quot;

###############################################################################
# Git -- the segment that earns its space
###############################################################################
[git_branch]
symbol = &quot;&quot;
style = &quot;bg:slate fg:mint&quot;
format = &quot; [](fg:slate)[ $symbol $branch ]($style)&quot;
truncation_length = 20
truncation_symbol = &quot;…&quot;

[git_status]
style = &quot;bg:slate fg:amber&quot;
format = &quot;[$all_status$ahead_behind]($style)[](fg:slate)&quot;
conflicted = &quot;󰞇 ${count} &quot;
ahead      = &quot;󰁝${count} &quot;
behind     = &quot;󰁅${count} &quot;
diverged   = &quot;󰃻${ahead_count}󰁅${behind_count} &quot;
up_to_date = &quot;&quot;
untracked  = &quot;󰋗${count} &quot;
stashed    = &quot;󰏗${count} &quot;
modified   = &quot;󰏫${count} &quot;
staged     = &quot;󰐗${count} &quot;
renamed    = &quot;󰑕${count} &quot;
deleted    = &quot;󰍶${count} &quot;

###############################################################################
# Toolchains -- each only appears in a project that actually uses it
###############################################################################
[nodejs]
symbol = &quot;󰎙&quot;
style = &quot;bg:abyss fg:mint&quot;
format = &quot; [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)&quot;

[python]
symbol = &quot;󰌠&quot;
style = &quot;bg:abyss fg:amber&quot;
format = &quot; [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)&quot;
pyenv_version_name = true

[rust]
symbol = &quot;󱘗&quot;
style = &quot;bg:abyss fg:coral&quot;
format = &quot; [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)&quot;

[golang]
symbol = &quot;󰟓&quot;
style = &quot;bg:abyss fg:sky&quot;
format = &quot; [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)&quot;

[lua]
symbol = &quot;󰢱&quot;
style = &quot;bg:abyss fg:azure&quot;
format = &quot; [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)&quot;

[zig]
symbol = &quot;&quot;
style = &quot;bg:abyss fg:coral&quot;
format = &quot; [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)&quot;

[java]
symbol = &quot;󰬷&quot;
style = &quot;bg:abyss fg:rose&quot;
format = &quot; [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)&quot;

[docker_context]
symbol = &quot;󰡨&quot;
style = &quot;bg:abyss fg:sky&quot;
format = &quot; [](fg:abyss)[$symbol $context ]($style)[](fg:abyss)&quot;
only_with_files = true

###############################################################################
# Feedback
###############################################################################
[cmd_duration]
min_time = 2_000
style = &quot;fg:haze italic&quot;
format = &quot; [󱎫 $duration]($style)&quot;
show_milliseconds = false

[character]
success_symbol = &quot;[󰁔](bold mint)&quot;
error_symbol = &quot;[󰁔](bold rose)&quot;
vimcmd_symbol = &quot;[󰁍](bold iris)&quot;

###############################################################################
# Right side
###############################################################################
[battery]
disabled = true   # waybar shows this
format = &quot;[$symbol$percentage]($style) &quot;
full_symbol = &quot;󰁹 &quot;
charging_symbol = &quot;󰂄 &quot;
discharging_symbol = &quot;󰁿 &quot;
unknown_symbol = &quot;󰂑 &quot;
empty_symbol = &quot;󰂎 &quot;

[[battery.display]]
threshold = 15
style = &quot;fg:rose bold&quot;

[[battery.display]]
threshold = 40
style = &quot;fg:amber&quot;

[[battery.display]]
threshold = 100
style = &quot;fg:haze&quot;

[time]
disabled = true   # waybar shows this
time_format = &quot;%H:%M&quot;
style = &quot;fg:haze&quot;
format = &quot;[󰥔 $time]($style)&quot;

[status]
disabled = false
symbol = &quot;󰅙 &quot;
format = &quot; [$symbol$common_meaning$signal_name$maybe_int]($style)&quot;
style = &quot;fg:rose bold&quot;
map_symbol = false
pipestatus = true

[jobs]
symbol = &quot;󰑮&quot;
style = &quot;fg:sky&quot;
format = &quot; [$symbol$number]($style)&quot;
number_threshold = 1
</code></pre>
<p><strong><code>~/.config/starship-tty.toml</code></strong></p>
<pre><code class="language-toml"># starship-tty.toml -- prompt for the Linux virtual console (TERM=linux)
#
# The kernel console loads PSF fonts, which hold at most 512 glyphs and cannot
# contain Nerd Font Private Use Area codepoints. It also has no truecolor: the
# VT gives you the 16 ANSI colours, so hex values like #bb9af7 are not usable.
#
# This config is therefore deliberately plain: pure ASCII symbols, named ANSI
# colours, no powerline separators, no icons. Same information, nothing that
# renders as a blank box.
#
# ~/.bashrc selects it automatically when TERM=linux; every other terminal gets
# the full ~/.config/starship.toml.

add_newline = true
command_timeout = 900

format = &quot;&quot;&quot;
$username\
$hostname\
$directory\
$git_branch\
$git_status\
$nodejs$python$rust$golang$lua$zig$java\
$cmd_duration\
$status$jobs\
$line_break\
$character&quot;&quot;&quot;

[username]
show_always = true
style_user = &quot;bold cyan&quot;
style_root = &quot;bold red&quot;
format = &quot;[$user]($style)&quot;

[hostname]
ssh_only = false
style = &quot;cyan&quot;
format = &quot;[@$hostname]($style) &quot;

[directory]
style = &quot;bold blue&quot;
format = &quot;[$path]($style)[$read_only]($read_only_style) &quot;
truncation_length = 3
truncation_symbol = &quot;.../&quot;
read_only = &quot; [ro]&quot;
read_only_style = &quot;red&quot;

[git_branch]
symbol = &quot;&quot;
style = &quot;bold green&quot;
format = &quot;[git:$branch]($style) &quot;
truncation_length = 20
truncation_symbol = &quot;...&quot;

[git_status]
style = &quot;yellow&quot;
format = &quot;([$all_status$ahead_behind]($style) )&quot;
conflicted = &quot;!${count} &quot;
ahead = &quot;+${count} &quot;
behind = &quot;-${count} &quot;
diverged = &quot;+${ahead_count}-${behind_count} &quot;
up_to_date = &quot;&quot;
untracked = &quot;?${count} &quot;
stashed = &quot;$${count} &quot;
modified = &quot;~${count} &quot;
staged = &quot;*${count} &quot;
renamed = &quot;r${count} &quot;
deleted = &quot;x${count} &quot;

[nodejs]
symbol = &quot;&quot;
style = &quot;green&quot;
format = &quot;[node:$version]($style) &quot;

[python]
symbol = &quot;&quot;
style = &quot;yellow&quot;
format = &quot;[py:$version]($style) &quot;

[rust]
symbol = &quot;&quot;
style = &quot;red&quot;
format = &quot;[rust:$version]($style) &quot;

[golang]
symbol = &quot;&quot;
style = &quot;cyan&quot;
format = &quot;[go:$version]($style) &quot;

[lua]
symbol = &quot;&quot;
style = &quot;blue&quot;
format = &quot;[lua:$version]($style) &quot;

[zig]
symbol = &quot;&quot;
style = &quot;yellow&quot;
format = &quot;[zig:$version]($style) &quot;

[java]
symbol = &quot;&quot;
style = &quot;red&quot;
format = &quot;[java:$version]($style) &quot;

[cmd_duration]
min_time = 2_000
style = &quot;bright-black&quot;
format = &quot;[took $duration]($style) &quot;

[status]
disabled = false
symbol = &quot;!&quot;
format = &quot;[$symbol$common_meaning$signal_name$maybe_int]($style) &quot;
style = &quot;bold red&quot;
map_symbol = false
pipestatus = true

[jobs]
symbol = &quot;&amp;&quot;
style = &quot;cyan&quot;
format = &quot;[$symbol$number]($style) &quot;
number_threshold = 1

[character]
success_symbol = &quot;[&gt;](bold green)&quot;
error_symbol = &quot;[&gt;](bold red)&quot;
vimcmd_symbol = &quot;[&lt;](bold purple)&quot;

# Everything below is off on the console: either it needs glyphs, or it is
# noise on an 80-column VT.
[battery]
disabled = true

[time]
disabled = true

[docker_context]
disabled = true

[package]
disabled = true
</code></pre>
<h3>Scripts (~/.config/scripts, all chmod +x)</h3>
<p><strong><code>~/.config/scripts/screenshot</code></strong></p>
<pre><code class="language-bash">#!/usr/bin/env bash
# screenshot [region|window|screen] [--edit]
#   always: copies to clipboard AND saves to ~/Pictures/Screenshots
#   --edit: opens swappy first to crop / annotate / redact before saving
set -euo pipefail

mode=&quot;${1:-region}&quot;
edit=&quot;${2:-}&quot;
dir=&quot;$HOME/Pictures/Screenshots&quot;
mkdir -p &quot;$dir&quot;
file=&quot;$dir/$(date +%Y-%m-%d_%H-%M-%S).png&quot;

notify() { command -v notify-send &gt;/dev/null &amp;&amp; notify-send -t 2500 &quot;$@&quot; || true; }

case &quot;$mode&quot; in
	region)
		geom=$(slurp -d) || exit 0          # Esc -&gt; quiet exit
		grim -g &quot;$geom&quot; &quot;$file&quot;
		;;
	window)
		geom=$(hyprctl activewindow -j \
			| jq -r '&quot;\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])&quot;')
		grim -g &quot;$geom&quot; &quot;$file&quot;
		;;
	screen)
		grim &quot;$file&quot;
		;;
	*)
		echo &quot;usage: screenshot [region|window|screen] [--edit]&quot; &gt;&amp;2
		exit 1
		;;
esac

if [ &quot;$edit&quot; = &quot;--edit&quot; ]; then
	# swappy writes the edited result back over the same path on save
	swappy -f &quot;$file&quot; -o &quot;$file&quot;
fi

wl-copy --type image/png &lt; &quot;$file&quot;
notify &quot;Screenshot copied&quot; &quot;$(basename &quot;$file&quot;)&quot;
</code></pre>
<p><strong><code>~/.config/scripts/record</code></strong></p>
<pre><code class="language-bash">#!/usr/bin/env bash
# record [region|screen] [--audio|--mic]   -- toggle screen recording
#
#   record            toggle: full screen, no audio
#   record region     toggle: pick an area with slurp
#   record screen --audio   full screen + system audio (what you hear)
#   record screen --mic     full screen + microphone
#
# Running it again while a recording is active STOPS it, whatever the args.
#
# Encoding is VAAPI h264 on the iGPU. On a 6W N150, software x264 at 1920x1200
# would peg the CPU and cook the battery; the hardware encoder is near-free.
set -uo pipefail

dir=&quot;$HOME/Videos/Recordings&quot;
mkdir -p &quot;$dir&quot;

notify() { command -v notify-send &gt;/dev/null &amp;&amp; notify-send -t 2500 &quot;$@&quot; || true; }

# --- stop an in-flight recording -------------------------------------------
# wf-recorder MUST get SIGINT, not SIGTERM/SIGKILL: it finalises the MP4
# container (moov atom) on interrupt. Killed any harder, the file is unplayable.
if pgrep -x wf-recorder &gt;/dev/null; then
	pkill -INT -x wf-recorder
	for _ in $(seq 1 40); do
		pgrep -x wf-recorder &gt;/dev/null || break
		sleep 0.1
	done
	newest=$(ls -t &quot;$dir&quot;/*.mp4 2&gt;/dev/null | head -1)
	if [ -n &quot;$newest&quot; ]; then
		size=$(du -h &quot;$newest&quot; | cut -f1)
		printf '%s' &quot;$newest&quot; | wl-copy 2&gt;/dev/null || true
		notify &quot;Recording saved&quot; &quot;$(basename &quot;$newest&quot;)  ($size)  path copied&quot;
	else
		notify &quot;Recording stopped&quot; &quot;no file produced&quot;
	fi
	exit 0
fi

# --- start a new recording --------------------------------------------------
mode=&quot;${1:-screen}&quot;
audio=&quot;${2:-}&quot;
file=&quot;$dir/$(date +%Y-%m-%d_%H-%M-%S).mp4&quot;

args=(-f &quot;$file&quot; -c h264_vaapi -d /dev/dri/renderD128
      --codec-param=qp=24 -m mp4)

case &quot;$mode&quot; in
	region)
		geom=$(slurp -d) || exit 0          # Esc -&gt; quiet exit
		args+=(-g &quot;$geom&quot;)
		;;
	screen) ;;
	*)
		echo &quot;usage: record [region|screen] [--audio|--mic]&quot; &gt;&amp;2
		exit 1
		;;
esac

case &quot;$audio&quot; in
	--audio)
		# .monitor of the default sink = system audio, not the microphone
		sink=$(wpctl inspect @DEFAULT_AUDIO_SINK@ 2&gt;/dev/null \
			| sed -n 's/.*node\.name = &quot;\(.*\)&quot;.*/\1/p' | head -1)
		if [ -n &quot;$sink&quot; ]; then
			args+=(--audio=&quot;${sink}.monitor&quot;)
		else
			notify &quot;Recording&quot; &quot;could not resolve system audio; recording silent&quot;
		fi
		;;
	--mic)
		args+=(-a)                          # default input device
		;;
	&quot;&quot;) ;;
	*)
		echo &quot;unknown audio option: $audio&quot; &gt;&amp;2
		exit 1
		;;
esac

wf-recorder &quot;${args[@]}&quot; &gt;/tmp/wf-recorder.log 2&gt;&amp;1 &amp;
sleep 1.2
if pgrep -x wf-recorder &gt;/dev/null; then
	notify &quot;Recording started&quot; &quot;$mode${audio:+ $audio}  ·  Super+Alt+R to stop&quot;
else
	notify &quot;Recording FAILED&quot; &quot;$(tail -2 /tmp/wf-recorder.log | tr '\n' ' ')&quot;
	exit 1
fi
</code></pre>
<p><strong><code>~/.config/scripts/hypr-keys</code></strong></p>
<pre><code class="language-bash">#!/usr/bin/env bash
# Pretty-print the Hyprland keybinds cheatsheet.
b=$'\e[1m'; d=$'\e[2m'; c=$'\e[36m'; r=$'\e[0m'
sec(){ printf &quot;\n${b}${c}%s${r}\n&quot; &quot;$1&quot;; }
k(){ printf &quot;  ${b}%-24s${r} %s\n&quot; &quot;$1&quot; &quot;$2&quot;; }

sec &quot;APPS&quot;
k &quot;Super Return&quot;        &quot;terminal&quot;
k &quot;Super Shift Return&quot;  &quot;floating terminal&quot;
k &quot;Super D&quot;             &quot;app launcher&quot;
k &quot;Super B&quot;             &quot;browser&quot;

sec &quot;WINDOWS&quot;
k &quot;Super Q&quot;             &quot;close window&quot;
k &quot;Super F&quot;             &quot;fullscreen&quot;
k &quot;Super Shift F&quot;       &quot;maximize (keeps bar)&quot;
k &quot;Super Ctrl F&quot;        &quot;fake fullscreen&quot;
k &quot;Super V&quot;             &quot;toggle floating&quot;
k &quot;Super C&quot;             &quot;centre window&quot;
k &quot;Super T&quot;             &quot;pin on top&quot;
k &quot;Super P / Super Y&quot;   &quot;pseudotile / toggle split&quot;
k &quot;Super hjkl|arrows&quot;   &quot;move focus&quot;
k &quot;Super Shift hjkl&quot;    &quot;move window&quot;
k &quot;Super Alt hjkl&quot;      &quot;resize window&quot;
k &quot;Super LMB / RMB&quot;     &quot;drag to move / resize&quot;

sec &quot;DESKTOPS (5)&quot;
k &quot;Super 1-5&quot;           &quot;go to desktop&quot;
k &quot;Super Shift 1-5&quot;     &quot;move window there, follow&quot;
k &quot;Super Ctrl 1-5&quot;      &quot;move window there, stay&quot;
k &quot;Super Tab&quot;           &quot;next desktop&quot;
k &quot;Super Shift Tab&quot;     &quot;previous desktop&quot;
k &quot;Super \`&quot;             &quot;last desktop&quot;
k &quot;Super scroll&quot;        &quot;cycle desktops&quot;
k &quot;3 fingers sideways&quot;  &quot;cycle desktops&quot;
k &quot;Super S&quot;             &quot;scratchpad&quot;
k &quot;Super Alt S&quot;         &quot;send to scratchpad&quot;

sec &quot;SCREENSHOTS  (clipboard + ~/Pictures/Screenshots)&quot;
k &quot;Super Shift S&quot;       &quot;select a region&quot;
k &quot;Print&quot;               &quot;whole screen&quot;
k &quot;Shift Print&quot;         &quot;active window&quot;

sec &quot;SCREEN RECORDING  (~/Videos/Recordings, toggle)&quot;
k &quot;Super Alt R&quot;         &quot;record whole screen&quot;
k &quot;Super Alt Shift R&quot;   &quot;record a region&quot;
k &quot;Super Alt A&quot;         &quot;record screen + system audio&quot;
k &quot;  press again&quot;       &quot;stops and finalises the file&quot;

sec &quot;CLIPBOARD&quot;
k &quot;Super Shift V&quot;       &quot;clipboard history&quot;
k &quot;Super Ctrl Shift V&quot;  &quot;wipe clipboard history&quot;

sec &quot;IF THE LOCK SCREEN EVER HANGS&quot;
k &quot;Ctrl+Alt+F2&quot;         &quot;switch to a text console, log in&quot;
k &quot;  then run:&quot;         &quot;hyprctl -i 0 dispatch exec hyprlock&quot;
k &quot;  &quot;                  &quot;(allow_session_lock_restore lets it adopt the dead lock)&quot;

sec &quot;SESSION&quot;
k &quot;Super Escape&quot;        &quot;lock the screen&quot;
k &quot;Super Shift R&quot;       &quot;reload config&quot;
k &quot;Super Shift Q&quot;       &quot;quit Hyprland&quot;
echo
read -rsn1 -p &quot;  press any key to close &quot;
</code></pre>
<h3>Greeter (these live in /etc/greetd/, root-owned)</h3>
<p><strong><code>/etc/greetd/config.toml</code></strong></p>
<pre><code class="language-toml">[terminal]
# VT 7 keeps tty1-6 free as plain console logins -- the escape hatch if the
# greeter ever fails to come up.
vt = 7

[default_session]
# Launched via the start-hyprland watchdog, not Hyprland directly: launching
# Hyprland bare makes it raise &quot;being launched without start-hyprland&quot; in the
# top-right error overlay, which flashes on the login screen.
# gtkgreet needs a Wayland compositor to live in; Hyprland gives it the correct
# panel rotation and scale. The greeter config exits the compositor on login.
command = &quot;start-hyprland -- --config /etc/greetd/hyprland.conf&quot;
user = &quot;_greetd&quot;
</code></pre>
<p><strong><code>/etc/greetd/hyprland.conf</code></strong></p>
<pre><code class="language-ini"># Minimal compositor for the login greeter only.
# It exists so gtkgreet inherits the same rotation and 1.5x scale as the real
# session -- a plain cage/gtkgreet greeter cannot rotate this DSI panel.

monitor = DSI-1, preferred, auto, 1.5, transform, 3

env = GTK_THEME,Adwaita:dark

exec-once = gtkgreet -l -b /usr/share/backgrounds/login.png -s /etc/greetd/gtkgreet.css -c start-hyprland; hyprctl dispatch exit

input {
    kb_layout = us
    touchdevice {
        transform = 3
    }
    touchpad {
        natural_scroll = true
        tap-to-click = true
    }
}

general {
    border_size = 0
    gaps_in = 0
    gaps_out = 0
}

decoration {
    rounding = 0
    blur {
        enabled = false
    }
    shadow {
        enabled = false
    }
}

animations {
    enabled = false
}

misc {
    force_default_wallpaper = 0
    disable_hyprland_logo = true
    background_color = rgb(1a1b26)
}
</code></pre>
<p><strong><code>/etc/greetd/gtkgreet.css</code></strong></p>
<pre><code class="language-css">/* Login screen styled to match hyprlock.
 *
 * gtkgreet builds its own widget tree (label beside entry, session combo,
 * &quot;Log in&quot; button), so this cannot reproduce hyprlock's exact vertical stack.
 * What it does match: the wallpaper treatment, palette, typography, the big
 * clock, and the blue-bordered input field -- so the two read as one design.
 *
 * Sizes are LOGICAL px; the greeter compositor runs at scale 1.5.
 */

window {
    background-color: #1a1b26;
}

/* No margins here: symmetric margins visibly shifted the whole block right of
 * centre, and matching hyprlock's centred layout matters more than a narrower
 * field. hyprlock floats its text straight on the wallpaper, so no card. */
box#body {
    background-color: transparent;
    border: none;
    box-shadow: none;
    padding: 0;
}

/* Big clock -- hyprlock's 96px physical -&gt; 64 logical */
label#clock {
    color: #c0caf5;
    font-family: &quot;JetBrains Mono ExtraBold&quot;, &quot;JetBrains Mono&quot;, monospace;
    font-weight: 800;
    font-size: 64px;
    margin-bottom: 18px;
    text-shadow: 0 2px 14px rgba(0, 0, 0, 0.6);
}

/* &quot;Username:&quot; -- hyprlock's muted secondary text */
label {
    color: #565f89;
    font-family: &quot;JetBrains Mono&quot;, monospace;
    font-size: 13px;
    margin-right: 10px;
    text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
}

/* hyprlock's input-field: 2px #7aa2f7, #1a1b26e6 fill, 8px radius */
entry {
    background-color: rgba(26, 27, 38, 0.90);
    color: #c0caf5;
    caret-color: #c0caf5;
    border: 2px solid #7aa2f7;
    border-radius: 8px;
    padding: 7px 13px;
    font-family: &quot;JetBrains Mono&quot;, monospace;
    font-size: 14px;
    min-height: 32px;
    box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
}

entry:focus {
    border-color: #bb9af7;
}

/* hyprlock submits on Enter and shows no button, so keep this understated. */
button {
    background-image: none;
    background-color: rgba(36, 40, 59, 0.75);
    color: #a9b1d6;
    border: 1px solid #3b4261;
    border-radius: 8px;
    padding: 5px 16px;
    font-family: &quot;JetBrains Mono&quot;, monospace;
    font-size: 12px;
    margin-top: 12px;
    box-shadow: none;
}

button:hover {
    background-color: #7aa2f7;
    color: #1a1b26;
    border-color: #7aa2f7;
}

/* Session picker: recede, it is not part of the lock-screen look */
#command-selector,
combobox {
    margin-top: 8px;
}

#command-selector entry,
combobox entry,
combobox button {
    background-color: rgba(36, 40, 59, 0.6);
    color: #565f89;
    border: 1px solid #2f3549;
    border-radius: 6px;
    font-size: 12px;
    min-height: 26px;
    box-shadow: none;
}

combobox button:hover {
    background-color: rgba(59, 66, 97, 0.8);
    color: #a9b1d6;
    border-color: #3b4261;
}

/* Auth failure -- hyprlock turns its field #f7768e */
#error_type,
label#error_type {
    color: #f7768e;
    font-weight: bold;
    font-size: 13px;
    text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}
</code></pre>
<p><strong><code>/etc/greetd/environments</code></strong></p>
<pre><code class="language-text">start-hyprland
</code></pre>
<h3>Applications and desktop integration</h3>
<p><strong><code>~/.config/gtk-3.0/settings.ini  (copy to gtk-4.0/ as well)</code></strong></p>
<pre><code class="language-ini">[Settings]
gtk-application-prefer-dark-theme=1
gtk-font-name=Cantarell 11
gtk-cursor-theme-name=Adwaita
gtk-cursor-theme-size=24
gtk-xft-antialias=1
gtk-xft-hinting=1
gtk-xft-hintstyle=hintslight
gtk-xft-rgba=rgb
</code></pre>
<p><strong><code>~/.config/mimeapps.list</code></strong></p>
<pre><code class="language-ini">[Default Applications]
inode/directory=pcmanfm.desktop
image/png=imv.desktop
image/jpeg=imv.desktop
image/gif=imv.desktop
image/webp=imv.desktop
application/pdf=org.pwmt.zathura.desktop
text/html=firefox-esr.desktop
x-scheme-handler/http=firefox-esr.desktop
x-scheme-handler/https=firefox-esr.desktop
</code></pre>
<p><strong><code>~/.config/libfm/libfm.conf  (write while pcmanfm is NOT running)</code></strong></p>
<pre><code class="language-ini"># Configuration file for the libfm version 1.4.0.
# Autogenerated file, don't edit, your changes will be overwritten.

[config]
single_click=0
middle_click=0
use_trash=1
confirm_del=1
confirm_trash=1
advanced_mode=0
si_unit=0
force_startup_notify=1
date_iso_8601=0
backup_as_hidden=1
no_usb_trash=1
no_child_non_expandable=0
show_full_names=0
only_user_templates=0
template_run_app=0
template_type_once=0
auto_selection_delay=600
drop_default_action=auto
defer_content_test=0
quick_exec=0
show_internal_volumes=0
thumbnail_local=1
thumbnail_max=0
smart_desktop_autodrop=1

[ui]
big_icon_size=64
small_icon_size=24
pane_icon_size=24
thumbnail_size=160
show_thumbnail=1
shadow_hidden=0

[places]
places_home=1
places_desktop=1
places_root=0
places_computer=0
places_trash=1
places_applications=1
places_network=0
places_unmounted=1
</code></pre>
<p><strong><code>~/.config/swappy/config</code></strong></p>
<pre><code class="language-ini">[Default]
save_dir=$HOME/Pictures/Screenshots
save_filename_format=swappy-%Y%m%d-%H%M%S.png
show_panel=true
line_size=5
text_size=20
text_font=JetBrains Mono
paint_mode=brush
early_exit=false
fill_shape=false
</code></pre>
<h3><code>~/.ssh/config</code></h3>
<pre><code>Host *
    ServerAliveInterval 20
    ServerAliveCountMax 3
    TCPKeepAlive no
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
    AddKeysToAgent yes
    IdentityFile ~/.ssh/id_ed25519
    HashKnownHosts no
</code></pre>
<p>Generate the key with <code>ssh-keygen -t ed25519 -C &quot;$(whoami)@$(hostname)&quot;</code>, and
<code>chmod 600 ~/.ssh/config</code>.</p>
<h3><code>~/.bashrc</code> — prompt selection</h3>
<p>Appended to the end. The <code>TERM=linux</code> branch is what keeps the prompt readable
on a virtual console, where Nerd Font glyphs cannot render:</p>
<pre><code class="language-bash">if command -v starship &gt;/dev/null 2&gt;&amp;1; then
    if [ &quot;$TERM&quot; = &quot;linux&quot; ]; then
        export STARSHIP_CONFIG=&quot;$HOME/.config/starship-tty.toml&quot;
    fi
    eval &quot;$(starship init bash)&quot;
fi
</code></pre>
<hr />
<p><em>Written on the machine it describes: CHUWI MiniBook X, Intel N150, 12 GB
LPDDR5, Debian 13 trixie, Hyprland 0.55.2, 216 DPI at 1.5× scale, S3 deep
sleep, 3.57 W idle.</em></p>
]]></content:encoded>
    </item>
    
  </channel>
</rss>