More from A Beautiful Site
As software engineers, we take pride in hand-written code and knowing how everything we build works in detail. While there are many developers out there who build good software, there are many, many more who don’t. Yet we balk at the idea of vibe coding, even when it yields decent results. You don’t need a scientific study to realize the mean quality of human-authored software leaned towards slop even before AI was around. How many times have you been frustrated by an app or website that just didn’t work right? (This is especially true for software produced by big companies.) The quality of software humans can build is absolutely incredible. But the quality of software humans actually do build is usually not. So no, AI can’t build perfect software. Maybe that will improve in the future, or maybe it will remain just as messy as the human-authored code LLMs have been trained on. I don’t think any of that matters, because if AI can build software that’s at least as good as the mediocre code that came before it, that will be acceptable for most industries. Customers don’t care about the craft, they care about the result. And if the result is faster, cheaper, and good enough…well, that’s a better combination than anything they’ve ever had before. Why wouldn't they choose it?
I launched ColorCopy on May 18. It was my second macOS app, but the first one I launched on the Mac App Store. Today, I decided to pull it. The app isn't a new idea, but it bakes four color tools into a single menu bar app: an eyedropper, a color picker, a palette manager, and a contrast checker, each one a hotkey away. I made it available for free, with a one-time in-app purchase to unlock unlimited use. No subscription, no recurring fees. It's simple, stable software with the lowest possible barrier to entry…exactly the kind of utility the App Store seems made for. I wasn't sure what to expect, so I ran an experiment to answer some questions: Is there any real benefit to having your Mac app on the App Store? Is the Apple tax worth it? Will customers just come flooding in? For ColorCopy's release, I did zero marketing. No Product Hunt launch. No Hacker News post. The only things I published were this blog post and a tweet. If the App Store delivers on its promise of discoverability, that should be enough to see some kind of traffic…right? You be the judge. In nearly three months on the App Store, ColorCopy got a little over 1,000 impressions, 149 product page views, 85 first-time downloads, three in-app purchases, and $21 in proceeds. Not per day. In total. Broken down over 81 days, that averages out to: 13 impressions per day 1.8 product page views per day 1 download per day 0.04 in-app purchases per day (about one per month) $0.26 in proceeds per day Extrapolated to 12 months, ColorCopy would have earned about $95…not even enough to cover Apple's $99 annual developer fee. If discoverability isn't a part of the App Store deal, what exactly is the benefit? Why limit your Mac app to the sandbox?* Why spend hours in App Store Connect filling out metadata, screenshots, and localization information? Why wait an arbitrary number of days for someone to review your app with the consistency of a coin flip? It doesn't seem worth it for Mac developers. Adios, App Store 👋 As of today, ColorCopy is self-distributed and sold through Polar (the new Stripe, which I highly recommend). This is the same way I sell TongueType, which makes pretty much everything easier on my end: one dashboard, real customer relationships, no finicky review process, and updates ship the moment they're ready. The move required swapping out in-app purchases from StoreKit to Polar, and updates now ship through Sparkle instead of the App Store's built-in update mechanism. Both are tried and true solutions for self-distributed Mac apps. To be fair, you can't really do this on iOS. Most users aren't jailbroken…walled garden and all. But on Mac, where self-distribution is still a first-class option, there seems to be zero incentive to be in the App Store, especially if you're counting on discoverability. In three months, the App Store sent me barely a trickle of customers. Had I marketed the app myself, the traffic would have flowed the other way. I would've been sending my customers to Apple and paying a tax for the privilege.** Self-distribution gives me the freedom and control over my apps that the App Store's sandbox never will. If it's on me to drum up all of my own traffic, I'm going to send it to my own website. *I originally used a third-party library for the eye dropper because NSColorSampler is meh, but the sandbox forbids it so I was forced to remove it. Yes, I had to make my app shittier in order to put it on the App Store. **I fully acknowledge this may not be the case for every app, but it was my experience and worth sharing. Your mileage may vary. Aside: ColorCopy was available in 10 languages on the App Store. For some reason, the listing always showed FR as its primary language (it wasn't). The app is available in many languages, including English.
I built the dictation app I wanted. It's called TongueType, and my daughter did the voice over for the video. (Family business.) It hasn't gotten much traction yet, and I think I know why: dictation has been bad for so long that most people stopped paying attention. I don't blame them. But I don't think most people realize how good local models have gotten. The thing that was flaky and frustrating five years ago is genuinely good now, and it runs entirely on your Mac. Downloads are low. But the conversion rate is great. The people who actually try it tend to stick around, which tells me the problem isn't the app, it's getting someone to give dictation one more honest chance. The real hurdle is the habit The hard part isn't accuracy. It's that talking instead of typing is a new habit, and new habits are awkward before they're automatic. For the first week it feels strange. You catch yourself reaching for the keyboard out of muscle memory. Then one day it clicks, and you realize how slow typing was making you for certain things. I still write code by hand. That's thinking, not transcribing, and I want my fingers on it (plus saying HTML tags and attributes out loud seems counterintuitive 😂). But for almost everything else, I talk. I prompt LLMs I send emails I reply on Slack I write commit messages I do most other text with my voice The common thread is that the thinking is already done and the only thing left is getting the words out. That turns out to be a surprising amount of my day. My desk setup On the go, the MacBook's built-in mic works just fine. You don't need fancy hardware to get good results. But when I'm at my desk, my laptop is docked, so I pair TongueType with a Tula mic. It's small, portable, sounds great, and looks the part! (Kuru Toga mechanical pencil positioned for size comparison.) One tip: use a wired mic if you can. Bluetooth adds latency, and latency is the enemy of a tool you reach for dozens of times a day. A wired connection makes the whole thing feel instant. Honest caveat: it's probably not a great look in a co-working space. 😂 "Why is this better than macOS dictation?" More accurate. Less flaky. More customizable. More fun. I could list the features, but the honest answer is the same one I'd give for most things: just try it and you'll feel the difference immediately. It's free to start. What's your experience been with dictation? If you wrote it off years ago, I'd genuinely love for you to give TongueType a shot and tell me what you think.
I just launched my first macOS app called TongueType. It's voice dictation that runs entirely on your Mac. Hold a key, speak, release, and your words appear wherever your cursor happens to be. I build small, simple, stable software. TongueType fits that description, and it scratches an itch I've had for a while. Why I made it I type fast, but I often think faster than I type. When an idea is fully formed in my head, the bottleneck is my fingers. macOS has had built-in dictation forever, but I never liked relying on it. Accuracy aside, I didn't love the idea of my voice taking a trip to a server and back just to write a sentence. There are many dictation apps in the wild, but I want one that's privacy focused, doesn't send data to the cloud, doesn't charge a monthly subscription, and gets out of the way. TongueType uses OpenAI's Whisper model running locally on Apple Silicon. Nothing is uploaded. Nothing is logged. There's no account to create. Zero telemetry. Your voice never leaves your Mac. How it works The whole interaction is one key. By default it's the Right Option key, because it's sitting right there and your thumb isn't doing anything important. Hold it, talk, let go. The transcribed text is inserted at your cursor…in your editor, your email, a chat box, a search field, anywhere text goes. You can also drop in an audio or video file — WAV, MP3, MP4, MOV — and get a transcript back. Handy for meeting recordings and voice memos. A few things I sweated the details on: A grace period so a quick accidental tap doesn't start recording. Double-tap to latch for when you want to keep talking without holding the key down. Cancel phrases — say "scratch that" at the end and the whole thing gets discarded. You will use this more than you expect. Spoken symbols — say "new line" or "question mark" and you get the symbol, not the words. Post-processing — for common terms that seldom get dictated properly. TongueType speaks twelve languages and includes automatic detection, so you don't have to tell it which one you're using. How I actually use it Building the app was one thing. Using it every day turned out to be another. A couple months in, it's quietly worked its way into most of what I do at the keyboard: Prompting LLMs. Talking to an AI assistant is conversational by nature, and typing out a long, detailed prompt is tedious. Speaking it isn't. I get more context into a prompt because I'm not rationing my words to save my fingers. Email. Replies that used to sit in my drafts now get spoken out in a single pass. I still read them before sending, but the blank-page friction is gone. Code comments and commit messages. The parts of coding that are just writing. It's faster to explain why a change exists out loud than to stop and type it. Direct messages. Quick replies in chat without breaking flow. Hold the key, say it, done. The common thread: TongueType is best wherever the thinking is already done and the only thing left is getting words out. That's a surprising amount of my work day. A fun personality TongueType is minimal and fun. It lives in the menu bar. The recording overlay is small and out of the way, and you can configure its position on screen. There are twenty accent colors including Rainbow Mode. None of these extras were necessary, but all of it was fun to build. Accessibility I want to call this out specifically. Voice dictation isn't only a convenience. For some people it's the difference between using a computer comfortably or not. If typing is painful or difficult for you, TongueType is built to be a genuine alternate input method, not an afterthought. That mattered to me, and it shaped a lot of the decisions above. Pricing TongueType is free to try, and the free tier includes every feature. You get 30 minutes of live dictation each month and short file transcriptions. If you want unlimited, TongueType Pro is a one-time $19.99 purchase that covers up to five Macs and unlocks unlimited dictation and full-length file transcription. No subscription. Buy it once, keep it forever. Requirements TongueType needs macOS 14 or later on an Apple Silicon Mac (M1 or newer). The local model is the whole point, and that's what makes it run so well. If any of this sounds useful, give it a try at TongueType.app. It's free to start, and I'd genuinely like to hear what you think.
I believe artificial intelligence is a powerful and valuable tool that can significantly improve how we create, solve problems, and bring ideas to life. I didn't always feel this way.. But these days, I use AI regularly in my work and I expect that to continue. We may not have chosen this reality, but it's the reality we're in. The tools and their benefits — costs be damned — can no longer be ignored. That said, when people ask "was this made with AI?" the honest answer is rarely simple. AI can speed up many parts of the process, but it doesn’t replace human judgment, creativity, or responsibility. What appears effortless on the surface rests on deliberate human direction, critical thinking, and careful review. Getting good results from AI requires active guidance and oversight. The nuances of context, ethics, user needs, and real-world application are simply too varied given the current technology. Moreover, AI doesn’t generate meaningful ideas or elegant solutions on its own. Strong human vision, architecture, and decision-making are still essential. There is no prompt, model, or service that can deliver finished, trustworthy work without substantial human input. My commitment to you is this: everything I create will be driven by human ideas, architecture, verification, and final review. I will use AI as an assistant to do what I would have done anyway, but more efficiently. I will not let AI replace my intelligence, but I will use it to turn my intelligence into code faster. — Cory LaViska
More in programming
It's time for a dev blogger's favorite topic: blogging about my new blogging setup. After migrating my blog from Hexo to Eleventy, building a photography website and creating a personal one-pager, it was time to find a new digital home for all my projects. If you notice any issue after the migration, please let me know. I redirect the RSS feed to the new domain, but you might want to update the feed URL in your reader. Motivation I've been maintaining three different websites under two different names: a mostly developer-oriented blog, a photography portfolio and a one-pager gluing everything together. When I started blogging about my sabbatical journey, I was thinking about making yet another page to create a clear separation. I've decided to do the opposite and merge all my websites into one instead. As I further reduce my dependency on social media and third-party services, it has become even more important to have my own space and practice POSSE. Every decision I've made was driven by two main goals: simplicity and personality. Setup I've stopped chasing the latest and greatest. I want to keep the setup as clean as possible, which means pulling fewer dependencies. Every package adds complexity and maintainability costs, so I've only added what was necessary — no more Gulp, Sass or Tailwind. Instead of taking one of my existing websites as a starting point, I created this project from scratch. I reused some of my previous code, but I didn't want to take over any technical debt (especially with the advancements in CSS). The foundation of this website hasn't changed: Eleventy (Build Awesome). It covers the fundamentals of a static site and offers a great level of customization. I've made my project more and less dependent on Eleventy at the same time: I replaced Gulp as my build pipeline with pure Eleventy processing. I replaced most shortcodes with Markdown alternatives (and markdown-it plugins), which are better supported by editors like Obsidian. I've also replaced the Nunjucks template engine with Liquid, which comes with a few trade-offs. The main reason was to avoid creating a new collection for every set of posts I render. With Liquid, you can apply array functions like filtering or sorting in the template itself: {% assign drafts = collections.all | where: "data.isDraft" | reverse %} {% for draft in drafts %} … {% endfor %} Another advantage is having date formatting included, so I don't need another library or custom code. You can even set a default date format, so you don't have to provide it each time. What I don't like is the verbosity of the template language, although I've gotten used to it by now. Nunjucks: {{ "/tags/" + tag + "/" }} {{ modified or created or date }} {% set perspectives = ["dev", "photography", "life"] %} Liquid: {{ "/tags/" | append: tag | append: "/" }} {{ modified | default: created | default: date }} {% assign perspectives = "dev, photography, life" | split: ", " %} After migrating my existing content, I've noticed the build taking way too long, both in the dev server and the production build. I've found out that the Eleventy template cache is turned off for Liquid by default. This was useful during development, but when updating actual content, it caused every template and partial to be constantly recalculated. After turning the cache on, this issue was resolved: eleventyConfig.setLiquidOptions({ cache: true }); Check the colophon for the full tech stack overview. Perspectives The main challenge was finding a way to separate and present my main personas. The Venn diagram intersection of people interested in software development, photography and my personal life is probably small. I could use tags, but they don't offer the level of personalization I was looking for. Most personal blogs separate the content by post type (blog post, note, photo), but this doesn't fit my use case. Instead, I've decided to structure my website around three perspectives, a term I have borrowed from the Eclipse IDE. Each perspective comes with its own avatar, tagline and bio. If someone is interested in everything I share (hi Mom!), they can still view all my posts. Design Since building my photography website, I've made many changes, but I never got around to share an update. The biggest change was the design, which I have used as a blueprint for the new website. I've decided to mimic a social timeline/feed, which most people are familiar with. At the same time, it should be clear that it's a personal website and not yet another walled garden. Typography is an important part of every design. The first step is to choose between a system font stack and a custom font, which comes with the usual performance vs. personality trade-offs. I've decided to go with a uniform look across all devices. I've settled on Source Sans 3 for copy text and Hack for code. Another big change was introducing a sidebar. I always felt like the empty horizontal space was "wasted" on a desktop device, but getting the sidebar UX right is challenging. I'm happy with the result, especially with the last two panels adding a personal touch to the design. Images I've made several changes to the handling of image files. To make them compatible with Markdown editors, I had to replace my custom picture shortcode. The official Eleventy image plugin is a popular choice, but it violates my simplicity goal and doesn't support everything I needed. I've decided to go with a plain Markdown image markup and a single image file (no format fallback or 2x version for retina displays). Additionally, I wrote a markdown-it plugin to turn the optional title parameter into a caption. As for the file format, both WebP and AVIF have excellent browser support. For each image, I select the format based on the quality and file size. For my photos, I'm using WebP for the timeline thumbnails and JPG for the full-size version. Theme picker There have been some recent discussions about dark mode toggles, and I considered going for a light/dark solution based on the user's OS preference. Driven by the personality goal, I knew I had to add at least one more theme, so a proper theme picker was necessary. Similar to navigation, people like to hide the theme selection on mobile devices behind a burger menu or a dropdown. I prefer the solution by Sarah L. Fossheim, where all available themes are displayed above the page footer. A friend was scrolling through my new website, and their first question was "Why is the theme picker in the footer?". This tells me the solution was a good idea — I don't think he would have noticed a typical theme button in the header. The implementation follows standard practices: Use the light or dark theme as default based on the user's OS or browser preference. Store a selected theme in the local storage and use it as the single source of truth. Prevent a flash of inaccurate color theme. Make the theme play nicely with bfcache. I might have slightly gone overboard with my third theme, but the nostalgia kept me adding more and more features. Enjoy going back to the 90s: Feeds As browser vendors decided to remove XSLT support, I didn't bother to style my feed. Instead, I've created a new web feeds page, listing all available feeds and a short explanation. I provide one feed for every perspective and one with all my posts combined. I once offered tag-based RSS feeds. This was useful for my sabbatical, but even there I had only a single subscriber. Now, all tag feeds redirect to their main perspective feed. I've also dropped the JSON feed, despite having at least 16 subscribers. Given that every web feed reader supports Atom feeds, I don't see the value of providing a JSON alternative. A JSON feed doesn't add much complexity, but handling different media types (post, photo) and properly escaping the content is one more thing to implement and maintain. Quality assurance I've put a lot of effort into my new digital home, and I want to make sure it stays in good shape. I've included several tools, most running automatically as part of the CI pipeline: eleventyDataSchema — lint Eleventy data values like titles or dates markdownlint — format/lint Markdown files Vale — lint content (grammar, writing style, formatting) Lychee — find broken links (internal and external) Evaluatory — check accessibility and validate HTML Oxlint — lint JavaScript files Eleventy linters are another option to analyze a template's output, but I haven't found a use case for it yet. Not everything can be automated, so I also did the following manually: Test with Chrome, Firefox and Safari Test with Polypane Test with Google Lighthouse Test with a screen reader Test with JavaScript disabled Test with CSS disabled Test at 200% zoom Test with a bigger browser font size Test with custom fonts disabled Test with delayed assets Performance It's difficult to compare the performance of two separate websites with that of a combined one. Some parts got worse, e.g. bigger font files and more CSS. At the same time, I've noticed that gzip compression was turned off for anything but HTML for a few years now (I must have missed it when upgrading nginx) 🙈. One improvement was to implement on-demand assets. For example, the lite-youtube-embed CSS and JavaScript is only loaded if a <lite-youtube> element is present on the page. As before, JavaScript is fully optional (except for interactive demos in individual posts). I've been using a mix of <noscript> and CSS to hide elements that require JS (like the theme picker). I don't like the user experience of pagination, so I avoided it for years. But now that I have almost 300 posts, I've decided to finally add it. I display 30 posts per page, with images further down still being lazy-loaded for better performance. I haven't applied pagination to tag pages, as that's more complex with Eleventy. Bits and pieces Here are some other noteworthy changes: I've removed post-specific social share images and use a single WebP Open Graph image instead. I've dropped JSON-LD. Open Graph and microformats are sufficient. "Naming is hard" is a common expression in software development, but it also applies to photograph titles. I've decided to use a counter as the title, e.g. #0100. Sometimes I do come up with a title that I love, like the Castle in the Sky, so I might mention it in the description. On a photo page, you can now open the previous/next photo using the left/right arrow keys (in addition to the existing navigation buttons). It's now possible to filter photos by date, gear, or location. Check the explore page for an overview. I've learned about and used some experimental CSS features (with progressive enhancement), like scroll-triggered animations or squircle corners. I've added an AI statement on my LLM usage. I finally have a proper about page. Cool URIs don't change, but it's inevitable when moving to a new domain. All my previous URLs are redirected, except for some assets that are no longer used. I will keep the old domain, so the redirects will continue to work for a foreseeable future. I've kept my previous Easter egg and added a few more. Have a happy hunt! 🕵️♂️ Inspiration I visited hundreds of personal websites looking for inspiration. Here are a few that I kept coming back to: Emma Goto. I love Emma's approach to presenting the variety of her interests, even though I went a different route. Brennan Kenneth Brown. This website has so much to offer. It's a great example of what the open web looks like. Rex Barrett. The sentence "Collecting hobbies is my hobby." hit home — so much that I had to steal borrow it for my short life bio. Conclusion This project was mostly about merging the content of two websites into one. I knew this would take a considerable amount of time, but I still greatly underestimated the effort. In the end, it was all about the journey, not the destination. I loved fine-tuning the design and tweaking little details that probably no one will ever notice. I still have many more ideas left in my backlog. How do you like my new digital home? If you have an opinion, let me know! Reply via email
New electronica album by Warrington-Runcorn New Town Development Plan
To protect against link rot and disappearing content, I save local copies of web pages I like into my web archive. Ideally, this archive is completely self-contained. I want a local copy of each page’s HTML and all the assets it depends on (CSS, images, scripts, and so on). I don’t want my archived pages fetching resources from the original website, which could disappear at any time. Initially, I was verifying self-containedness by hand. I’d open each saved page in a web browser, and check the browser dev tools. I’d check the Console tab for errors and failed requests, and watch the Network tab for requests going out to the internet. That approach was tedious and error-prone, and I wanted something better. I now automate the process using Playwright, a testing framework that allows you to control web browsers. I open each page using Playwright, and check if it’s making external requests. Detecting external requests in Playwright When you open a page in Playwright, you can subscribe to request events which tell you when the page issues a request. In this Python snippet, we start Playwright, start a WebKit browser, and open a new page. We tell the page to append external requests to a list, and then load example.com. We see that example.com makes two requests on page load: one for https://example.com, and one for a JavaScript file. from contextlib import closing from playwright.sync_api import sync_playwright with ( sync_playwright() as p, closing(p.webkit.launch()) as browser, closing(browser.new_page()) as page, ): requests = [] page.on("request", lambda msg: requests.append(msg)) page.goto("https://example.com") print("requests:", requests) # requests: [<Request url='https://example.com/' method='GET'>, # <Request url='https://example.com/s.js' method='GET'>] In a more complex scenario, we might use Playwright to interact with the page after loading – for example, clicking a button or filling in a form, and then we could see the requests triggered by each action. Once we have a list of requests made by the page, we can inspect the url property to check if any of them were fetching from an external website. I wrote a function is_external_request to carry out this check: from playwright.sync_api import Request import urllib.parse def is_external_request(req: Request, original_url: str) -> bool: """ Check if a request has a different origin than the original URL. A request is considered internal if: - it matches the original URL exactly - it has the same scheme, host, and port as the original URL - it uses a `blob:` scheme (for example, in-memory media representations) """ if req.url == original_url: return False # Compare the scheme/host of the request to that of the original URL. org_url = urllib.parse.urlsplit(original_url) req_url = urllib.parse.urlsplit(req.url) if org_url.scheme == req_url.scheme and org_url.netloc == req_url.netloc: return False # Ignore blob: URLs, which are in-memory representation of some data, # and not an external request. # # Playwright detects blob: URLs when loading media files; for example # using the <audio> or <video> tag. if req_url.scheme == "blob": return False # Fail open: if we can't decide if a request was internal or external, # assume it was external. return True assert_page_is_self_contained(ctx, url) I’ve wrapped these two steps in a function assert_page_is_self_contained(ctx, url), which is part of my chives utility library. The function takes a Playwright Browser (or BrowserContext) and a URL, then: Opens a new page in the browser Loads the URL in the page Checks what happened when the page completes loading It throws an exception if there are any: Uncaught page exceptions or errors Console warnings or errors Non-GET requests Requests targeting external resources The function tries to trigger important network requests that might be skipped on the initial page load. For example, it evaluates the body’s offsetHeight to force a synchronous reflow, which causes the browser to request any lazily loaded images or fonts that might otherwise be missed. Here’s an example which verifies a file example.html from the working directory: from chives.browser_fixtures import assert_page_is_self_contained, file_uri from contextlib import closing from playwright.sync_api import sync_playwright with ( sync_playwright() as p, closing(p.webkit.launch()) as browser, ): assert_page_is_self_contained(browser, url=file_uri("example.html")) I originally passed a Page as the first argument, but changing the function to take a Browser or BrowserContext lets it manage its own pages. It gets a fresh page for every verification, and closes it when it’s done. (The first time I ran this, I created a new page for every verification, and never closed them – my computer ground to a crawl as my script opened hundreds of browser tabs.) If you create a new context, you can emulate different browser settings, like location, locale, and window size. Here’s an example where we use contexts to test the same page in light/dark mode: with ( sync_playwright() as p, closing(p.webkit.launch()) as browser, ): with closing(browser.new_context(color_scheme="light")) as light_ctx: assert_page_is_self_contained(light_ctx, url=file_uri("example.html")) with closing(browser.new_context(color_scheme="dark")) as dark_ctx: assert_page_is_self_contained(dark_ctx, url=file_uri("example.html")) In my web archive, I check every page in light/dark mode, and at 1×/2× pixel densities. It takes at least a second to run each verification, so I cache the results. As I save new pages, they’re added to a verification queue, and I’m warned of any missing assets. Unsurprisingly, the first run flagged many missing fonts and images, which I added to my local archive. Every saved page now passes cleanly. Browser is best-effort, but may still miss things Even with automated testing, it’s difficult to guarantee a completely self-contained archive. Different instances of a browser might load different assets, and a single page load might miss different variations: Responsive images – web pages can offer different sizes of an image, and the browser chooses a specific size based on the current window dimensions Appearance settings – a browser might load different fonts or images depending on light/dark mode Interaction-dependent assets – a web page might have assets that are only required on hover or user action You can mitigate some of this by testing with multiple contexts or adding post-load interactions, but every step makes the process take longer. Fortunately, most of the pages I save are simple, blog-style pages where I’ve stripped out the JavaScript. For my specific use case, initial load verification is more than good enough. Other uses for this code There are other projects where it could be useful to intercept and audit the requests made by a web page: Verifying your Content Security Policy (CSP). The CSP header restricts what resources can be loaded by a web page, and you could check that your configuration has the correct effect. Auditing login screens. You could check that sensitive pages don’t load any external scripts, which mitigates the risk of credential theft via a compromised third-party. Testing analytics and tracking. You could script user flows, like a shopping cart checkout, and confirm that tracking events fire correctly. I don’t do much front-end web development, but Playwright has become a key tool in my toolbox. I read a lot about people using AI agents and MCPs to interact with websites, but I prefer Playwright. It’s more fiddly and requires explicit instructions, but it’s constrained, deterministic, and I never have to worry about it doing something unexpected. [If the formatting of this post looks odd in your feed reader, visit the original article]
It’s four years since I bought a printer. I thought I would provide an update on running costs, print sales, and profit. I’m happy to be completely transparent about all this as I know it can be difficult to figure this out if you are thinking of creating your own printing business. January 11th 2026, 11:30am All the photos here are work shot in the last twelve months. Year Four Investment? Nothing other than consumables this year. I did purchase a high-spec film scanner last year, detailed here, but don’t consider that as part of this. The scanner has paid for itself several times over, and it may become a factor in future years (see below). Income Income is made up of sales through a local gallery that I am part of, along with web sales: 5,940.- CHF - sales through the gallery 80.- CHF - sales through web shop = 6,020.- CHF total income Total income is down about 50% compared to 2025. Costs I had to purchase consumables (paper and ink), at a total of 957.- CHF. That cost is included in the total here under the “print consumables” category. January 11th 2026, 3:30pm Rental cost of space in the gallery was reduced due to having overpayments of rent and my loan to the gallery, from the previous year(s), paid back to me. -1,500.- CHF - rental cost of space in the gallery -957.- CHF - print consumables (paper, ink) -1,138.- CHF - framing/mounting/postage -1,135.- CHF - commission to the gallery -300.- CHF - web shop subscription 0.- CHF - stripe / paypal fees 0.- CHF - advertising (none this year) = -5,030.- CHF total costs Total costs are down by about 60% compared to 2025. Total Profit 6,020.- CHF total income -5,030.- CHF total costs = 990.- CHF total profit A Quiet Year? Actually an average year. The previous couple of years were outliers, due to large orders, so really I’m back to normal in terms of sales. The key factors in managing to generate a [small] profit this year were: 1) no longer having to pay back the loan used to finance the printer, and 2) reduced rental cost of space in the gallery due to a previous loan to it being repaid. Also having to spend almost 1,000.- CHF on ink this year ate into profit. This is probably a “once every three years” cost given the rate I print at. The principal cost is rental of space in the gallery - in a normal year this would be 3,000.- CHF. Essentially this means, given the material cost of printing and framing/mounting and commission to the gallery, I need to make about 5,500.- CHF of sales to break even. Solar Eclipse, August 12th 2026 That’s a grind. However I did receive two substantial orders at the end of October, which haven’t made it in to this year’s figures and should boost next year’s. Maybe even a good sign for the year to come? At least now the original investment loan is repaid, the printing costs cover themselves in most years. I may look at offering scanning services now that I have a high-spec film scanner, which could become a scan + print service. More thoughts about that next time.