<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mark Ndubuisi</title>
    <description>The latest articles on DEV Community by Mark Ndubuisi (@devtochukwu).</description>
    <link>https://dev.to/devtochukwu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1003732%2F3eaffc29-2ad2-4869-8085-bd409297dbc2.png</url>
      <title>DEV Community: Mark Ndubuisi</title>
      <link>https://dev.to/devtochukwu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devtochukwu"/>
    <language>en</language>
    <item>
      <title>How I got my HP fingerprint sensor working on Linux.</title>
      <dc:creator>Mark Ndubuisi</dc:creator>
      <pubDate>Wed, 20 May 2026 05:40:55 +0000</pubDate>
      <link>https://dev.to/devtochukwu/how-i-got-my-hp-fingerprint-sensor-working-on-linux-and-why-nobody-else-had-3n2a</link>
      <guid>https://dev.to/devtochukwu/how-i-got-my-hp-fingerprint-sensor-working-on-linux-and-why-nobody-else-had-3n2a</guid>
      <description>&lt;p&gt;I bought a used HP EliteBook 840 G5 last year. Cleaned it up, wiped Windows, put Ubuntu on it. Everything worked except the fingerprint reader, which I figured I'd get to "eventually."&lt;/p&gt;

&lt;p&gt;"Eventually" turned out to mean three sessions, several wrong turns, a USB reverse engineering side quest, and a one-line fix that fixes the same problem for a chunk of HP laptops nobody had been able to use on Linux.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — if you just want it to work
&lt;/h2&gt;

&lt;p&gt;If you've got a Validity/Synaptics sensor with USB ID &lt;code&gt;138a:00ab&lt;/code&gt; or &lt;code&gt;06cb:00b7&lt;/code&gt; (HP EliteBook 840 G5 and several HP G6-family laptops), you don't need to read the rest. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;add-apt-repository ppa:devtochukwu/fingerprint
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;python3-validity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Builds for Ubuntu 22.04 (jammy), 24.04 (noble), 25.10 (questing), and 26.04 (resolute). Enroll a finger via &lt;strong&gt;Settings → Users → Fingerprint Login&lt;/strong&gt;, or &lt;code&gt;sudo fprintd-enroll -f right-index-finger $USER&lt;/code&gt; from the terminal. &lt;code&gt;sudo&lt;/code&gt;, screen unlock, and GNOME login are all wired up automatically.&lt;/p&gt;

&lt;p&gt;The rest of this post is &lt;em&gt;why&lt;/em&gt; this took multiple sessions and a USB reverse-engineering detour to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expectation: just install a driver
&lt;/h2&gt;

&lt;p&gt;This is the thing non-Linux people (and a lot of Linux people, honestly) get wrong about hardware support. You don't "just install a driver." Either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The kernel already supports it (out of the box)&lt;/li&gt;
&lt;li&gt;Someone reverse-engineered the protocol and shipped a userspace driver&lt;/li&gt;
&lt;li&gt;The vendor ships a Linux driver (rare outside of mainstream chipsets)&lt;/li&gt;
&lt;li&gt;It doesn't work&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My sensor was case 4. Specifically: &lt;code&gt;138a:00ab&lt;/code&gt;, a Synaptics VFS7552 chip with their "PurePrint" anti-spoofing variant. Synaptics only ships a Windows driver. &lt;code&gt;libfprint&lt;/code&gt; (the standard Linux fingerprint library) has no driver for this PID. &lt;code&gt;python-validity&lt;/code&gt; (the heroic community reverse-engineering project) supports the related Lenovo Prometheus chips but not mine.&lt;/p&gt;

&lt;p&gt;Of 649 systems with this exact device logged on linux-hardware.org, zero worked on Linux. There were three open GitHub issues asking for support, the oldest from 2023.&lt;/p&gt;

&lt;p&gt;So that was the starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: figure out what you actually have
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;lsusb | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; validity
&lt;span class="go"&gt;Bus 001 Device 019: ID 138a:00ab Validity Sensors, Inc.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Searching &lt;code&gt;138a:00ab&lt;/code&gt; led to &lt;a href="https://linux-hardware.org/?id=usb:138a-00ab" rel="noopener noreferrer"&gt;linux-hardware.org's page&lt;/a&gt;, which confirmed the model ("Validity Sensors Synaptics VFS7552 Touch Fingerprint Sensor with PurePrint") and the bleak status: 649 reports, all failing.&lt;/p&gt;

&lt;p&gt;The PurePrint suffix sounded ominous. Looking at the USB endpoint structure (5 endpoints: one bulk OUT, two bulk IN, two interrupt IN), I figured PurePrint probably added an encrypted channel on top of the plain VFS7552 protocol. Spoiler: I was half right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first dead end: the libfprint vfs7552 driver
&lt;/h2&gt;

&lt;p&gt;libfprint ships a driver called &lt;code&gt;vfs7552&lt;/code&gt; for the Dell XPS variant of this chip (PID &lt;code&gt;0091&lt;/code&gt;). I cloned the repo, added &lt;code&gt;0x00ab&lt;/code&gt; to its PID table, rebuilt, and tried it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cmd_01 (rom info) ............. OK, 38 bytes returned
cmd_19 ........................ OK, 68 bytes
vfs7552_init_00 (501 bytes) ... rejected, status 0x04be
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chip &lt;em&gt;accepted&lt;/em&gt; the basic identification commands and returned valid-looking data. It just refused the 501-byte initialization blob that the Dell driver wanted to send.&lt;/p&gt;

&lt;p&gt;Diffing the cmd_01 response against the Dell reference values, my chip and the Dell one agreed on the first 18 bytes (sensor family identifier) but differed in a few bytes that looked like firmware version (&lt;code&gt;0x03d1&lt;/code&gt; vs &lt;code&gt;0x0000&lt;/code&gt;) and a per-chip serial number. The init blob is firmware-version-specific. Sending the Dell's blob to my chip is like sending Windows XP install media to a Mac.&lt;/p&gt;

&lt;p&gt;I tried disassembling the HP Windows driver (&lt;code&gt;synaWudfBioUsb.dll&lt;/code&gt;, extracted from the official HP softpaq) in &lt;code&gt;radare2&lt;/code&gt; to find the right bytes to send. I got far enough to confirm the driver does ECDH key exchange and signed firmware upload — &lt;code&gt;BCryptGenerateKeyPair&lt;/code&gt;, &lt;code&gt;BCryptSecretAgreement&lt;/code&gt;, &lt;code&gt;BCryptSignHash&lt;/code&gt; all in the imports — but the actual byte sequences turned out to be dynamic, not static. They're constructed from runtime crypto, not stored as constants you can grep for.&lt;/p&gt;

&lt;p&gt;The libfprint path was over. The chip wasn't really a vfs7552; it was something more sophisticated wearing a vfs7552 marketing label.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pivot: python-validity
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;python-validity&lt;/code&gt; is uunicorn's reverse-engineered userspace driver for the chips Synaptics calls "Prometheus" — &lt;code&gt;138a:0090&lt;/code&gt;, &lt;code&gt;0097&lt;/code&gt;, &lt;code&gt;009d&lt;/code&gt;, &lt;code&gt;06cb:009a&lt;/code&gt;. These chips do real TLS-like crypto handshakes and signed firmware upload, which matched what I'd seen in the disassembly.&lt;/p&gt;

&lt;p&gt;The architecture made me hopeful: maybe &lt;code&gt;0x00ab&lt;/code&gt; was just a Prometheus chip wearing a vfs7552 marketing label. The protocol bits I'd already verified (cmd_01, cmd_19) are also what python-validity's &lt;code&gt;usb.send_init()&lt;/code&gt; sends first.&lt;/p&gt;

&lt;p&gt;I wrote a minimal test script that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Opened the device&lt;/li&gt;
&lt;li&gt;Sent &lt;code&gt;cmd_01&lt;/code&gt;, &lt;code&gt;cmd_19&lt;/code&gt;, &lt;code&gt;cmd_4302&lt;/code&gt; (basic info queries) — all worked&lt;/li&gt;
&lt;li&gt;Sent python-validity's &lt;code&gt;init_hardcoded&lt;/code&gt; blob (a 581-byte crypto blob for the supported chips)
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;init_hardcoded (581 bytes): send 06 02 00 00 01 4a 23 14 06 e5 54 2f c6 dc 3b 1a ...
recv 2 bytes, status=00 00: 00 00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0000&lt;/code&gt;. Status OK. The HP chip accepted python-validity's blob, which meant: same protocol family. Verified by going further and completing the full ECDH key exchange — the encrypted channel established cleanly against the chip's factory TLS state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The painful middle: I thought I was stuck
&lt;/h2&gt;

&lt;p&gt;I patched python-validity to support &lt;code&gt;00ab&lt;/code&gt;. The service started. &lt;code&gt;fprintd-enroll&lt;/code&gt; ran, asked for finger swipes, completed. &lt;code&gt;fprintd-verify&lt;/code&gt;... hung forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;fprintd-verify
&lt;span class="go"&gt;Using device /net/reactivated/Fprint/Device/0
Listing enrolled fingers:
&lt;/span&gt;&lt;span class="gp"&gt; - #&lt;/span&gt;0: right-index-finger
&lt;span class="go"&gt;Verify started!
Verifying: any
&lt;/span&gt;&lt;span class="gp"&gt;^C   #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;me, after a minute of nothing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I figured the problem was that python-validity's &lt;code&gt;sensor.open()&lt;/code&gt; only knows two sensor type profiles (&lt;code&gt;0x199&lt;/code&gt; and &lt;code&gt;0xdb&lt;/code&gt;), and my chip reports &lt;code&gt;0xd51&lt;/code&gt;. With the wrong profile, image dimensions and calibration parameters would be wrong, producing garbage images.&lt;/p&gt;

&lt;p&gt;I spent hours on this hypothesis. Tried both profiles. Looked for ways to extract the right values from Windows. Read the open issues. &lt;strong&gt;Issue #225 — same &lt;code&gt;0xd51&lt;/code&gt; sensor type on a different USB ID — was a user who had hit the exact same wall with the exact same patches I'd applied.&lt;/strong&gt; They got stuck at the same place.&lt;/p&gt;

&lt;p&gt;The narrative I had in my head was: this is the calibration wall. Without per-chip data extracted from Windows, we're done. I started writing the apologetic "we did good work but here's where it ends" wrap-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking at the actual data
&lt;/h2&gt;

&lt;p&gt;But before giving up I added one more piece of instrumentation: dump every TLS-decrypted response over 100 bytes to disk. Re-enrolled. Looked at what came out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4104 B  cmd40   (× 4)     calibration frames
1966 B  cmd02   (× 9)     per-stage frame data
5040, 9584, 14128, 14128, 18672, 18672, 18672, 23304 B  cmd6b   enrollment template
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The enrollment template was growing by ~4500 bytes per scan. That's real feature data accumulating. If the chip were producing garbage images, the matcher wouldn't have anything to extract features from — the template wouldn't grow.&lt;/p&gt;

&lt;p&gt;I rendered the &lt;code&gt;cmd02&lt;/code&gt; frames as 44×44 grayscale PNGs. They didn't look like noise. They looked like fingerprint ridges.&lt;/p&gt;

&lt;p&gt;The chip was capturing real images. The matcher was building real templates. So why did verify hang?&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual bug
&lt;/h2&gt;

&lt;p&gt;I went back and stared at &lt;code&gt;sensor.capture()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;assert_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build_cmd_02&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

    &lt;span class="c1"&gt;# start
&lt;/span&gt;    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;usb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_int&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wait_start: Unexpected interrupt type ...&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# wait for finger
&lt;/span&gt;    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;usb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_int&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="c1"&gt;# wait capture complete
&lt;/span&gt;    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;usb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_int&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Unexpected interrupt type ...&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three interrupt phases: start, finger-detected, capture-complete. I went back to the journal logs and looked at what interrupts my chip was actually sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;int&amp;lt; 00 00 00 00 00    # b[0] = 0  (start)
&amp;lt;int&amp;lt; 03 20 07 00 00    # b[0] = 3  (capture event)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Two interrupts. The chip never sent &lt;code&gt;b[0] = 2&lt;/code&gt; "finger detected." It skipped straight from start to capture event.&lt;/p&gt;

&lt;p&gt;So the wait-for-finger loop sat there forever, waiting for an interrupt that was never coming. The chip had captured the image, the matcher had a result, but the daemon couldn't tell because of an infinite loop in user code.&lt;/p&gt;

&lt;p&gt;The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# wait for finger. Sensor type 0xd51 (138a:00ab, 06cb:00b7) does not
# emit b[0]=2; it jumps directly to capture events. Accept b[0]=3 as
# a substitute and save the interrupt for the next loop.
&lt;/span&gt;&lt;span class="n"&gt;saved_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;usb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_int&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;real_device_type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mh"&gt;0xd51&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;saved_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;

&lt;span class="c1"&gt;# wait capture complete
&lt;/span&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;saved_b&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;saved_b&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;usb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_int&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;saved_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight lines, gated on the chip type so existing supported chips take the unchanged code path.&lt;/p&gt;

&lt;p&gt;Restart the service. Re-enroll. &lt;code&gt;fprintd-verify&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verify result: verify-retry-scan (not done)
Verify result: verify-retry-scan (not done)
Verify result: verify-match (done)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A correct finger matched. Tried with a different finger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verify result: verify-no-match (done)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real matching, real rejection. Wired it through PAM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo whoami&lt;/span&gt;
&lt;span class="go"&gt;[sudo] Place your finger on the fingerprint reader
root
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What was actually hard about this
&lt;/h2&gt;

&lt;p&gt;The thing that fooled me — and fooled everyone else who had tried — was that the chip &lt;em&gt;appeared&lt;/em&gt; to be calibration-stuck. Enrollment completed (because enrollment uses a slightly different code path that doesn't wait for &lt;code&gt;b[0]=2&lt;/code&gt;). Verify hung. The natural conclusion was "matching fails, images are bad." Everyone tried fiddling with the sensor profile. Nobody tried instrumenting the interrupt stream.&lt;/p&gt;

&lt;p&gt;Looking at the actual returned data was the move. The cmd6b templates growing by 4500 bytes per scan was the smoking gun: the chip was clearly producing real features, which meant the chip was capturing real images, which meant the image pipeline was working, which meant the problem had to be &lt;em&gt;after&lt;/em&gt; image capture and &lt;em&gt;before&lt;/em&gt; match results came back. That's a small window — and the wait-finger loop was sitting in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the patch
&lt;/h2&gt;

&lt;p&gt;The PR is at &lt;a href="https://github.com/uunicorn/python-validity/pull/256" rel="noopener noreferrer"&gt;uunicorn/python-validity#256&lt;/a&gt;. +37 / -3 lines across five files. It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wires &lt;code&gt;138a:00ab&lt;/code&gt; and &lt;code&gt;06cb:00b7&lt;/code&gt; through the &lt;code&gt;SupportedDevices&lt;/code&gt; enum, blob routing, firmware mapping, and udev rules&lt;/li&gt;
&lt;li&gt;Aliases sensor type &lt;code&gt;0xd51&lt;/code&gt; to the &lt;code&gt;0x199&lt;/code&gt; profile so downstream &lt;code&gt;SensorTypeInfo&lt;/code&gt; / &lt;code&gt;SensorCaptureProg&lt;/code&gt; lookups succeed (no native profile exists yet — empirically &lt;code&gt;0x199&lt;/code&gt; is close enough that the on-chip matcher accepts real images)&lt;/li&gt;
&lt;li&gt;Patches &lt;code&gt;Sensor.capture()&lt;/code&gt; to accept &lt;code&gt;b[0]=3&lt;/code&gt; as a substitute for &lt;code&gt;b[0]=2&lt;/code&gt;, gated on the real device type&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If it merges, three open issues get closed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/uunicorn/python-validity/issues/181" rel="noopener noreferrer"&gt;#181&lt;/a&gt; — open since 2023&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/uunicorn/python-validity/issues/225" rel="noopener noreferrer"&gt;#225&lt;/a&gt; — &lt;code&gt;06cb:00b7&lt;/code&gt; user with the same wall&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/uunicorn/python-validity/issues/238" rel="noopener noreferrer"&gt;#238&lt;/a&gt; — newer report&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have one of these chips and don't want to wait for the merge, you can clone my fork branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;-b&lt;/span&gt; feat/sensor-type-0xd51 https://github.com/SimpleX-T/python-validity.git
&lt;span class="nb"&gt;sudo &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--break-system-packages&lt;/span&gt; &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr ./python-validity
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart python3-validity.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(You'll also need &lt;code&gt;open-fprintd&lt;/code&gt; and the supporting D-Bus / systemd / udev plumbing, which the PR description and python-validity's debian/ folder document.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardware support on Linux is not "downloading a driver."&lt;/strong&gt; When the vendor doesn't ship one, real people spend real weeks reverse-engineering protocols. Look at the python-validity codebase sometime; the existing supported chips have ~500-byte chip-specific crypto blobs in them that were extracted from USB captures of Windows driver sessions. That work doesn't happen by itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a hypothesis explains some of the evidence, don't stop.&lt;/strong&gt; I had a clean story — "calibration is wrong, images are bad, matching fails" — that explained the verify failure. It did not explain why enrollment completed cleanly and why the template was growing. I should have noticed the contradiction earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the actual data the chip is sending.&lt;/strong&gt; Adding the TLS-response dump took ten minutes and changed everything. I had been reasoning about what I thought the chip was sending; the actual bytes told a different story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI-assisted reverse engineering is a real workflow now.&lt;/strong&gt; I did this with Claude Code; the model held the protocol knowledge I didn't have and ran disassemblers and code searches in parallel while I was deciding what to do next. The interrupt-handling insight was a result of "let me actually look at every byte the chip sent us and check the contradictions" — a kind of careful empiricism that's much easier with an assistant doing the bookkeeping.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've got a Validity/Synaptics fingerprint reader that doesn't work on Linux, check the USB ID and the open python-validity issues before assuming it's hopeless. There's a lot of "almost working" out there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;p&gt;I didn't do this alone. All three sessions of this — the libfprint dead end, the python-validity pivot, the calibration rabbit hole, and finally the interrupt-loop fix — happened in &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, with Claude Opus as a pair. The model held protocol details I didn't have, ran disassemblers and &lt;code&gt;grep&lt;/code&gt;s and test scripts in parallel while I was deciding what to do next, and kept track of every hypothesis we'd already ruled out. The interrupt-handling insight came out of "wait, the template is growing — that contradicts the bad-images story; what else could it be?" which is a kind of careful re-examination that's much easier when you've got a partner doing the bookkeeping.&lt;/p&gt;

&lt;p&gt;The hardware was mine. The patience for re-enrolling a finger thirty times was mine. The decisions about when to give up and when to push were mine. But the protocol memory and the second pair of eyes were Claude's, and I want to call that out because "I built X" stories tend to hide the assist.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>reverseengineering</category>
      <category>opensource</category>
      <category>claude</category>
    </item>
    <item>
      <title>Unison: Building a Workspace Where Language Barriers Don't Exist</title>
      <dc:creator>Mark Ndubuisi</dc:creator>
      <pubDate>Sun, 22 Feb 2026 08:49:13 +0000</pubDate>
      <link>https://dev.to/devtochukwu/unison-building-a-workspace-where-language-barriers-dont-exist-887</link>
      <guid>https://dev.to/devtochukwu/unison-building-a-workspace-where-language-barriers-dont-exist-887</guid>
      <description>&lt;h2&gt;
  
  
  The Problem Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Remote work won. The debate is over. But here's the thing nobody seems to be solving well: &lt;strong&gt;your team is global now, but your tools still assume everyone speaks the same language.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Think about it. You've got a product manager in Tokyo writing specs in Japanese. A designer in Berlin annotating wireframes in German. A developer in Lagos writing task descriptions in English. They're all using the same Notion workspace, the same Slack channels, the same Google Docs — and they're all quietly struggling.&lt;/p&gt;

&lt;p&gt;The usual workflow looks something like this: write something in your language, copy it, open Google Translate in another tab, paste, copy the result, paste it back into the shared doc. Multiply that by every message, every document, every comment, every day. It's exhausting, and it introduces a subtle but real friction that slows teams down and makes people feel like outsiders in their own workspace.&lt;/p&gt;

&lt;p&gt;I had to work with some Japanese developers once, and I experienced this friction first-hand; I believe they did too. I had to manually translate messages sent to me before understanding what was sent, then I had to send back a manually-translated text, which was tiring and inefficient in a fast-paced modern workspace as the one we're in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Unison&lt;/strong&gt; is a collaborative workspace where every piece of text — documents, chat messages, task descriptions, comments, UI labels — adapts to the reader's preferred language automatically. You write in yours. Your teammate reads in theirs. Nobody has to think about translation.&lt;/p&gt;

&lt;p&gt;But Unison isn't just a "Google Docs with auto-translate bolted on." The core insight that drives the whole architecture is this: &lt;strong&gt;real-time collaboration and translation are fundamentally at odds, and you need a different model to make them work together.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Features
&lt;/h3&gt;

&lt;p&gt;Here's what Unison includes as a full workspace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Collaborative Documents&lt;/strong&gt; — A rich text editor with real-time sync, powered by Yjs CRDTs and TipTap&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat Channels&lt;/strong&gt; — Real-time messaging where every message is translated on-the-fly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kanban Boards&lt;/strong&gt; — Task management with drag-and-drop, priorities, and due dates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whiteboards&lt;/strong&gt; — An infinite canvas (powered by Tldraw) for visual collaboration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git-Inspired Document Branching&lt;/strong&gt; — The piece I'm most proud of, and the one that required the most thinking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of it, in 12 languages. All of it, translated in real-time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Problem: Why "Just Translate Everything" Doesn't Work
&lt;/h2&gt;

&lt;p&gt;Early on, I tried the obvious approach: everyone edits the same document simultaneously (standard CRDT behavior), and we just translate the content for each viewer.&lt;/p&gt;

&lt;p&gt;It broke immediately.&lt;/p&gt;

&lt;p&gt;When two people type into the same paragraph at the same time — one in English, one in Japanese — the CRDT merges their keystrokes character by character. You end up with gibberish: half-English, half-Japanese fragments that can't be translated because they're not valid text in either language.&lt;/p&gt;

&lt;p&gt;The core tension is: &lt;strong&gt;CRDTs are designed to merge concurrent edits at the character level. Translation operates on complete sentences and paragraphs.&lt;/strong&gt; These two models are incompatible when applied to the same shared state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Git-Inspired Branching
&lt;/h2&gt;

&lt;p&gt;The answer came from an unlikely place: Git.&lt;/p&gt;

&lt;p&gt;In Git, you don't have everyone pushing to &lt;code&gt;main&lt;/code&gt; simultaneously. You branch, you work, you submit a pull request, someone reviews, and then your changes get merged. We applied the same model to document collaboration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The document owner works on &lt;code&gt;main&lt;/code&gt;&lt;/strong&gt; — they edit the canonical version directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collaborators get personal branches&lt;/strong&gt; — when you open a shared document, Unison automatically creates your own branch (a fork of &lt;code&gt;main&lt;/code&gt;). You edit freely in your own language, on your own Y.Doc instance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submit for review&lt;/strong&gt; — when you're done, you submit your branch. The owner sees it in their Merge Panel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translate and merge&lt;/strong&gt; — Unison translates the branch content into the document's original language, the owner reviews the translated preview, and merges it into &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each branch gets its own Yjs document, its own Supabase Realtime channel (&lt;code&gt;yjs:{docId}:branch:{branchId}&lt;/code&gt;), and its own persistence. Branches are fully isolated — no character-level conflicts across languages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Owner (English)     ──── edits main Y.Doc ────────────────&amp;gt; main
                                                              ↑
Collaborator A (Japanese) ──── edits branch A ── submit ──── merge (translate JP → EN)
                                                              ↑
Collaborator B (Spanish)  ──── edits branch B ── submit ──── merge (translate ES → EN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the "aha" moment. By separating the editing contexts and deferring the merge to a deliberate review step, we avoid the CRDT-vs-translation conflict entirely. Each person edits a clean, single-language document. Translation only happens once, at merge time, on complete content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Translation Architecture
&lt;/h2&gt;

&lt;p&gt;Translation is the backbone of Unison, so getting it right was critical. We built a two-tier system:&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 1: Static UI Strings (Zero API Calls)
&lt;/h3&gt;

&lt;p&gt;Every button, label, tooltip, and placeholder in the app is pre-translated into all 12 supported languages and bundled at build time. A &lt;code&gt;useUITranslation()&lt;/code&gt; hook reads the user's preferred language from the Zustand store and returns the right string instantly — no network request, no loading state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useUITranslation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// t("sidebar.documents") → "Documents" | "ドキュメント" | "Documentos" | ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This covers 100+ UI string keys across every component. When you switch your language, the entire interface updates instantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 2: Dynamic Content Translation (Three-Layer Cache)
&lt;/h3&gt;

&lt;p&gt;For user-generated content — document text, chat messages, task titles, comments — we use a &lt;code&gt;useTranslation()&lt;/code&gt; hook backed by a three-layer cache:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;L1: In-memory Map&lt;/strong&gt; — instant for repeated translations within a session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;L2: Supabase &lt;code&gt;translation_cache&lt;/code&gt; table&lt;/strong&gt; — persists across sessions, shared across users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;L3: Translation API&lt;/strong&gt; — calls Lingo.dev SDK&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: the first time someone's Japanese message gets translated to English, it hits the API. Every subsequent view — by any user, in any session — is a cache hit. Translation costs amortize to near-zero over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lingo.dev as the Translation Engine
&lt;/h3&gt;

&lt;p&gt;We use &lt;a href="https://lingo.dev" rel="noopener noreferrer"&gt;Lingo.dev&lt;/a&gt; as our primary translation engine via their SDK. It handles the actual text localization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LingoDotDevEngine&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LINGODOTDEV_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localizeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sourceLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fromLanguage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;targetLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toLanguage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Real-Time Chat Translation
&lt;/h2&gt;

&lt;p&gt;The chat system is where translation feels most magical. Here's the flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user in Tokyo types a message in Japanese and hits Enter&lt;/li&gt;
&lt;li&gt;The message is stored in Supabase with &lt;code&gt;original_language: "ja"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Supabase Realtime broadcasts the INSERT to all channel subscribers&lt;/li&gt;
&lt;li&gt;Each recipient's client receives the message and passes it through &lt;code&gt;useTranslation()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A user in Berlin sees the message in German. A user in Lagos sees it in English.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The sender sees their original message. The "Translated from Japanese" badge is subtle — just enough context to know it's a translation, not enough to break the flow of conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Next.js 16 (App Router)&lt;/td&gt;
&lt;td&gt;Server components for initial data loading, client components for interactivity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Database + Auth + Realtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;PostgreSQL with RLS for security, Realtime for live updates, Auth for identity — one service for three concerns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Real-Time Collaboration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yjs + TipTap&lt;/td&gt;
&lt;td&gt;Yjs CRDTs handle conflict-free editing; TipTap gives us a rich text editor on top&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Translation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lingo.dev SDK + DeepL (fallback)&lt;/td&gt;
&lt;td&gt;Reliable, high-quality translations with redundancy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Whiteboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tldraw&lt;/td&gt;
&lt;td&gt;Mature infinite-canvas library, stores state as serializable snapshots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State Management&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zustand&lt;/td&gt;
&lt;td&gt;Lightweight, no boilerplate, perfect for user/workspace context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Drag &amp;amp; Drop&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;@hello-pangea/dnd&lt;/td&gt;
&lt;td&gt;Accessible, performant drag-and-drop for the Kanban board&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Styling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom CSS + CSS variables&lt;/td&gt;
&lt;td&gt;Theming (dark/light mode), animations, no utility-class bloat in the markup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture Decisions I'd Make Again
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Branch-per-user instead of shared editing for cross-language docs.&lt;/strong&gt; This was the biggest departure from "how collaboration tools usually work" and it paid off. The tradeoff is that collaborators can't see each other's edits in real-time — but that's actually fine when they're writing in different languages. You wouldn't be able to read their edits anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-translated UI strings instead of runtime translation.&lt;/strong&gt; Translating 100+ UI strings at runtime would mean either (a) 100+ API calls on page load or (b) a loading spinner while UI text loads. Neither is acceptable. Pre-translating everything into a static dictionary and bundling it means the UI is always instant, regardless of language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three-layer translation cache.&lt;/strong&gt; The in-memory cache makes repeated renders free. The database cache means translations survive page refreshes and are shared across users. The API is only called for genuinely new content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase for everything.&lt;/strong&gt; Using one service for auth, database, and realtime means fewer integration points, fewer credentials, fewer things to debug at 2am during a hackathon. The RLS policies also mean our security model is enforced at the database level, not just in application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Building multilingual applications is a complex task, I never realized it until I started.&lt;/li&gt;
&lt;li&gt;The modern workspace needs more tools like Unison, and we need to build them.&lt;/li&gt;
&lt;li&gt;Building the collaborative document editor was probably the hardest of all, because I had to make it seamless and friction-less for collaborators.&lt;/li&gt;
&lt;li&gt;If I had to start over, I will go with the diffing approach first, and then iterate from their; I believe I will have a better result that way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;With guidance from the &lt;code&gt;lingo.dev&lt;/code&gt; team, I plan to improve the translation mode from whole-text translation to per-word translation with backwards translation feature.&lt;/li&gt;
&lt;li&gt;I plan to improve on the diffing mechanism to allow a smoother flow for collaborators on documents.&lt;/li&gt;
&lt;li&gt;I will also improve the document editor tools, for more rich text editing.&lt;/li&gt;
&lt;li&gt;I plan to integrate voice translation too (optimistic feature), so that teams can send voice notes across to each other, and also use the speech-to-text feature in editing documents.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Unison&lt;/strong&gt; was built for the &lt;code&gt;lingo.dev&lt;/code&gt; hackathon. The idea is simple: your tools should adapt to you, not the other way around. Language is context, not a barrier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/simplex-t/unison" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;&lt;br&gt;
&lt;a href="https://unison-nine-swart.vercel.app" rel="noopener noreferrer"&gt;Live demo&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;devtochukwu is product-oriented software engineer, with years of experience building real-world solutions on different platforms. Find him on &lt;a href="https://x.com/devtochukwu" rel="noopener noreferrer"&gt;X&lt;/a&gt; and &lt;a href="https://www.linkedin.com/in/devtochukwu" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>saas</category>
      <category>startup</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
