You are browsing as a guest. Sign up (or log in) to start making projects!

reimunyancat

@reimunyancat

Joined June 7th, 2026

  • 21Devlogs
  • 9Projects
  • 5Ships
  • 60Votes
I am a 17-year-old developer from South Korea. I am trying out various things, but I mainly work in web and programming.

I am a Manchester United fan. btw
Open comments for this post

6h 30m 16s logged

Devlog #1

Building my portfolio as an operating system that runs in a browser tab.

Why this exists

When I first saw the brief, I honestly just planned to knock something out — the
minimum that clears the requirements and move on. But somewhere between the first
commit and the second, I changed my mind. If I’m going to build a website anyway,
why not make it the website — my actual portfolio. So I decided to treat
PrismaOS as the real thing and pour everything I have into it: a portfolio site
that is itself the project. Every window, every pixel of the shell, is something
I want to be proud to show. That reframing is why this repo suddenly has design
tokens, four themes, and a genie animation nobody asked for.

The concept: instead of a scrolling landing page, the visitor boots into a tiny
web desktop — a menu bar, a dock, draggable windows — and explores my work the
way you’d poke around someone’s actual computer.

This milestone: the window manager

Devlog #1 covers the skeleton — the part that makes it feel like an OS rather

One source of truth

Every window’s position, size, focus order (z-index), and minimized state lives
in a single hook, useWindows. Components never track their own coordinates.
Opening, closing, focusing, moving, and resizing all route through that one
place.

Dragging without dropping frames

The naive approach — call setState on every pointer move — makes windows stutter
because React re-renders on every pixel. So during a drag I don’t touch state at
all. I mutate the DOM node directly (el.style.left/top) for a smooth 60fps, and
only commit the final position to state when the pointer is released.

The other trick is setPointerCapture. Once the pointer is captured, the drag
keeps working even if the cursor flies outside the window — or outside the
browser entirely. Touch gets the same behavior for free, since Pointer Events
unify mouse and touch.

handle.setPointerCapture(e.pointerId)
// ...move: write to el.style directly (no re-render)
// ...up:   commit once via onDragEnd()

Resizing from the bottom-right corner uses the exact same pattern: capture,
mutate the DOM live, commit on release.

Bug I caught

Clicking any window brings it to the front by bumping a z-counter.

0
0
6
Ship

Flit is my answer to a problem I hit every single day: moving a link, a bit of text, or a file between my own devices. The normal fixes all have friction. You email yourself, message yourself, or upload to a cloud drive just to download it thirty seconds later on the machine sitting right next to it. Every one of those involves accounts, apps, and steps that have nothing to do with the actual job.

Flit is a self-hosted drop inbox. One Rust binary, no accounts, no cloud. Open the URL on any device and everything you drop shows up on every other device in real time.

What it actually improves day to day:
Real-time sync over SSE. Paste on the desktop and it's on your phone before you pick it up, no refresh button.
QR pairing, because scanning a code beats typing an IP address and a token on a phone keyboard.

Share links and drop links. Hand someone exactly one item (one-time or expiring if you want), or give them a post-only page where they can send files into your box without seeing what's already in it.
It installs as a PWA, so on Android "share to Flit" works from any app.
Optional browser-side end-to-end encryption (PBKDF2 into AES-GCM), so a public box stores nothing the server can read.

On top of that there's dark mode, an English/Korean toggle, rate limiting, and an ephemeral mode that spins the box down after it sits idle. It's live at the link, it survived me recording a demo (barely, devlog #5 has the carnage), and it's built from scratch in Rust with axum.

Try project → See source code →
Open comments for this post

9h 8m 15s logged

Flit Devlog #5

Last time I ended on “it’s live, it’s public, anyone can hit the URL.” That felt great for about a day, until I thought about what “anyone can hit the URL” actually means. A public drop box where every visitor can read every drop isn’t a feature, it’s a liability. So this entry is me getting paranoid and cleaning up after myself.

The public box problem

I wanted encryption without a server that holds keys. If the box is public, the server should store gibberish. So it happens in the browser: type a passphrase and everything runs through PBKDF2 (150k rounds, SHA-256) into an AES-256-GCM key before it leaves the tab. The server only ever sees a FLITENC1: blob.

Great, until I tested over the LAN and got TypeError: Cannot read properties of undefined (reading 'deriveKey'). crypto.subtle wasn’t there. Web Crypto only exists in a secure context, so localhost and HTTPS work, but http://192.168.x.x on another device is undefined. So I documented it (HTTPS or a VPN hostname for off-localhost encryption) instead of pretending.

A status light that lied

The “Live” dot is driven by the SSE onopen. Locally it fired instantly; on the deployed box it sat on “Connecting…” while drops streamed in fine. The proxy was buffering the first chunk, so onopen fired late. Fix: send one byte on connect (a ready event prepended to the stream) so the proxy flushes and the client knows it’s open.

A server that wouldn’t die

Added graceful shutdown; Ctrl+C then did nothing. with_graceful_shutdown waits for every connection to close, and the SSE stream never closes, that’s its whole point. So it waited forever. I stopped being clever: print, spawn a 500ms timer, std::process::exit(0). Not elegant, but it actually turns off.

Handing over one thing, not the keys

Two mirrored features. Share links (/s/{id}) hand someone a single item, optionally one-time or expiring, without exposing the inbox. Drop links (/d/{id}) are post-only: a guest can send something into my box but can’t see what’s inside.

Making it a real app

A manifest and service worker so Flit installs to the home screen and loads offline. Web Share Target so Android’s “share” can target it. A server-rendered SVG QR (qrcode, default-features = false) so pairing a phone is a scan, not a typed IP.

And then it all broke on camera

Not proud of this. I’d rewritten the whole front-end in one sitting, shipped it, hit record for the demo, and watched it faceplant.

Clicked “Share”: dead button, no request. Console: Uncaught ReferenceError: POST is not defined. I’d written method: POST instead of "POST" — a bare identifier that threw before fetch.

The drop link “worked”: it copied the string undefined. The culprit was await r.json without the (), so j.url read off the function, not the body. One () fixed it.

Once I actually read the file, the rest tumbled out: uploads sent fd.append("file", blob.name) (the filename as text, not the file), download was a.href - URL.createObjectURL(...) (a minus sign, evaluates to NaN), files never rendered because the check was it.kind === "kind", and the status indicator pointed at a #status element that no longer existed.

The infuriating part: the Rust backend was innocent. I traced the whole request path expecting a bug in create_drop or the router and found nothing wrong. The crime scene was entirely the front-end, a pile of one-character typos I’d never have caught without reading it line by line. “It compiles / it loads” is not “it works,” and the front-end has no compiler to yell at you. You find out on camera.

Where Flit stands

  • Real-time public/private drop box: text, links, files
  • Browser-side E2E encryption
  • Share links and post-only guest drops
  • Installable PWA: offline shell, share target, QR pairing
  • Dark mode, EN/KO, rate limiting, ephemeral mode
  • One binary, Docker image, on Render
0
0
1
Ship

I built Lodestar, a personal status sheet designed to look like a printed spec document rather than a typical dark-glass web dashboard. It relies on a warm paper aesthetic, a custom CSS graph-paper grid, and a single ink-red accent to feel like a physical document.

The biggest challenge was resisting the muscle memory of using modern UI frameworks and blur effects. I also had to carefully wire up the scroll-triggered stat animations in vanilla JS to ensure they properly respected the `prefers-reduced-motion` accessibility setting.

I'm most proud of the stat meters—instead of standard progress bars, they use a row of filled and empty block characters to look like a terminal readout, keeping the raw engineering vibe intact.

When you view it, scroll down to see the text-block stat meters animate, and check out the live KST clock ticking in the masthead!

Try project → See source code →
Open comments for this post

16m 21s logged

Devlog #1

Every personal site I’ve ever started died the same way: I open an empty folder, reach for a dark background and a glassy blur, and end up with the same neon-on-black card grid everyone else already shipped. So this time, I gave myself one rule — no dashboard. Lodestar had to look like something you could print.

That constraint decided everything. Instead of a control panel, I built a spec sheet: warm paper, a faint graph-paper grid, one ink-red accent, and a hard black offset shadow. The moment it landed on screen, it stopped feeling like a website and started feeling like paper no.01.

The layout and the grid

The graph-paper background is just two hairline gradients tiled every 24 pixels — no image, no extra request. Combined with Fraunces for the display type and IBM Plex Mono for everything structural, it immediately sets the “engineering worksheet” tone.

Stat bars that aren’t bars

For the stats section, the obvious approach was a standard progress bar. But a smooth gradient bar is a dashboard element. Instead, the meters are text: a row of filled and empty block characters, so each skill reads like a terminal readout rather than a loading bar. They fill up on scroll, and the script cleanly respects prefers-reduced-motion to keep the animation accessible.

Putting it all together

The rest of the sheet came together quickly. I added a live KST clock in the masthead, built out the loadout, quest log, and outbound links, and kept the CSS entirely custom with no frameworks. It’s fully responsive, but keeps the rigid, printable document feel even on mobile.

Where Lodestar stands

  • Editorial spec-sheet layout: paper + graph grid + one ink-red accent.
  • Five sections completed — Identity / Stats / Loadout / Quest log / Signals.
  • Text-block stat meters that fill smoothly on scroll.
  • Live KST clock in the masthead.
  • Successfully deployed on GitHub Pages.
0
0
3
Open comments for this post

4h 48m 9s logged

Flit Devlog #4

Devlog #3 ended on software’s most dangerous sentence: it works great on my machine. This one’s about every other machine — a real flit command, a lock on the door, prebuilt binaries for three OSes, a copy on the open internet, and the devices with no terminal at all. Where Flit stops being my tool and becomes a tool.

A CLI that respects the pipe

The flit client is a thin wrapper around curl with opinions: flit "text", flit -f file, cmd | flit, flit -l. The stdin branch is my favorite — if the script sees it’s on the end of a pipe, whatever flows in becomes a drop, no flags, no quoting. So git log --oneline -5 | flit lands on my iPad before I’ve switched windows. Then I wrote the PowerShell sibling and relearned it isn’t “bash with different keywords”: headers are a hashtable, multipart a whole other ritual, and one missing dash cost me embarrassing time.

A lock for the door

Flit was wide open — fine on localhost, horrifying anywhere else. The rule: no FLIT_TOKEN, nothing changes; set it, and everything except /health demands it. The interesting part wasn’t whether to check a token but how you hand it over. A CLI sends Authorization: Bearer; but you can’t staple a header onto a QR code. So the middleware speaks three dialects — Bearer header, ?token=, or cookie — and when the token arrives by URL, the server upgrades it to a cookie so it doesn’t live in your address bar:

if from_query {
    if let Ok(v) = format!("flit_token={expected}; Path=/; HttpOnly; SameSite=Lax").parse() {
        res.headers_mut().insert(header::SET_COOKIE, v);
    }
}

Open the link once, authed from then on. I also turned the upload cap into a knob (FLIT_MAX_MB).

Three OSes, one tag

Push a v* tag and GitHub Actions builds on three native runners — ubuntu, macos, windows — while a fourth gathers the artifacts into one release. But three OSes means three sets of opinions: Windows insists on .exe, wants shell: bash spelled out, and each names its artifact differently so the release job merges them cleanly. None of it hard in hindsight; all of it a red X on the first try. v0.1.0 now ships Linux, macOS, and Windows binaries from one git tag.

Finding a home (the actual hard part)

Nobody says this: deploying was easy, finding where was the fight. My first pick ambushed me mid-signup with a credit-card wall on the “free” tier. Closed the tab, went to Render, which read the Dockerfile from the repo and worked first try — thanks to one patch: check PORT first, fall back to FLIT_ADDR, else 0.0.0.0:7777. Every platform injects PORT and expects you to listen on it; respect that one variable and the container runs anywhere, ignore it and you stare at “deploy succeeded” beside a site that never answers. Flit’s public face: https://flit-xw2a.onrender.com — open on purpose, drops expire in 10 min, uploads cap at 5 MB. The free box dozes when idle, so the first hit yawns before it wakes.

The last mile: devices with no terminal

The piece it was born for. My laptop has a shell; my iPad and phone don’t. Same trick on both — hijack the system share sheet so “Share → Flit” fires an HTTP request. On iPad that’s Apple Shortcuts; Android uses the open-source HTTP Shortcuts app — identical job. One mental model: the share sheet is the send button. A screenshot goes thumb-to-laptop with no computer in the middle — the entire point.

Where Flit stands

  • Real-time receive — drops appear instantly (#2)
  • Clipboard-direct — text/links hit your clipboard (#3)
  • Auto-expiry — drops delete themselves on a TTL (#3)
  • Send from anything — Linux/macOS/Windows CLI, any browser, iPad, Android

One binary, downloadable for three OSes, curl-able like a caveman or token-locked if you’re not, plus a live demo at https://flit-xw2a.onrender.com. That’s the build. Flit is shipped.

0
0
2
Open comments for this post

2h 52m 30s logged

Flit Devlog #3

Flit has one job: move a thing from one device to another without making you think about it. After devlog #2 it could already do the hard part — receive in real time over SSE, no refresh. But there was still a dumb gap at the end: a link would land, and I’d reach for the mouse, click the card, hit copy. For a project whose whole personality is “frictionless,” that last inch was embarrassing. This entry kills it: paste-on-arrival, and drops that take themselves out when they’re done.

Paste before you ask for it

The plan felt trivial. The SSE stream already fires an item event whenever something new shows up, so: listen for it, grab the newest text, drop it on the clipboard. No button. You copy on your phone, tab to your laptop, and it’s already under Ctrl+V.

source.addEventListener("item", async () => {
  const items = await fetch("/api/items").then((r) => r.json());
  if (items[0]?.text) await navigator.clipboard.writeText(items[0].text);
});

Ship it, right?

…except the browser quietly says no

It didn’t work — and the cruel part is how. No crash, no console error, the text just never landed. The only tell was a buried NotAllowedError.

Here’s what nobody warns you about: clipboard.writeText() needs transient user activation. The browser only allows a clipboard write in the short window right after a real gesture — a click, a keypress. A message arriving over SSE is not a gesture. To the browser, a background script grabbing your clipboard is exactly what it should block. Fair.

This also explained a bug that was driving me up the wall: auto-copy “only worked after toggling it off and on.” Of course — the toggle click itself was the gesture. So the honest fix was to lean in: the auto-copy switch IS the gesture. You arm it with a click, and the browser trusts the page to keep copying. Not magic — just asking permission in the only language the browser accepts.

Drops that clean up after themselves

The other half of frictionless is never having to remember to delete things. A drop inbox that fills up forever is just a junk drawer with a URL.

So every item gets an expires timestamp at creation, and a reaper task wakes up every 30 seconds to sweep anything past it:

let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
    tick.tick().await;
    let t = now();
    state.items.lock().unwrap()
        .retain(|_, it| it.expires == 0 || it.expires > t);
}

I chose an active reaper over lazy “check on read” expiry on purpose: lazy expiry lets stale items linger until someone looks, and the list can briefly lie about what’s still alive. A background sweep keeps memory and the UI honest. Default is one hour (FLIT_TTL_SECS=3600); 0 means keep forever.

Where Flit stands

The three quality-of-life wins are all in:

  • Real-time receive — drops appear instantly (devlog #2)
  • Clipboard-direct — text and links land on your clipboard
  • Auto-expiry — drops delete themselves on a TTL

And it’s still one binary you can curl at like a caveman if you feel like it.

Next up

It works great on my machine — the most dangerous sentence in software. Step 5 makes it real for other machines: a proper flit CLI (plus a PowerShell sibling), an auth layer so it isn’t wide open the second it leaves localhost, and a release workflow that ships prebuilt binaries for all three platforms. See you in #4.

0
0
1
Open comments for this post

1h 16m 28s logged

Devlog #2

session Flit could swallow things — text, links, files — but the only way to see what was in there was to keep poking it with curl. Functional, sure, but it felt like checking a mailbox by reaching in with my eyes closed. This time I wanted the moment that actually makes Flit worth building: you drop something on one device and it just appears on another, no refresh, no waiting.​

So I built the inbox page, and then I taught the server how to tap you on the shoulder.​

The tap is the fun part. My first thought was to have the page ask “anything new?” every couple of seconds, which works but always feels a half-step behind, and it hammers the server for nothing most of the time. WebSockets would do it, but that’s a full two-way phone line when all I need is the server occasionally shouting “hey, look!” down a one-way hallway. That’s exactly what Server-Sent Events are — plain HTTP, the browser reconnects on its own, and I get to keep the whole thing stupidly simple.​

Under the hood there’s a little broadcast channel. Whenever something gets stored, the server drops the new item’s id into the channel, and every browser that’s currently watching /api/events hears it and re-pulls the list. I left a 15-second background poll in there too, just as a safety net for the one time a connection quietly dies — belt and suspenders, because the entire promise of this thing is “it just shows up,” and I’d rather it be boringly reliable than clever.​

The first time it worked I had two terminals open. One sitting on curl -N /api/events, the other posting a note — and the event popped out the instant I hit enter, while the browser tab in the corner of my eye updated in the same breath. I may have said “oh, nice” out loud to an empty room.

Next I want the drop to land where my hands already are: auto-copy the newest text straight to the clipboard the second it arrives, and let old stuff quietly expire so there’s never anything to tidy up. That clipboard moment is the one I’m most excited about — it’s the whole difference between “a place I go to check” and “it’s already there.“​

0
0
3
Ship

# Enigma

I built a working Enigma machine you can use, take apart, and see the electricity move through — all in the browser. Not a diagram of one, not a gif: the real cipher engine, wired to a 3D model you can rotate, peel open, and rewire with your hands.

## What it is

The brain is a historically correct Enigma engine written once in C++ and shared two ways — a CLI binary and a WASM module compiled with Emscripten — so the thing in your browser ciphers letter-for-letter the same as the command line. Double-stepping, ring settings, reflector, plugboard, all of it. It passes the standard test vector (rotors I·II·III / reflector B / rings TOU / start HOU / plugs ABCDXZ → `TOUHOU` encrypts to `MQGGFI`).

On top of that engine sits a Three.js machine you can actually operate: type on the keyboard, the rotors turn to their real angles and the matching lamp lights up. Flip on **X-ray** and the wooden case goes translucent, revealing all 26 wires inside — and the exact current path of the key you just pressed lights up and sparks along the wire, traced straight from the engine's own answer.

## What was hard

The engine was the easy part. Making it *look like the real thing* is what fought back.

- The case isn't one lid — a real Enigma comes apart in three: the main hinged lid, a fold-down flap over the plugboard, and a separate rotor cover with little letter windows. Each one had to hinge and occlude correctly without clipping through the others.
- The plugboard cables kept poking through the closed front cover. I spent two rounds shrinking how far they sagged before realizing the problem was never depth — the cables sat *in front of* the closed cover on the z-axis, so they'd punch through the wood no matter how short I made them. Pull them back behind the cover face and it finally read right.
- Reflector and entry wheel looked like "a rotor with the letters erased." Turns out on the real machine they're fixed inside the housing and never visible from outside — so I hid them in normal view and only expose the contacts under X-ray.

## What I'm proud of

The plugboard you wire with your hands. Grab a socket, drag to another, and a cable snaps in; the wire stretches under your cursor while you drag, the target socket highlights blue, and the patch feeds straight back into the engine so the encryption *and* the X-ray wiring update live. Pulling a cable out works the same way. It feels like handling the real hardware, not clicking a form.

And the honesty of it: one engine, verified against a known test vector, with nothing faked between what you see and what actually ciphers.

## How to test it

- **Type something.** Watch the rotors step and the lamps light. Long messages show the rotors carrying over, double-step and all.
- **Hit X-ray.** The case turns to glass; press a key and follow the lit current path through plugboard → rotors → reflector → back out to the lamp.
- **Wire the plugboard.** Drag between two sockets to pair them, drag a cable out to remove it — the cipher changes immediately.
- **Open it up.** Toggle the lid, the rotor cover, and the front flap to see how the real case comes apart.
- **Trust but verify.** Set rotors I·II·III, reflector B, rings TOU, start HOU, plugs ABCDXZ, and type `TOUHOU` — you should get `MQGGFI`, the canonical Enigma test vector.

  • 6 devlogs
  • 7h
  • 19.69x multiplier
  • 135 Stardust
Try project → See source code →
Open comments for this post

1h 10m 48s logged

Devlog #8


I asked the question — “is this actually accurate?” — and the answer was no. A real Enigma’s case isn’t one big lid you flip up. It splits into three: the main hinged lid, a fold-down flap in front of the plugboard, and a separate cover over the rotors with little letter windows. So I rebuilt all three.

What actually works now

  • Plugboard front flap. On the real machine the plugboard sat behind a panel that folds down from a hinge at the bottom. I added that front panel and tied it to the lid state, so closing the lid folds the flap up over the front too — the way it would look packed away for transport.
  • Rotor cover (Walzenabdeckung). Instead of leaving the rotors fully exposed, there’s now a cover that hides everything except three letter windows and the finger-wheel slits, with brass bezels around each window. It rides its own ▢ 로터 toggle — closed gives the authentic operating look, open shows the rotors.
  • Lid interior. The inside of the lid used to be empty; the real one carried spare bulbs, red/green filters, and a metal plate. Added a bulb-holder strip, the two filters, and a nameplate — and kept them on independent materials so they stay solid in X-ray instead of going transparent along with the case.

Stuff that tripped me up

  • Fully sinking the rotors into a well — so only the finger wheel and a single letter peek out, like the real machine — tangles with the X-ray wiring coordinates I’d already placed. Rather than move all the internal geometry, I got the same read from the cover: close it and you only ever see the letter windows. Same look, none of the rework.
0
0
7
Open comments for this post

58m 42s logged

Devlog #7

The engine has been correct for a while now — rotor stepping, ring settings, reflector, all of it. This session wasn’t about correctness, it was about feel: making the wooden lid open and shut for real, and making a keypress light up an actual path of current through the machine instead of just blinking a lamp at the end.

Then I committed all three changes in one go and the build blew up in my face. Worth it.

What actually works now

  • The lid hinges open and closed. A ▣ 뚜껑 toggle drives a hinge pivot, and the render loop lerps it toward the target angle so it swings instead of snapping shut.
  • Rebuilt the lid as a real box. My first lid was a single flat plank — it clipped straight through the rotors and only covered half the keyboard. Now it’s a hollow box (top + front + two side walls) with the hinge raised above the rotor tops, so it clears everything when it closes.
  • Live current path. Until now, pressing a key did nothing to the cabling — the keyboard-to-plugboard route was never visualized at all. Now I take the trace the engine hands back and draw the whole route — keycap → plugboard → rotor contacts → lamp — as one glowing tube, with a spark running along it.
  • Heavier materials pass. Nudged the wood/brass/steel roughness, metalness, and env-map strength a notch heavier to kill the last of the plastic look.

Stuff that tripped me up

  • I committed all three changes at once and the build died on boot — three TypeScript errors, all from one mistake. My new lid store collided with an existing lid mesh variable, so the compiler threw Property 'subscribe' does not exist..., an implicit-any fell out of that, and on top of it I’d pasted over and deleted a partnerOf helper. Aliasing the import to lidStore and restoring the helper cleared all three at once.
  • Lesson, paid the usual way: when you add a store with the same name as an existing mesh variable, alias it at the import line first — before TypeScript makes you.

Next up

The case opens and the wiring lights up, but it isn’t historically right yet. The real Enigma’s case came apart in three pieces — the main lid, a fold-down flap in front of the plugboard, and a separate cover over the rotors — and mine is still one lid sitting over fully exposed rotors. Next session: make it match the real thing.

0
0
2
Open comments for this post

1h 19m 55s logged

Devlog #1 — From an empty folder to a working drop box

So I finally started building Drop — the little “throw a file/link/note from one device and just grab it on another” thing I keep wishing existed pretty much every day.

The whole pitch is no friction: no login, no app-store download, no database to babysit. You run one binary and suddenly there’s a shared inbox sitting on your network.

What actually works now
I started from literally nothing — cargo new drop –name drop-server — and by the end of the session the server accepts stuff and hands it back.

Stood up an axum 0.8 + tokio server with / and /health just to confirm it was breathing.
Designed one Item type and tossed everything into an in-memory Arc<Mutex>. No database, on purpose — the point is to be instant and disposable, not durable.
POST /api/text takes raw text and is smart enough to tag it as a link when it looks like a URL, otherwise text.
POST /api/file accepts a multipart upload and keeps the original filename + content type.
GET /api/items returns everything newest-first as JSON. I deliberately strip the raw bytes out of the list so it stays light.
GET /api/items/{id}/raw streams the original file back with Content-Disposition: inline.
Stuff that tripped me up
axum 0.8 changed route params from :id to {id}. I pasted a 0.7 example first and the server panicked on boot. Easy fix once I knew what I was looking at.
Multipart is a stream you drain with next_field(), so it has to be mut multipart — the borrow checker kept yelling until I gave in.
My first instinct was to shove file bytes straight into the JSON list. Terrible idea for anything big, so the bytes are now #[serde(skip_serializing)] and only ever come out of the /raw endpoint.
Quick proof it works
curl -d “ship it” localhost:7777/api/text # ok
curl -F “[email protected]” localhost:7777/api/file # ok
curl -s localhost:7777/api/items | jq ‘.[].kind’ # “file” “link” “text”
Next up
Right now it’s all curl, which is fine for me but not exactly the dream. Next session I’m building the actual inbox web page and wiring up SSE, so a new drop pops up on my other devices instantly — that real-time moment is the whole reason I’m making this.

0
0
2
Open comments for this post

1h 31m 34s logged

Finished the PCB, so this round was all about the case. I went with a sandwich design: a bottom shell that holds the PCB and a top plate with 14mm cutouts the switches snap into, bolted together with M3×16 screws into heatset inserts.I modeled the whole thing myself in Fusion 360 — bottom case, top plate, and the full assembly. Everything’s dimensioned around the real part measurements: 0.2mm tolerance on the mating surfaces, a USB-C cutout on the side wall for the XIAO, and openings for the encoder knob and the OLED. Six standoffs inside keep the board from flexing.Exported all three parts as .STEP (Bottom.STEP, Top.STEP, assembled-model.STEP) — STEP keeps the exact geometry, and the print team converts to STL on their end. Whole thing fits well within the 200×200×100mm limit.Case = done. Next up: firmware.

0
0
1
Open comments for this post

2h 48m 44s logged

X-ray mode

This is the part I said I was actually here for. Flip a switch and the wooden case turns to glass so you can see what’s been sealed inside the whole time. Getting it to look right was the fun part. Getting it to stay working cost me the better part of an afternoon.

What I built

  • Case → glass. The casing shells (body, rotor housing, plugboard panel) drop to ~28% opacity with depth-writing turned off, so you can see straight through the wood.
  • A lit interior. A warm point light kicks on inside the case the moment X-ray turns on — otherwise the guts just read as a dark smudge instead of actual hardware.
  • The hidden hardware. Built everything that’s normally sealed away: a battery block with brass terminals, and thick orange cable looms routing down beneath the rotors.
  • The wiring map. A 26-strand bundle draws the actual internal permutation for the current rotor / ring / position setup, so what you see under the glass matches what the engine is really doing.

Then it went dark — on a fresh load only
Everything looked perfect while I was building it. Then I cleared the cache, cold-booted the dev server to check it clean… and X-ray did nothing. Solid wood. The button highlighted, the wiring was clearly toggling on, the opacity was being set — and the case flat-out refused to turn transparent. I burned a good while convinced a git reset had eaten the fix.

The one-line culprit
It wasn’t git — the code was byte-for-byte the version I’d already signed off on. The real reason: in three.js (r137+), if you flip a material’s transparent flag at runtime, the change won’t take effect unless you also set material.needsUpdate = true. My toggle was setting transparent and opacity but never needsUpdate, so blending never actually switched on and opacity = 0.28 was silently ignored. It “worked” during development purely because hot-reload kept rebuilding the materials from scratch — a real cold load exposed it instantly. One line in the toggle loop and the glass came back.

Next up: make the current actually flow — each keypress lighting up the live signal path through the rotors and off the reflector, with a real cable running from the key you pressed into the loom, so the glow starts where your finger does.

0
0
3
Open comments for this post

1h 2m 10s logged

Hardware fidelity pass — “wait, is this actually what the real Enigma looks like?”

After the electrical signal path was rendering, I pulled up real Kriegsmarine M4 photos to compare — and the 3D model didn’t hold up. So before adding anything new, this one is a “make it historically accurate first” pass.

What was wrong

  • Floating letters. The rotor alphabet was hovering in mid-air beside each wheel instead of being on it. On the real machine the letters are engraved around the rotor’s circumference and you read one through a small window.
  • The reflector and entry wheel looked fake. They were basically copy-pasted rotors with the letters wiped off. In reality the reflector (UKW) and entry wheel (ETW) are fixed and tucked inside the housing — there are no spinning lettered drums there at all.
  • Lopsided UI. A huge dead zone under the input field was squashing the 3D view into a thin strip.
  • Input/output drift. The I/O readout was center-aligned, so every keypress shoved the existing letters further left. Genuinely uncomfortable to type on.

What I did

  • Wrapped the alphabet onto the rotor circumference with engraved grooves, contact rings, and brass window bezels, so you read it through the top window like the real thing.
  • Rebuilt the reflector and ETW as smaller dark drums seated in the housing, and set them to stay hidden in the normal view — they’ll surface later in X-ray mode, where the internal wiring belongs.
  • Set the 3D viewport to flex: 1 so it fills the space, and re-framed the camera.
  • Switched the I/O readout to a fixed-width, left-anchored layout, so input and output line up and grow rightward instead of sliding around.

A lesson paid for in blood

Squashed a stack of messy single-digit commits into one clean commit — then ran git reset --hard with an uncommitted layout fix still in the working tree and watched it evaporate. Re-did it in two minutes, but the takeaway stands: run git status before --hard. Always.

Next up: the X-ray translucent mode that exposes the internal wiring and lights up the live signal path through the rotors. That’s the part I actually started this whole thing for.

0
0
3
Open comments for this post

19m 45s logged

I really agonized over how to add UI elements to meet the requirements. I originally intended to create this for display purposes, and the HUD I had made for a school assignment was already somewhat inconvenient. It was quite challenging due to the requirement to add more UI, but I found a way to incorporate features without compromising the aesthetics. I modified the code so that clicking the key descriptions located below the HUD immediately activates the functions. Additionally, instead of using the automatically generated readme.md, I wrote it myself based on my own ideas. While the content might seem AI-driven, I wrote it out by hand since it is also being used for a school assignment presentation.

0
0
2
Ship

🪐 Ephemeris — a real-time 3D solar system simulator, built from scratch

What I made:
A desktop app (Tauri + TypeScript + Three.js) that simulates the solar system from real NASA JPL Horizons initial conditions. Instead of faking the orbits, it actually integrates Newtonian gravity in real time. I hand-implemented three classic computational-physics algorithms: a Velocity Verlet symplectic integrator for stable long-term orbits, a 3D Barnes-Hut octree for O(N log N) gravity, and a Newton-Raphson solver for Kepler's equation to read out live orbital elements.

What was challenging:
The trickiest part was the tension between the symplectic integrator and the Barnes-Hut approximation — every time the octree rebuilds, tiny force discontinuities break symplecticity and energy drift creeps back in. Getting the numerical stability right (gravity softening near close encounters, sub-stepping at high time-acceleration, and removing barycenter drift) and killing GC frame spikes with an octree node memory pool took a lot of iteration.

What I'm proud of:
It genuinely runs on real NASA data and the orbits come out physically correct — the unit tests recover the actual JPL inclinations. And it looks nice too: procedural planet textures, age-faded orbit trails, Saturn's rings, Earth's atmospheric glow, and real axial tilt + rotation.

How to test it:
Open it and the 8 planets load automatically. Drag the time slider to speed things up (1 day/s → 1 year/s), click any planet to see its 6 live orbital elements, then toggle the asteroid belt on (1k–10k particles) and switch between naive O(N²) and Barnes-Hut to watch the FPS difference. You can also pause, reverse, and jump through time.

  • 3 devlogs
  • 4h
  • 14.56x multiplier
  • 51 Stardust
Try project → See source code →
Loading more…

Followers

Loading…