bvandeusenandClaude Opus 5 1d3cc7bcd4
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Nine tints that looked like three — generate the fill instead
Measured, the nine dark subdued fills were separated from each other by at
most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is
why a board of them reads as one card repeated: "I only see 3 colors ... it
looks like a monolithic wall."

Two causes, and the second one is mine.

  NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's
  fill says nothing at all — it only has to keep the board from repeating.
  Those are different jobs and tying them together capped the second at nine
  values for a board that will hold hundreds.

  ONE AXIS IS TOO FEW. The subdued ramp varied hue while pinning every fill
  to the same lightness — deliberately, so each would read as a card against
  the board. But the eye separates by lightness first, so nine hues at one
  lightness are one card nine times. Hue alone was never going to carry it at
  that darkness.

So an untagged note's fill is now generated from its id rather than looked up:
hue anywhere on the circle, one of six lightness levels, saturation fixed.
324 distinct fills in dark and 193 in light, against nine. Separation between
fills goes from a 1.03 ceiling to 1.42.

Varying lightness is only SAFE because the card has its own grey edge now.
While the fill was the card's only boundary it could not afford to drift
toward the board; the edge bought that freedom, one commit before it was
needed.

Saturation is the one dial the hash never touches — variety comes from hue and
lightness, loudness would come from saturation. Dark starts a hair under
`neutral-900` and climbs, so no note is ever darker than a plain card. Light
runs from white down past the board. Body text measures 8.7 at worst against
the 4.5 it needs; the meta row 5.1 against 3.0.

DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it
is binary64; a Kotlin Float is binary32, so the two would round differently
near a channel boundary and a note would be one byte off between the phone and
the browser. Nobody would ever file that — they would see two colours that are
"sort of the same" and never work out why.

The web half cannot be executed here at all (no node on this machine), so the
Kotlin fixture test is the only place the two implementations are ever
compared. It now pins eight generated values as well as the hash, plus the
properties that actually matter: that lightness varies, that nothing sinks
below the card surface, and that body text stays clear of AA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:55:50 -04:00

ThoughtSync

Self-hosted personal thought-capture web app in the FabledSword family — a Google-Keep-style masonry post-it board for capturing disparate thoughts in under a second, designed to grow into a lightweight second brain (labels, search, [[wiki-links]], graph).

  • Stack: Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
  • Auth: native email + password (signed-cookie sessions). Multi-user with the family owner + direct-share + group-share ACL.
  • Web-first. An Android companion is a later, conditional milestone.

Layout

src/thoughtsync/     Quart app (app factory, auth, models, ACL, config, db)
alembic/             async migrations (schema built via `alembic upgrade head`)
tests/               DB-free unit tests (pytest)
frontend/            Vue 3 + Vite + TypeScript + Tailwind SPA
Dockerfile           2-stage build (Vue -> python:3.12-slim runtime)
docker-compose.yml   local app + Postgres stack

Development

Backend (needs a Postgres reachable at THOUGHTSYNC_DATABASE_URL):

pip install -e ".[dev]"
alembic upgrade head
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000

Frontend (proxies /api to :5000):

cd frontend
npm install
npm run dev        # http://localhost:5173

Or run it in Docker. Hot-reload dev stack (edit code, changes reload live) — Postgres + backend + Vite dev server, app at http://localhost:5173:

docker compose -f docker-compose.dev.yml up

Or the full production image (SPA baked in) at http://localhost:5000:

docker compose up --build

Deploy (self-host)

A complete two-container stack (app + Postgres) you can copy and run. Save it as docker-compose.yml, change the two CHANGE_ME passwords (they must match), then docker compose up -d:

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: thoughtsync
      POSTGRES_PASSWORD: CHANGE_ME          # change this
      POSTGRES_DB: thoughtsync
    volumes:
      - thoughtsync-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
      interval: 5s
      timeout: 5s
      retries: 10

  app:
    image: git.fabledsword.com/bvandeusen/thoughtsync:latest   # :dev for the current dev build
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
    volumes:
      - thoughtsync-data:/var/thoughtsync   # uploaded images; omit if you don't use attachments
    ports:
      - "5000:5000"

volumes:
  thoughtsync-db:
  thoughtsync-data:

Then open http://<host>:5000 and register — the first account becomes the admin.

  • Only THOUGHTSYNC_DATABASE_URL is required. THOUGHTSYNC_SECRET_KEY is optional; if unset, a signing key is generated and persisted in the database (sessions survive restarts).
  • Uploaded images live under the thoughtsync-data volume at /var/thoughtsync.
  • The app waits for the database and runs migrations (alembic upgrade head) automatically on start.
  • Image tags: :latest (stable, built from main) · :dev (latest dev build) · :<git-sha> (immutable, for pinning / rollback).
  • Putting it on the public internet: there are four things to do first — close registration, terminate TLS and forward X-Forwarded-Proto, stop publishing the app port, and back up the attachment volume as well as the database. See docs/public-hosting.md, which also lists what the app hardens on its own and what it deliberately doesn't.
  • Install as an app (PWA): ThoughtSync is installable ("Add to Home Screen" / the browser's install button) for an app-like window. Browsers only offer install over a secure context, so put the app behind a reverse proxy terminating HTTPS (or reach it via localhost) — plain http://<host>:5000 won't show the install prompt.

Milestones

  • M0 — Foundation & Identity : Quart+Vue+Postgres skeleton, native auth, sharing-ACL spine, Fabled-Git CI.
  • M1 — Capture Core : note model + masonry board (quick-add, colors, pin, edit-in-place, archive, trash+restore).
  • M1.5 — Roles & DB-backed Settings : first user is admin, admin Settings UI, DATABASE_URL is the only required env.
  • M2 — Organize: labels/tags, search, checklists, attachments.
  • M3 — Second-brain seeds: [[wiki-links]], backlinks, graph view, reminders.
  • M4 — Launch hardening: responsive/PWA, settings UI, polish.

Work happens on dev; main is protected (PR-only).

S
Description
Self-hosted personal thought-capture web app (FabledSword family) — a Google-Keep-style masonry post-it board for fast idea capture, growing into a lightweight second brain. Quart + Vue 3 + PostgreSQL.
Readme
3.2 MiB
2026-08-23 16:50:53 -04:00
Languages
Python 26.3%
Rust 23.9%
Kotlin 22.7%
Vue 13.3%
TypeScript 6.6%
Other 7.2%