Turn everyday data (starting with Brave browsing history) into generated visual art, so novelty-seeking gets satisfied by creation instead of scrolling.
Actively being built. (Previously queued behind other projects in the "replace doomscrolling" lineup per an earlier LLM Council pass — that ordering is no longer a hard gate; build order is whatever's useful.)
- No shared backend. No Neon Postgres for this project.
- Even the standardized
(timestamp, source, value, tags)shape used by other projects in this lineup is, at most, a natural input this system could render — it is not something this system needs to write to. This stays a standalone, read-only consumer of data, not another writer into shared infra.
Python + Pillow, rendering to static PNGs. Simple, scriptable, easy to run on a schedule and dump output to a local folder.
- Source: Brave browsing/search history, auto-exported locally on a
schedule (see
scripts/). - Categorization: adaptive, domain-driven, not a fixed type list.
categorize.pydoesn’t hardcode categories like "stocks" or "video games" — it looks at whatever’s actually in your history, ranks domains by visit count, and turns the most-visited ones into their own categories automatically (named after the domain, e.g.youtube.com,claude.ai,github.com). Everything outside the top domains folds intoother. This means the category set self-adjusts as browsing habits change — nothing to manually re-tune. - Category membership is stabilized with hysteresis, not
recomputed fresh every run: a domain only gets promoted into its own
category once it clears the strict top-
TOP_N_DOMAINScutoff, but only gets demoted once it falls past a wider buffer (TOP_N_DOMAINS + DEMOTE_BUFFER). State lives indata/category_membership.json(gitignored, falls back to a fresh selection if missing/corrupt). Without this, a domain hovering right at the cutoff could flicker in and out of its own category — and therefore its own color — from one run to the next, which would read as a glitch, not evolution. - The category mix feeding a render isn’t a single day’s snapshot — see "How it works" below for how recent days are blended together, and how that mix drives the piece.
- The grid layout itself persists across renders
(
data/render_state.json, gitignored) — each render inherits roughly 70% of the previous render's shape positions/sizes/types, mutating the rest, so today's piece has actual lineage from yesterday's rather than being a full recompute each time. Falls back to a fresh layout if the state file is missing, corrupt, or the screen/icon size changed.
Pipeline, in order, all under scripts/:
export_brave_history.ps1— copies Brave'sHistorySQLite DB out of its locked profile folder intodata/brave_history/. Copying (rather than opening it directly) is what lets this run even while Brave is open.parse_brave_history.py— reads that copy, writes a flat per-URL summary todata/history.csv(url, title, visit_count, last_visit).categorize.py— reads the same SQLite copy'svisitstable (one row per visit event, not just per URL), ranks domains by visit count and picks the top ones as categories (see "Data source" above), and writes:data/categorized_visits.csv— every visit tagged with a categorydata/daily_category_mix.csv— visit counts per category per day
generate_art.py— combines recent days into one snapshot with exponential recency weighting instead of using a single day's raw counts: each prior day's visits count0.5 ** (days_ago / HALF_LIFE_DAYS)toward the mix (half-life 8 days, ~90-day lookback beyond which the weight is negligible). That means today's render is dominated by roughly the last few weeks, but shifts gradually day to day rather than jumping around on single-day noise or lurching once every 3 weeks. The weighted mix is renormalized back to a single-day scale (effective_visits) specifically so the coverage formula below still spans quiet-to-busy correctly instead of saturating once several weeks are blended together. Rendersdata/art/<date>.png(dated by the most recent completed day, the anchor of the decay) as a geometric mosaic sized to your actual screen resolution (detected via the Win32 API at render time, physical pixels not DPI-scaled logical ones, so the wallpaper is sharp). The canvas is divided into an 8x4 grid of sectors (SECTOR_COLS/SECTOR_ROWS); each sector, once active, is completely filled with small icon-sized shapes (matches your configured Windows shell icon size, read from the registry), drawn in randomly sized groups of those cells — mostly pairs and quads, sometimes bigger accent pieces — each one a random shape (triangle / quarter-circle fan / circle / semicircle / stripes / square) colored by a category chosen with probability proportional to that category's weighted share. Every group gets the same gutter inset regardless of size or color, so the piece reads as evenly spaced rather than a jumbled collage — the gutter is the negative space between pieces. Because sectors are strictly non-overlapping regions and each is packed independently with the same non-overlap-checked placement, symbols never overlap, across sectors or within one. Sectors activate starting from the top-left corner, expanding outward ring by ring by Manhattan distance (row + col) -- each ring is a diagonal band, so filling band by band gives the growing region a triangular shape (a diagonal edge sweeping down-right), not a square/rectangular one; how many sectors fill is not a fixed floor or a forced all-32, but a smooth, uncapped function ofeffective_visits(1 - e^(-effective_visits / COVERAGE_SCALE)), so the piece visibly grows or shrinks day to day. An inactive sector just stays background -- no fade, a clean edge, which reads better for this style than a gradient would. Background is black; the 5-color accent palette (ACCENT_COLORSin generate_art.py) is cream#F2E7DA, orange#BF7A4B, rust#B8653B, sage#629475, and dark green#3B6659— brightened versions of an original cream-background palette (#F2E7DB/#C07A4A/#9B5B3A/#4F6B5A/#23312B), since the two darkest colors would have nearly vanished against true black. Each category hashes to one of the five accents, so the same domain gets a consistent-ish color across different days' pieces.set_wallpaper.ps1— sets the newest PNG indata/art/as the Windows desktop wallpaper (Fill style), so the piece is the delivery surface — nothing to open manually.prune_archive.py— every render is dated (data/art/<date>.png), so nothing gets overwritten day to day — but left alone that archive grows one PNG forever. This keeps every render from the last 90 days, and thins anything older down to one per ISO week (oldest render in each week survives), so a long-run timelapse stays possible without unbounded disk growth.run_export.ps1— chains all six steps above. This is what a daily Windows Scheduled Task (GenerativeArtSystem-BraveHistoryExport) runs automatically at 9 AM, so the pipeline refreshes without manual intervention.
All of data/ (raw history, CSVs, rendered art) is gitignored — none
of it is committed. Only the scripts that produce it are.
heart_widget.py is a separate, always-running small program (not
part of the daily pipeline) — a borderless heart button pinned to the
top-right corner of the screen. Click it to copy whatever render is
currently the wallpaper into a dedicated folder,
Pictures\GenerativeArtFavorites\ — separate from the automatic
data/art/ archive, which keeps every render regardless. This folder
only holds what you deliberately hearted.
Hearting is a once-a-day action, not a toggle: after you click it, the button hides itself for the rest of that day and only reappears once tomorrow's render becomes current (checked once a minute). If the process restarts mid-day after you've already hearted that render, it comes back up already hidden rather than re-showing.
It stays out of the way of other apps without ever being "always on
top": it's an overrideredirect window, so it never shows up in the
taskbar or Alt-Tab (no normal way to bring it forward), and instead of
pinning itself above everything, it periodically re-lowers itself to
the bottom of the window stack (once a second). Any app you open or
click naturally ends up in front of it within about a second. This was
chosen over reparenting into Explorer's WorkerW window (the
technique interactive-wallpaper tools use for true desktop-layer
attachment) after testing showed WorkerW discovery is unreliable
across Windows sessions -- it depends on Explorer's undocumented
internal window structure, which varies by version/session (confirmed
failing in two different environments during development). Lowering
the window is a plain, fully-supported stacking operation with no such
dependency, so it behaves the same everywhere.
It uses a solid black background matching the wallpaper's own
background color rather than true window transparency — Windows'
-transparentcolor trick breaks on anti-aliased text/emoji (a single
chroma key ends up matching some of a glyph's own blended edge pixels,
producing a visible hatched artifact), so this sidesteps that entirely
by just blending in. Works well in the top-right corner specifically,
since the mosaic grows as a triangular wedge from the top-left corner
toward the bottom-right (see "How it works" step 4) -- the top-right
stays clear on all but the highest-coverage days. The single latest
area reached is technically the bottom-right corner instead, for the
same reason; worth moving the button there if coverage regularly gets
high enough to reach the top-right first.
Launch it manually whenever you want it active:
pythonw scripts\heart_widget.py
To have it (and the resume watchdog below) start automatically every
time you log into Windows, run scripts\setup_autostart.ps1 yourself.
That's a separate, opt-in step rather than something the pipeline sets
up on its own — logon-triggered auto-start is a more invasive,
persistent capability than a scheduled daily task, worth a deliberate
decision on your end.
That script uses the Windows Startup folder, not Task Scheduler:
Register-ScheduledTask -AtLogOn returns Access is denied in this
environment -- confirmed even run directly from an interactive session,
not just from automation -- while plain daily-trigger tasks (the ones
the main pipeline uses) register fine. The Startup folder is a
different, equally standard Windows mechanism that isn't gated by
whatever's blocking that specific Task Scheduler trigger type. Since a
Startup-folder shortcut launches immediately at logon with no built-in
delay option, the script generates a small VBScript launcher per
program (data/*-launcher.vbs, gitignored -- they embed your machine's
pythonw.exe path) that sleeps 45 seconds before actually starting it,
so neither ever adds to the startup rush.
Resource use. Measured on a live instance before any of the changes below: ~0% CPU (below measurement precision over a 10s sample) and ~28MB working set, and ~170ms from process launch to the window being ready. That was already negligible in raw CPU% terms, but wake frequency matters for laptop idle power independent of that number, so the widget is built to minimize it regardless:
- The button's own re-lower timer (see above) only runs while it's actually visible. Once hearted (or hidden on restart because it was already hearted), that timer stops entirely -- for most of the day, only the slow "did a new render appear" check keeps ticking.
- Both timers are intentionally relaxed (2s for re-lowering, 5min for the render check) -- there's no reason either needs to be tighter for a button reacting to daily-cadence events.
- The process sets its own priority to
IDLEat startup, so it never competes with foreground apps for CPU scheduling regardless of how often it wakes. (Needed explicit HANDLE-width ctypes types forGetCurrentProcess/SetPriorityClass-- the default 32-bit marshaling truncates the pseudo-handle and fails silently withERROR_INVALID_HANDLEon 64-bit Windows otherwise.)
This isn't a separate "low power mode" you opt into -- there's no real tradeoff to always running this way, so it's just how the widget behaves.
After certain sleep/wake cycles, desktop compositing can end up in a
stale state where the wallpaper stops actually being drawn -- another
app's window (in practice, Rainmeter's own desktop-attached skin) ends
up covering the screen instead, and the heart widget can end up hidden
behind it too. Nothing in this pipeline is actually broken when this
happens -- the wallpaper registry setting and the widget process are
both still correct the whole time, they just aren't being rendered.
Restarting explorer.exe (Task Manager → find "Windows Explorer" →
Restart) fixes it by forcing everything to redraw; confirmed this
manually once.
resume_watchdog.py automates that fix. It detects resume-from-
sleep with a simple wall-clock heuristic rather than hooking raw
Windows power-event messages (WM_POWERBROADCAST) -- it checks every
30 seconds, and if the elapsed time since the last check is much
longer than that (>90s), this process was suspended for a while, since
its own thread can't run at all during sleep. This works regardless of
which underlying clock does or doesn't keep ticking during suspend,
because it's the watchdog's own execution that stalls, not a
hardware counter's behavior -- a more robust approach than the
already-proven-unreliable-in-this-project WorkerW-style Windows
internals. On detecting resume, it restarts explorer.exe, re-applies
the wallpaper, and relaunches the heart widget if it isn't already
running. Runs at IDLE priority like the heart widget, registered and
started the same way (see above) -- both are set up by the same
setup_autostart.ps1 script.
If the data-to-visual transform were purely deterministic, quiet/flat
data days (barely any browsing, or one category dominating) would
produce flat, boring art — undermining the whole point of satisfying
novelty-seeking. generate_art.py handles this in a few ways: shape
choice within every sector is seeded per-day random (not a fixed
layout); which sectors within a ring get filled first, when the budget
runs out mid-ring, is also seeded-random (so the boundary of a quiet
day's piece is a jagged, organic edge rather than a clean rectangle);
and even on the quietest day, at least the corner sector still renders
rather than the canvas going fully blank. Real data still steers the
color composition and how many sectors fill; the seeded randomness
guarantees the arrangement is never the same twice.
Council-reviewed decision (see project history for the full session): rejected batching to one render every ~3 weeks, since that's a slower snapshot, not evolution — you'd notice it less, not more. Built instead, in order of the council's recommendation:
- The daily 9am render reads a decay-weighted recent-history window (see "How it works" step 4), so the ~3-week feel comes from a smooth, continuously-updating blend rather than an infrequent jump.
- The coverage formula runs on that window's renormalized average, not a raw multi-day sum, so it doesn't saturate near full-screen every render.
- Category-to-color identity is stabilized with hysteresis (see "Data source" above) so it can't flicker.
- The council's strongest point: decay-weighting alone is still a fresh recompute from raw history every time, not true persistence. A wallpaper only really "evolves" once today's render is a function of yesterday's render. The grid layout now persists across renders for exactly this reason (see "Data source" above) — today's piece has actual lineage from yesterday's, not just fresh data poured into a fresh recompute.
- Renders archive instead of overwriting, with weekly thinning past 90 days (see "How it works" step 6), so a long-run timelapse stays possible later without committing to building one now.
- Something that actually uses the archive — a timelapse/GIF stitched
from
data/art/, or a quarterly grid tiling recent renders into one "generative memoir" piece. - Tune
TOP_N_DOMAINS/DEMOTE_BUFFER(categorize.py) andSECTOR_COLS/SECTOR_ROWS/COVERAGE_SCALE/HALF_LIFE_DAYS/GUTTER_FRACTION/REUSE_PROBABILITY/MIN_SECTORS(generate_art.py) as real usage patterns become clearer. - Consider a secondary/fallback delivery surface beyond wallpaper (a local gallery folder?) — there's still no forcing function pulling this back into view otherwise.