## Summary A cleanup pass across the blog: route and feed bugs fixed, SEO basics added, smaller and lazier images, accessibility fixes, and a real README. No content or design changes beyond small fixes. ## Bug fixes - **Tag/category pages**: static params now use slugs, so pre-rendered pages match the links (before, `/tags/standard-model` was only rendered on demand and the pre-built pages said "Tag not found"). Unknown slugs now return a real 404, and there's a new not-found page. - **Recently updated**: sorts by last modification *before* taking the top 5, so older posts that were updated recently now show up. - **Atom feed**: `rel="self"` now points to `/feed.xml` (was `/feed`); `<updated>` uses the `updated` frontmatter field. - **No-JS flash**: the `no-js` → `js` class swap runs in an inline `<head>` script before first paint, not after hydration. - **Markdown links**: only internal paths go through `next/link`; `#anchors`, `mailto:` and links without an `href` no longer break. - **Invalid HTML**: images in a paragraph together with text (e.g. "**Screenshots:**" followed by images on the Projects page) produced `<p><figure>` and a hydration error. Those paragraphs are now split. - **Dates**: frontmatter dates are normalized to ISO, so `<time datetime>` values are valid. Formatting is pinned to Europe/Prague so a UTC build server can't shift a date by a day. ## SEO - `sitemap.xml` and `robots.txt` - Canonical URLs, Twitter cards and a feed link on every page, built by a shared `pageMetadata()` helper - Generated Open Graph images: a site card, and for each post its cover image or a branded title card (pre-rendered at build time) - `BlogPosting` JSON-LD on post pages ## Performance - Only the post cover and the latest-post image are preloaded (was: every image) - In-post image quality 100 → 90, plus responsive `sizes` - Screenshots downscaled to 1800px wide; `linkboard-old.png` converted to WebP (3.1 MB → 60 KB) - `CodeWrapper` is now a server component; only the copy button ships to the client - Mermaid CDN script pinned to `11.17.2` for reproducible builds ## Accessibility - Accessible names for icon-only links (share, social, mobile nav) and `aria-current` on active nav items - Copy button: uses React state instead of mutating `innerHTML`, handles clipboard errors, announces the result to screen readers, and is no longer inside an `aria-hidden` header - Code-block line numbers hidden from screen readers - Image captions: the Markdown `"title"` becomes the caption; captions that only repeat the alt text are hidden from screen readers ## Content & maintenance - **Frontmatter validation**: a missing or malformed field fails the build and names the file, instead of rendering a broken post - **`draft: true` support**: drafts only render in development - About/Projects/Setup pages share one `MarkdownPage` component; tag/category lists share `PostList` - Markdown rendering is cached, so metadata and page share one render - New README covering features, env vars, frontmatter schema, Markdown extensions and deployment - Removed unused ESLint setup and the unrelated `hast` package; `oxlint`, `oxfmt` and `puppeteer` moved to devDependencies (locked versions unchanged) - Typo fix in the Tessera cover alt text; removed unused `math`/`mermaid` flags from two posts ## Deployment notes - **`SITE_URL` is now required**: the build fails with a clear error if it's missing. - **Puppeteer is a devDependency now**: Nixpacks installs dev dependencies for the build by default, but check that Mermaid diagrams render on the first deploy (the Standard Model post has one). ## Testing - `tsc --noEmit` and `bun run lint` pass - `bun run build` passes; all 81 pages are generated statically - Smoke-tested `next start`: posts, tags, categories, feed, sitemap and OG images return 200; unknown tags and routes return 404 - No block elements nested inside `<p>` in any built page Reviewed-on: #1
105 lines
5.6 KiB
Markdown
105 lines
5.6 KiB
Markdown
---
|
|
title: 'Tessera: A Privacy-First Card Wallet'
|
|
description: A simple PWA for managing loyalty and membership cards. No account, no ads, no data leaving your device. A privacy-first approach to digital card storage.
|
|
date: 2026-09-18 12:00:00 +0200
|
|
updated: 2026-09-18 12:30:00 +0200
|
|
category: Projects
|
|
tags: [projects, pwa, privacy, webdev]
|
|
coverImage: tessera-cover.png
|
|
coverImageAlt: Tessera cover with basic information about the post.
|
|
---
|
|
|
|
## The Problem
|
|
|
|
I have too many loyalty cards.
|
|
|
|
Coffee shops, grocery stores, pharmacies, the occasional bookstore that insists on a points program - all of them want a piece of plastic in your wallet or an app on your phone.
|
|
|
|
The apps are the worst part. Each one wants an account. Each one collects data. Each one sends push notifications you didn't ask for. And half of them are barely functional web wrappers with a 40 MB download size.
|
|
|
|
There are wallet apps that aggregate cards, but they all share the same problem: they're cloud-based, account-gated, and you're trusting some startup with the barcode data from every store you shop at. That felt unnecessary.
|
|
|
|
All I want is to scan a barcode once and show it at checkout. That's it.
|
|
|
|
So I built Tessera.
|
|
|
|
## What It Does
|
|
|
|
Tessera lets you scan the barcode or QR code from any physical loyalty card and save it to a local digital wallet. When you're at checkout, open the card and the barcode is displayed for scanning.
|
|
|
|
That's the whole thing.
|
|
|
|
No account required. No server involved. All data lives in IndexedDB in your browser. Once installed as a PWA, it works fully offline.
|
|
|
|

|
|
|
|

|
|
|
|

|
|
|
|
Try it at [tessera.hhu.cz](https://tessera.hhu.cz), or self-host it yourself, it is a static build.
|
|
|
|
## Stack Decisions
|
|
|
|
This is where it got a bit different from my usual work.
|
|
|
|
My default is Next.js, and for most things that's the right call. But Tessera has no backend, no API, and no server-side rendering. It is a pure client-side app. Reaching for Next.js would have meant using a framework for a problem that does not need one.
|
|
|
|
The first prototype was built with vanilla JS and a few libraries. That worked for a proof of concept, but I wanted a more structured approach for the final version. So I pivoted to just adding **Vite**, that allowed me to use TypeScript and a proper build system without the overhead of a full framework.
|
|
|
|
But as I continued to expand the app, I found myself rebuilding the same systems that some lightweight framework would have provided, probably better than I could have done on my own.
|
|
|
|
So I went with **Preact + Vite** instead. Preact because it's small and I didn't need React's full weight for something this contained. Vite because it's just faster and the build setup is nearly zero friction.
|
|
|
|
For barcode scanning I used **ZXing**. It handles most common barcode formats well and worked reliably on both desktop and mobile cameras. For rendering the barcode when showing the card at checkout, **BWIP-JS** covers essentially every format I'd realistically encounter.
|
|
|
|
Storage is handled by **IDB-Keyval**, a thin wrapper over IndexedDB. I didn't want to deal with the raw IndexedDB API and didn't need anything heavy. IDB-Keyval is minimal.
|
|
|
|
The full stack:
|
|
|
|
| Component | Implementation |
|
|
| ----------------- | ---------------------- |
|
|
| Framework | Preact + Vite |
|
|
| Language | TypeScript |
|
|
| Barcode scanning | ZXing |
|
|
| Barcode rendering | BWIP-JS |
|
|
| Storage | IDB-Keyval (IndexedDB) |
|
|
| Icons | Lucide |
|
|
|
|
## Why a PWA
|
|
|
|
The PWA constraint shaped a lot of the decisions here.
|
|
|
|
No app store means no review process, no gatekeeping, no fee. You open the URL, install from the browser, and it behaves like a native app. That's the right distribution model for something this simple.
|
|
|
|
It also forces you to be honest about offline support. If your app is installed and behaves like a native app, it should work like one, including without a connection. Since everything is local anyway, offline support came essentially for free.
|
|
|
|
The one trade-off is camera access for scanning, which requires HTTPS and the browser's permission prompt. Not a problem in practice, but worth knowing if you're self-hosting.
|
|
|
|
## Self-Hosting
|
|
|
|
Tessera is a static build, so self-hosting is straightforward:
|
|
|
|
```bash
|
|
git clone https://github.com/HangerThem/tessera
|
|
cd tessera
|
|
bun install
|
|
bun run build
|
|
```
|
|
|
|
Serve the `dist/` folder with anything like Nginx, Caddy, or Coolify. I'm running it on my own infrastructure at tessera.hhu.cz the same way.
|
|
|
|
## What's Next
|
|
|
|
Tessera is intentionally small. I don't want to turn it into a platform.
|
|
|
|
As of now it is designed mainly for mobile use, but it works on desktop too. In the next few days I will focus on making it more desktop-friendly, with a better layout for larger screens and keyboard shortcuts for power users. Even tho I find the usage of something like this on desktop a bit niche, I know some people will want to use it there.
|
|
|
|
A few things I might add: card categories or grouping, export/import functionality, simple notes, and icon customization so your cards are easier to tell apart at a glance.
|
|
|
|
What I won't add: accounts, sync, a backend, analytics, or anything that sends data off your device. That's the whole point.
|
|
|
|
The source is on [GitHub](https://github.com/HangerThem/tessera), MIT licensed. If something's broken or missing, PRs are welcome.
|
|
|
|
Best, <br />
|
|
Frank
|