Nimbin[12]?Web & Tools / Git-Server / commits / 1d84f22

Git-Server git · main

gitsync + this read-only git browser

git php bash apache self-hosted · first commit 2026-10-05 · last commit 2026-10-06 (3 days ago) · synced 3 days ago

PHP 77.1% CSS 11.4% Markdown 8.6% Makefile 2.9%
git clone https://git.christianimmanuel.de/web-tools/Git-Server.gitwget https://git.christianimmanuel.de/web-tools/Git-Server/archive/Git-Server.tar.gz

init

Christian Immanuel · 2026-10-05 10:18 · 1d84f2275a2bb65257597c319ef6acdf8ae11c18

 Makefile                                    |  54 +++
 README.md                                   | 134 +++++++
 apache/git.christianimmanuel.de-common.conf |  53 +++
 apache/git.christianimmanuel.de-le-ssl.conf |  10 +
 apache/git.christianimmanuel.de.conf        |   8 +
 cron/gitsync                                |  19 +
 gitsync                                     | 218 +++++++++++
 gitsync.conf                                |  75 ++++
 web/index.php                               | 569 ++++++++++++++++++++++++++++
 web/style.css                               |  97 +++++
 10 files changed, 1237 insertions(+)

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..151599c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,54 @@
+# gitsync - 100% Vibecode but tested
+PREFIX  ?= /usr
+BINDIR   = $(PREFIX)/bin
+CONF     = $(HOME)/.config/gitsync/gitsync.conf
+HOST    ?= debian@162.19.227.194
+REMOTE  ?= /var/www/html/git.christianimmanuel.de
+
+.PHONY: install uninstall config config-update deploy deploy-web deploy-apache clean ssh-key cron cron-daily uncron
+KEY      = $(HOME)/.ssh/gitsync_ed25519
+CRON     = 0 12 * * * ping -c1 -W5 162.19.227.194 >/dev/null 2>&1 && $(BINDIR)/gitsync -q
+
+install:              ## install gitsync to /usr/bin (needs root)
+	install -Dm755 gitsync $(DESTDIR)$(BINDIR)/gitsync
+
+uninstall:            ## remove gitsync
+	rm -f $(DESTDIR)$(BINDIR)/gitsync
+
+config:               ## copy example config to ~/.config/gitsync (does not overwrite)
+	@test -f $(CONF) && echo "$(CONF) exists, not touching it" || (install -Dm644 gitsync.conf $(CONF) && echo "created $(CONF)")
+
+config-update:        ## overwrite ~/.config/gitsync/gitsync.conf with the shipped one (keeps a .bak)
+	@test -f $(CONF) && cp $(CONF) $(CONF).bak && echo "backup at $(CONF).bak" || true
+	install -Dm644 gitsync.conf $(CONF)
+
+deploy-web:           ## upload only index.php + style.css
+	rsync -a web/index.php web/style.css $(HOST):$(REMOTE)/
+
+deploy-apache:        ## upload apache configs (http, https, shared) to /tmp on the server
+	scp apache/git.christianimmanuel.de.conf apache/git.christianimmanuel.de-le-ssl.conf apache/git.christianimmanuel.de-common.conf $(HOST):/tmp/
+	@echo "on the server: sudo mv /tmp/git.christianimmanuel.de*.conf /etc/apache2/sites-available/ && sudo apache2ctl configtest && sudo systemctl reload apache2"
+
+deploy: deploy-web deploy-apache
+
+ssh-key:              ## create the upload key and print what to do on the server
+	@test -f $(KEY) || ssh-keygen -t ed25519 -N "" -C gitsync -f $(KEY)
+	@echo; echo "on the server (as root):"; echo "  adduser --disabled-password --gecos '' git-local"
+	@echo "  install -d -m700 -o git-local -g git-local /home/git-local/.ssh"
+	@echo "  echo 'restrict,port-forwarding $$(cat $(KEY).pub)' > /home/git-local/.ssh/authorized_keys"
+	@echo "  chown git-local:git-local /home/git-local/.ssh/authorized_keys; chmod 600 /home/git-local/.ssh/authorized_keys"
+	@echo "  chown -R git-local:www-data $(REMOTE)/repos"
+	@echo; echo "then: ssh -i $(KEY) git-local@162.19.227.194 true"
+
+cron:                 ## sync daily at 12:00 when the server is reachable
+	@( crontab -l 2>/dev/null | grep -v '/gitsync -q'; echo "$(CRON)" ) | crontab -
+	@crontab -l | grep gitsync
+
+cron-daily:           ## system-wide alternative: /etc/cron.daily/gitsync (run as root, set USER= inside)
+	install -m755 cron/gitsync /etc/cron.daily/gitsync
+
+uncron:               ## remove the cron job
+	@crontab -l 2>/dev/null | grep -v '/gitsync -q' | crontab - ; echo "removed"
+
+clean:                ## remove local staging cache
+	rm -rf $(HOME)/.cache/gitsync
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f5b45c7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,134 @@
+# gitsync + git browser
+
+Self-hosted, read-only git browser for `git.christianimmanuel.de`.
+Repos are grouped in categories, browsable like GitHub/GitLab, and cloneable via HTTPS.
+Nothing is pushed to the server with git; `gitsync` on your laptop publishes the projects you list.
+
+**100% Vibecode but tested.**
+
+## Parts
+
+| File | What |
+|---|---|
+| `gitsync` | laptop script: stages listed projects, rsyncs them to the server |
+| `gitsync.conf` | example config with categories |
+| `web/index.php`, `web/style.css` | the browser (PHP, no database) |
+| `apache/*.conf` | vhosts: `-common.conf` holds everything (browser, clones, private auth), the `:80` and `-le-ssl` vhosts just include it |
+| `Makefile` | install / uninstall / config / deploy / clean |
+
+## Server (Debian)
+
+```sh
+sudo apt install apache2 git libapache2-mod-php rsync
+sudo a2enmod rewrite cgi env
+sudo mkdir -p /var/www/html/git.christianimmanuel.de/repos
+sudo chown -R debian:www-data /var/www/html/git.christianimmanuel.de
+```
+
+From the laptop: `make deploy`, then on the server:
+
+```sh
+sudo mv /tmp/git.christianimmanuel.de*.conf /etc/apache2/sites-available/
+sudo a2ensite git.christianimmanuel.de && sudo systemctl reload apache2
+sudo certbot --apache -d git.christianimmanuel.de     # first time only; afterwards keep our -le-ssl.conf
+sudo a2ensite git.christianimmanuel.de-le-ssl && sudo systemctl reload apache2
+```
+
+Both vhosts only `Include` `git.christianimmanuel.de-common.conf`; edit that one file for changes.
+
+The `repos/` directory is only reachable through `index.php` and `git-http-backend`.
+Only `git-upload-pack` is exposed, so clones work and pushes do not.
+
+## Laptop
+
+```sh
+sudo make install   # /usr/bin/gitsync
+make config         # ~/.config/gitsync/gitsync.conf (only if missing)
+make config-update  # overwrite it with the shipped config (.bak is kept)
+gitsync             # publish
+```
+
+Config format:
+
+```
+HOST=debian@162.19.227.194
+REMOTE_DIR=/var/www/html/git.christianimmanuel.de
+
+[SDL & Graphics]
+~/Git/sdl_3d | 3D rendering experiments | sdl, 3d, c
+```
+
+- Git projects become bare repos (`category/name.git`) with all branches and tags. Clone URL: `https://git.christianimmanuel.de/category/name.git`
+- Plain directories are copied as files, shown without commits. `.gitignore` files are honoured (per directory, like git). Symlinks are followed, the target's content is published (dangling links are skipped with a warning). Build junk (`CMakeFiles`, `*.o`, `*.swp`, `__pycache__`, ...), compiled binaries and files over `MAX_SIZE` (default 50m) are skipped; `EXCLUDE=a,b` in the config adds more patterns. `STAGE=` moves the staging dir (default `~/.cache/gitsync`).
+- Category order on the page = order in the config.
+- "Recently updated" on the index sorts by the `modified` date from each project's meta file: last commit for git projects, newest file mtime for plain directories (rsync keeps mtimes).
+- Removing a line removes the repo from the server on the next run.
+- Tags are optional (third column), searchable on the site.
+- Origin remote (GitHub/Codeberg) is shown as upstream link if the project has one.
+
+### Automatic daily sync
+
+```sh
+make ssh-key        # creates ~/.ssh/gitsync_ed25519 and prints the server-side commands (user git-local)
+make cron           # crontab entry: 12:00 daily, only if the server answers a ping
+make uncron         # remove it
+sudo make cron-daily  # alternative without a user crontab: /etc/cron.daily/gitsync (edit USER= in cron/gitsync first)
+```
+
+Set `HOST=git-local@…` and `SSH_KEY=~/.ssh/gitsync_ed25519` in the config (default). The upload runs with `BatchMode`, so it never hangs on a password prompt.
+
+Options: `-n` dry run, `-q` quiet, `-l` stage only, `-c` list config, `-f` ignore secret scan, `-v` verbose, `-V` version.
+
+### Secret scan
+
+Before staging, every project is checked for private keys, AWS/GitHub/GitLab/Slack/OpenAI tokens,
+`api_key = "…"` / `password: '…'` style lines with a quoted literal value containing a digit (placeholders like `"your_api_key"` are ignored), and files like `.env`, `id_rsa`, `*.pem`.
+Git projects: tracked files and the whole history are scanned (untracked/ignored files are never published anyway). Plain directories: exactly the staged files are scanned, so ignored files don't trigger it. Files named `*example*`, `*sample*`, `*template*` or `*public*` are exempt from the filename check. A hit skips that project and prints where it was found.
+If it is a false positive, publish with `gitsync -f`. If it is real: rotate the key, then remove it from history:
+
+```sh
+pip install git-filter-repo
+git filter-repo --invert-paths --path path/to/client.key --path path/to/server.key
+git push --force origin --all   # if the project has a remote
+```
+
+`gitsync` checks the whole history because a bare clone ships every commit, not just HEAD.
+Staging dir is `~/.cache/gitsync` (`make clean` removes it).
+
+## Private projects
+
+Prefix a config line with `!` to publish it under `/private` instead of the public tree:
+
+```
+[Private]
+!~/LFS/lfs-config | My LFS config | lfs
+```
+
+Everything below `/private` (pages, raw files, tarballs, `git clone`) is protected by HTTP Basic auth in
+Apache. Private projects are not listed on the public pages and are **not** secret-scanned. Create users on the server:
+
+```sh
+sudo apt install apache2-utils
+sudo htpasswd -cB /var/www/html/git.christianimmanuel.de/.htpasswd NAME    # -c only for the first user
+```
+
+Fetch with `wget --user=NAME --ask-password URL` or `git clone https://NAME@git.christianimmanuel.de/private/cat/name.git`.
+Apache enforces this in `-common.conf`; `index.php` additionally checks the login itself against `.htpasswd` (bcrypt), so pages, raw files and tarballs stay closed even with an outdated vhost.
+
+## Scripts collection
+
+`SCRIPTS=~/Bash-Public` in the config publishes a directory of standalone scripts at `/scripts`.
+Every subdirectory is a group, every file a script. Lines starting with `### ` at the top of a file
+are shown as description (`######` lines are ignored). Each script gets a `wget … && chmod 740` line,
+a source view and a raw URL (`/scripts/raw/<group>/<file>`). Same excludes, `.gitignore` and secret scan as plain directories.
+
+## Browser features
+
+Categories, cards with description, tags, language bar and last change. Search by name/description/tag.
+File tree, file view, Markdown README (rendered server side), raw files, commit log with diffs,
+commit activity graph (per repo and site wide), `git clone` and `wget` tarball commands, dark mode.
+Everything is plain HTML, so it works in lynx; JavaScript only adds syntax highlighting and copy buttons.
+
+## Make targets
+
+`install`, `uninstall`, `config`, `config-update`, `ssh-key`, `cron`, `uncron`, `deploy-web`, `deploy-apache`, `deploy`, `clean`
diff --git a/apache/git.christianimmanuel.de-common.conf b/apache/git.christianimmanuel.de-common.conf
new file mode 100644
index 0000000..e3272d9
--- /dev/null
+++ b/apache/git.christianimmanuel.de-common.conf
@@ -0,0 +1,53 @@
+# /etc/apache2/sites-available/git.christianimmanuel.de-common.conf
+# Shared part of the git.christianimmanuel.de vhosts. Included by the :80 and the :443 (certbot) vhost.
+# needs: a2enmod rewrite cgi env  (and php module)
+
+DocumentRoot /var/www/html/git.christianimmanuel.de
+
+# --- clone via smart HTTP (read-only: no receive-pack is exposed) ---
+SetEnv GIT_PROJECT_ROOT /var/www/html/git.christianimmanuel.de/repos
+SetEnv GIT_HTTP_EXPORT_ALL 1
+SetEnv GIT_CONFIG_COUNT 1
+SetEnv GIT_CONFIG_KEY_0 safe.directory
+SetEnv GIT_CONFIG_VALUE_0 *
+ScriptAliasMatch \
+    "(?x)^/([^/]+/[^/]+\.git/(HEAD|info/refs|objects/(info/[^/]+|[0-9a-f]{2}/[0-9a-f]{38,62}|pack/pack-[0-9a-f]{40,64}\.(pack|idx))|git-upload-pack))$" \
+    /usr/lib/git-core/git-http-backend/$1
+ScriptAliasMatch \
+    "(?x)^/private/([^/]+/[^/]+\.git/(HEAD|info/refs|objects/(info/[^/]+|[0-9a-f]{2}/[0-9a-f]{38,62}|pack/pack-[0-9a-f]{40,64}\.(pack|idx))|git-upload-pack))$" \
+    /usr/lib/git-core/git-http-backend/.private/$1
+
+<Directory /usr/lib/git-core>
+    Options +ExecCGI
+    Require all granted
+</Directory>
+
+# --- private area: everything below /private needs a password (browse, raw, tarball, clone) ---
+# users:  htpasswd -cB /var/www/html/git.christianimmanuel.de/.htpasswd NAME   (-c only the first time)
+<Location /private>
+    AuthType Basic
+    AuthName "private"
+    AuthUserFile /var/www/html/git.christianimmanuel.de/.htpasswd
+    Require valid-user
+</Location>
+
+# --- browser ---
+<Directory /var/www/html/git.christianimmanuel.de>
+    Options -Indexes
+    AllowOverride None
+    Require all granted
+    RewriteEngine On
+    RewriteCond %{REQUEST_FILENAME} !-f
+    RewriteRule ^ index.php [L]
+</Directory>
+<FilesMatch "^\.">
+    Require all denied
+</FilesMatch>
+
+# repos are only reachable through git-http-backend or index.php
+<Directory /var/www/html/git.christianimmanuel.de/repos>
+    Require all denied
+</Directory>
+
+ErrorLog ${APACHE_LOG_DIR}/git.christianimmanuel.de-error.log
+CustomLog ${APACHE_LOG_DIR}/git.christianimmanuel.de-access.log combined
diff --git a/apache/git.christianimmanuel.de-le-ssl.conf b/apache/git.christianimmanuel.de-le-ssl.conf
new file mode 100644
index 0000000..5f2eb8a
--- /dev/null
+++ b/apache/git.christianimmanuel.de-le-ssl.conf
@@ -0,0 +1,10 @@
+# /etc/apache2/sites-available/git.christianimmanuel.de-le-ssl.conf  (https, certs from certbot)
+<IfModule mod_ssl.c>
+<VirtualHost *:443>
+    ServerName git.christianimmanuel.de
+    Include /etc/apache2/sites-available/git.christianimmanuel.de-common.conf
+    SSLCertificateFile /etc/letsencrypt/live/git.christianimmanuel.de/fullchain.pem
+    SSLCertificateKeyFile /etc/letsencrypt/live/git.christianimmanuel.de/privkey.pem
+    Include /etc/letsencrypt/options-ssl-apache.conf
+</VirtualHost>
+</IfModule>
diff --git a/apache/git.christianimmanuel.de.conf b/apache/git.christianimmanuel.de.conf
new file mode 100644
index 0000000..35478bc
--- /dev/null
+++ b/apache/git.christianimmanuel.de.conf
@@ -0,0 +1,8 @@
+# /etc/apache2/sites-available/git.christianimmanuel.de.conf  (plain http: redirect to https)
+<VirtualHost *:80>
+    ServerName git.christianimmanuel.de
+    Include /etc/apache2/sites-available/git.christianimmanuel.de-common.conf
+    RewriteEngine On
+    RewriteCond %{SERVER_NAME} =git.christianimmanuel.de
+    RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
+</VirtualHost>
diff --git a/cron/gitsync b/cron/gitsync
new file mode 100755
index 0000000..7fb0c6c
--- /dev/null
+++ b/cron/gitsync
@@ -0,0 +1,19 @@
+#!/bin/sh
+# /etc/cron.daily/gitsync - publish repos once a day, as the normal user.
+# Runs as root via cron.daily, drops to USER (config, ssh key and repos live in that home).
+# If the server is not reachable it retries a few times, then gives up until tomorrow.
+USER=n76310
+HOST=162.19.227.194
+RETRIES=6        # attempts
+WAIT=600         # seconds between attempts (10 min)
+
+i=0
+while [ "$i" -lt "$RETRIES" ]; do
+    if ping -c1 -W5 "$HOST" >/dev/null 2>&1 && su - "$USER" -c '/usr/bin/gitsync -q'; then
+        exit 0
+    fi
+    i=$((i + 1))
+    [ "$i" -lt "$RETRIES" ] && sleep "$WAIT"
+done
+echo "gitsync: server $HOST not reachable after $RETRIES attempts, giving up until tomorrow" >&2
+exit 0
diff --git a/gitsync b/gitsync
new file mode 100755
index 0000000..202613b
--- /dev/null
+++ b/gitsync
@@ -0,0 +1,218 @@
+#!/bin/bash
+# gitsync - push marked local repos to a read-only git browser server.
+# 100% Vibecode but tested.
+set -eu
+
+VERSION="1.9.2"
+CONF="${GITSYNC_CONF:-$HOME/.config/gitsync/gitsync.conf}"
+STAGE="${GITSYNC_STAGE:-$HOME/.cache/gitsync}"
+DRYRUN=0 LOCAL=0 VERBOSE=0 FORCE=0 LIST=0 QUIET=0 SSH_KEY=""
+# always the real git binary, never a shell alias/function/wrapper (GIT= in config overrides)
+GIT=$(command -v /usr/bin/git || command -v /bin/git || echo git)
+# build junk never published from plain (non-git) directories; EXCLUDE= in the config adds more
+MAX_SIZE="50m"   # plain dirs: files above this are skipped (MAX_SIZE= in config)
+EXCLUDES=".git CMakeFiles CMakeCache.txt cmake_install.cmake *.o *.a *.so *.swp *.swo *.pyc __pycache__ .idea .ipynb_checkpoints massif.out.* core"
+
+usage() {
+cat <<USAGE
+gitsync $VERSION - sync repos listed in $CONF
+
+  gitsync            stage all repos and upload to server
+  gitsync -n         dry run (show what would happen)
+  gitsync -l         only stage locally, no upload
+  gitsync -c         list configured repos
+  gitsync -f         publish even if the secret scan finds something
+  gitsync -v         verbose rsync/git output
+  gitsync -q         quiet (only warnings; for cron)
+  gitsync -h         this help
+  gitsync -V         version
+
+Config: HOST=user@server, REMOTE_DIR=/var/www/html/site, then
+[Category] sections with lines:  /path | description | tag1, tag2
+Prefix a line with ! to publish it under /private (HTTP auth, no secret scan).
+Optional: SSH_KEY=~/.ssh/gitsync_ed25519 (key for the upload, no password prompt),
+          SCRIPTS=~/Bash-Public (script collection, subdirs = groups),
+          EXCLUDE=a,b (extra excludes), MAX_SIZE=50m (skip bigger files),
+          STAGE=~/other/dir (staging dir, default ~/.cache/gitsync)
+USAGE
+}
+
+log()  { [ "$QUIET" = 1 ] || printf '\033[1;34m==>\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
+die()  { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
+q()    { [ "$VERBOSE" = 1 ] && echo "" || echo "-q"; }
+slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-//; s/-$//'; }
+trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
+
+case "${1:-}" in --version|-V) echo "gitsync $VERSION"; exit 0 ;; --help) usage; exit 0 ;; esac
+while getopts "nlcfvqh" o; do
+  case $o in
+    n) DRYRUN=1 ;; l) LOCAL=1 ;; c) LIST=1 ;; f) FORCE=1 ;; v) VERBOSE=1 ;; q) QUIET=1 ;;
+    h) usage; exit 0 ;; *) usage; exit 1 ;;
+  esac
+done
+[ -f "$CONF" ] || die "no config at $CONF (see gitsync.conf example)"
+log "config: $CONF"
+
+# --- secret scan -------------------------------------------------------------
+# content patterns (case-insensitive ERE) and file name patterns
+SECRET_RE='(AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,}|(api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|password|passwd)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{8,}["'"'"'])'
+SECRET_FILES='(^|/)(\.env([./].*)?|id_(rsa|dsa|ecdsa|ed25519)|.*\.(pem|key|p12|pfx|kdbx)|.*(secret|credential)s?[^/]*)$'
+
+scan_secrets() { # src kind -> prints findings, returns 1 if any
+  local src="$1" kind="$2" hits
+  if [ "$kind" = git ]; then
+    hits=$( { "$GIT" -C "$src" ls-files | grep -iE "$SECRET_FILES" | grep -viE 'public|example|sample|template' | sed 's/^/file: /';
+             "$GIT" -C "$src" grep -I -i -n -E -e "$SECRET_RE" $("$GIT" -C "$src" rev-list --all 2>/dev/null | head -500) -- . 2>/dev/null | cut -c1-160 | sort -u | head -20; } || true )
+  else
+    hits=$( { find "$src" -type f -not -name .gitsync.meta | sed "s|^$src/||" | grep -iE "$SECRET_FILES" | grep -viE 'public|example|sample|template' | sed 's/^/file: /';
+             grep -rIinE --exclude-dir=.git -e "$SECRET_RE" "$src" 2>/dev/null | sed "s|^$src/||" | cut -c1-160 | head -20; } || true )
+  fi
+  hits=$(printf '%s\n' "$hits" | grep -vE '[=:][[:space:]]*["'"'"']?[A-Z][A-Z0-9_]{4,}["'"'"']?[,;)]?[[:space:]]*$' \
+       | grep -E 'file: |BEGIN |AKIA|ghp_|glpat-|xox[baprs]-|sk-|[=:][[:space:]]*["'"'"'][A-Za-z_/+.=-]*[0-9]' || true)
+  [ -z "$hits" ] && return 0
+  printf '%s\n' "$hits" | sed 's/^/    /' >&2
+  return 1
+}
+
+# --- language stats -----------------------------------------------------------
+lang_stats() { # reads "size path" lines on stdin -> "C:1234,Shell:99"
+  awk '
+  function lang(p,  b, e) { b=p; sub(/.*\//,"",b); e=tolower(b); sub(/.*\./,"",e);
+    if (tolower(b)=="makefile"||e=="mk") return "Makefile";
+    if (e=="c"||e=="h") return "C"; if (e=="cpp"||e=="cc"||e=="cxx"||e=="hpp"||e=="hh") return "C++";
+    if (e=="py") return "Python"; if (e=="sh"||e=="bash") return "Shell"; if (e=="js"||e=="mjs") return "JavaScript";
+    if (e=="ts") return "TypeScript"; if (e=="php") return "PHP"; if (e=="html"||e=="htm") return "HTML";
+    if (e=="css") return "CSS"; if (e=="rs") return "Rust"; if (e=="go") return "Go"; if (e=="java") return "Java";
+    if (e=="vim") return "Vim Script"; if (e=="lua") return "Lua"; if (e=="glsl"||e=="vert"||e=="frag") return "GLSL";
+    if (e=="s"||e=="asm") return "Assembly"; if (e=="md") return "Markdown"; if (e=="rb") return "Ruby";
+    if (e=="pl") return "Perl"; if (e=="cs") return "C#"; if (e=="kt") return "Kotlin"; if (e=="swift") return "Swift";
+    if (e=="tex") return "TeX"; return "" }
+  { l=lang($2); if (l!="") s[l]+=$1 }
+  END { for (l in s) printf "%s:%d\n", l, s[l] }' | sort -t: -k2 -nr | paste -sd, -
+}
+
+write_meta() { # file desc tags origin created modified languages
+  printf 'description=%s\ntags=%s\norigin=%s\ncreated=%s\nmodified=%s\nlanguages=%s\nsynced=%s\n' "$2" "$3" "$4" "$5" "$6" "$7" "$(date +%s)" > "$1"
+}
+
+sync_git() { # src bare desc tags
+  local src="$1" bare="$2" branch origin created modified langs
+  [ -d "$bare" ] || { "$GIT" init -q --bare "$bare"; rm -f "$bare"/hooks/*.sample; }
+  "$GIT" -C "$src" push $(q) --force --prune "$bare" 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' \
+    || { warn "push failed for $src (no commits?)"; return 0; }
+  branch=$("$GIT" -C "$src" symbolic-ref --short HEAD 2>/dev/null || true)
+  [ -n "$branch" ] || branch=$("$GIT" -C "$bare" for-each-ref --format='%(refname:short)' refs/heads | head -n1)
+  [ -n "$branch" ] && "$GIT" -C "$bare" symbolic-ref HEAD "refs/heads/$branch"
+  "$GIT" -C "$bare" update-server-info
+  origin=$("$GIT" -C "$src" remote get-url origin 2>/dev/null | sed -E 's#^(ssh://)?git@([^:/]+)[:/]#https://\2/#; s#\.git$##' || true)
+  created=$("$GIT" -C "$src" log --reverse --format=%at | head -n1)
+  modified=$("$GIT" -C "$src" log -1 --format=%at)
+  langs=$("$GIT" -C "$src" ls-tree -r -l HEAD | awk '{print $4, $5}' | lang_stats)
+  write_meta "$bare/gitsync.meta" "$3" "$4" "$origin" "$created" "$modified" "$langs"
+}
+
+sync_plain() { # src dst desc tags
+  local src="$1" dst="$2" created modified langs ex=() f
+  mkdir -p "$dst"
+  for f in $EXCLUDES; do ex+=(--exclude="$f"); done
+  # compiled binaries (ELF) are skipped too
+  while IFS= read -r -d '' f; do
+    [ "$(head -c4 "$f" 2>/dev/null | tail -c3 | tr -d '\0')" = "ELF" ] && ex+=(--exclude="/${f#"$src/"}")
+  done < <(find -L "$src" -type f -not -path '*/.git/*' -size +0 -print0 2>/dev/null)
+  # honour .gitignore files like git would; symlinks are followed and their target content copied
+  rsync -aL --delete --max-size="$MAX_SIZE" --filter=':- .gitignore' "${ex[@]}" "$src/" "$dst/" \
+    || { rc=$?; [ "$rc" = 23 ] || [ "$rc" = 24 ] || return "$rc"; warn "$(basename "$src"): some files skipped (dangling symlink?)"; }
+  big=$(find -L "$src" -type f -not -path '*/.git/*' -size +"$MAX_SIZE" 2>/dev/null | wc -l)
+  [ "$big" -gt 0 ] && warn "$(basename "$src"): $big file(s) over $MAX_SIZE skipped"
+  created=$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | head -n1 | cut -d. -f1)
+  modified=$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | tail -n1 | cut -d. -f1)
+  langs=$(find "$dst" -type f -printf '%s %p\n' | lang_stats)
+  write_meta "$dst/.gitsync.meta" "$3" "$4" "" "$created" "$modified" "$langs"
+}
+
+HOST="" REMOTE_DIR="" SCRIPTS="" cat="" cslug="" BLOCKED=0
+mkdir -p "$STAGE"
+: > "$STAGE/.categories.new"
+declare -a KEEP=()
+
+while IFS= read -r line || [ -n "$line" ]; do
+  line="${line%%#*}"; line=$(trim "$line")
+  [ -z "$line" ] && continue
+  case "$line" in
+    HOST=*)       HOST="${line#HOST=}" ;;
+    REMOTE_DIR=*) REMOTE_DIR="${line#REMOTE_DIR=}" ;;
+    MAX_SIZE=*)   MAX_SIZE="${line#MAX_SIZE=}" ;;
+    GIT=*)        GIT="${line#GIT=}" ;;
+    SSH_KEY=*)    SSH_KEY="${line#SSH_KEY=}"; SSH_KEY="${SSH_KEY/#\~/$HOME}" ;;
+    SCRIPTS=*)    SCRIPTS="${line#SCRIPTS=}"; SCRIPTS="${SCRIPTS/#\~/$HOME}" ;;
+    STAGE=*)      STAGE="${line#STAGE=}"; STAGE="${STAGE/#\~/$HOME}"; mkdir -p "$STAGE" ;;
+    EXCLUDE=*)    EXCLUDES="$EXCLUDES $(printf '%s' "${line#EXCLUDE=}" | tr ',' ' ')" ;;
+    \[*\])
+      cat=$(trim "${line#[}"); cat=$(trim "${cat%]}"); cslug=$(slug "$cat")
+      printf '%s|%s\n' "$cslug" "$cat" >> "$STAGE/.categories.new"
+      mkdir -p "$STAGE/$cslug" ;;
+    *)
+      [ -n "$cslug" ] || die "repo line before any [Category]: $line"
+      private=0; [ "${line:0:1}" = "!" ] && { private=1; line="${line#!}"; }
+      IFS='|' read -r path desc tags <<< "$line"
+      path=$(trim "$path"); desc=$(trim "${desc:-}"); tags=$(trim "${tags:-}")
+      tags=$(printf '%s' "$tags" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | tr '[:upper:]' '[:lower:]' | paste -sd, - || true)
+      path="${path/#\~/$HOME}"
+      name=$(basename "$path")
+      if [ ! -d "$path" ]; then warn "missing: $path"; continue; fi
+      if [ "$LIST" = 1 ]; then printf '%-22s %-45s %-40s %s%s\n' "[$cat]" "$path" "$desc" "$tags" "$([ $private = 1 ] && echo '  [private]')"; continue; fi
+      root="$STAGE"; [ "$private" = 1 ] && { root="$STAGE/.private"; mkdir -p "$root/$cslug"; }
+      if [ -d "$path/.git" ]; then dest="$root/$cslug/$name.git"; kind="git"
+      else dest="$root/$cslug/$name"; kind="files"; fi
+      log "$cat / $name ($kind$([ $private = 1 ] && echo ', private'))"
+      # git: scan tracked files + history; files: stage first, then scan exactly what would be published
+      if [ "$kind" = git ]; then scan_target="$path"; else [ "$DRYRUN" = 1 ] || sync_plain "$path" "$dest" "$desc" "$tags"; scan_target="$dest"; fi
+      if [ "$private" = 0 ] && [ -d "$scan_target" ] && ! scan_secrets "$scan_target" "$kind"; then
+        if [ "$FORCE" = 1 ]; then warn "possible secrets in $name, publishing anyway (-f)"
+        else warn "possible secrets in $name, SKIPPED (fix it or use -f)"; BLOCKED=1; rm -rf "$dest"; continue; fi
+      fi
+      KEEP+=("$dest")
+      [ "$DRYRUN" = 1 ] && continue
+      [ "$kind" = git ] && sync_git "$path" "$dest" "$desc" "$tags"
+      ;;
+  esac
+done < "$CONF"
+
+# scripts collection (SCRIPTS=dir): subdirs = groups, published as files under /scripts
+if [ -n "$SCRIPTS" ] && [ -d "$SCRIPTS" ]; then
+  log "scripts ($SCRIPTS)"
+  if [ "$DRYRUN" = 0 ]; then
+    sync_plain "$SCRIPTS" "$STAGE/.scripts" "Scripts" ""
+    if ! scan_secrets "$STAGE/.scripts" files; then
+      if [ "$FORCE" = 1 ]; then warn "possible secrets in scripts, publishing anyway (-f)"
+      else warn "possible secrets in scripts, SKIPPED (fix it or use -f)"; BLOCKED=1; rm -rf "$STAGE/.scripts"; fi
+    fi
+  fi
+elif [ -z "$SCRIPTS" ]; then rm -rf "$STAGE/.scripts"; fi
+
+[ "$LIST" = 1 ] && { rm -f "$STAGE/.categories.new"; exit 0; }
+
+if [ "$DRYRUN" = 0 ]; then
+  mv "$STAGE/.categories.new" "$STAGE/.categories"
+  date +%s > "$STAGE/.synced"
+  while IFS= read -r d; do
+    keep=0; for k in "${KEEP[@]:-}"; do [ "$k" = "$d" ] && keep=1; done
+    [ "$keep" = 0 ] && { log "prune $(basename "$(dirname "$d")")/$(basename "$d")"; rm -rf "$d"; }
+  done < <(find "$STAGE" -mindepth 2 -maxdepth 2 -type d -not -path "$STAGE/.scripts/*" -not -path "$STAGE/.private/*"; [ -d "$STAGE/.private" ] && find "$STAGE/.private" -mindepth 2 -maxdepth 2 -type d)
+  while IFS= read -r d; do
+    grep -q "^$(basename "$d")|" "$STAGE/.categories" || rm -rf "$d"
+  done < <(find "$STAGE" -mindepth 1 -maxdepth 1 -type d -not -name .scripts -not -name .private)
+else
+  rm -f "$STAGE/.categories.new"
+fi
+
+[ "$BLOCKED" = 1 ] && warn "some repos were skipped because of the secret scan"
+[ "$LOCAL" = 1 ] && { log "staged in $STAGE"; exit 0; }
+[ -n "$HOST" ] && [ -n "$REMOTE_DIR" ] || die "HOST and REMOTE_DIR must be set in config"
+
+log "upload to $HOST:$REMOTE_DIR/repos/"
+extra=""; [ "$DRYRUN" = 1 ] && extra="-n"; [ "$VERBOSE" = 1 ] && extra="$extra -v"
+SSH="ssh -o BatchMode=yes"; [ -n "$SSH_KEY" ] && SSH="$SSH -i $SSH_KEY"
+rsync -az --delete --chmod=D755,F644 -e "$SSH" $extra "$STAGE/" "$HOST:$REMOTE_DIR/repos/"
+log "done"
diff --git a/gitsync.conf b/gitsync.conf
new file mode 100644
index 0000000..a635f8b
--- /dev/null
+++ b/gitsync.conf
@@ -0,0 +1,75 @@
+# gitsync config  (~/.config/gitsync/gitsync.conf)
+# Format:  [Category]   then   /path/to/project | description | tag1, tag2
+#          a leading ! marks a private project (HTTP auth under /private, no secret scan)
+# Only what is listed here gets published. Git projects are pushed as bare
+# repos (cloneable), plain directories are copied as files (.gitignore is
+# honoured, build junk and binaries are skipped). Every project is scanned
+# for keys before upload.
+
+HOST=git-local@162.19.227.194
+REMOTE_DIR=/var/www/html/git.christianimmanuel.de
+# ssh key for the upload user (make ssh-key creates it), lets cron run without a password
+SSH_KEY=~/.ssh/gitsync_ed25519
+# extra excludes for non-git directories (defaults already cover CMakeFiles, *.o, *.swp, ...)
+EXCLUDE=imdb_gz,local,final_output.txt,selected_results.db
+# files bigger than this are not published from non-git dirs (default 50m)
+MAX_SIZE=50m
+# script collection: every subdir is a group, every file a script (### lines at the top = description)
+SCRIPTS=~/Bash-Public
+# staging dir, default ~/.cache/gitsync - move it if your home is small
+#STAGE=~/.cache/gitsync
+
+[Embedded]
+~/Git/snake_compiler                | Snake compiled to a bare-metal RISC-V microcontroller      | riscv, embedded, bare-metal, c, snake
+~/Git/esp_garden                    | Distributed ESP sensor and actuator system (garden)        | esp32, esp-idf, iot, sensors, c++, sql
+~/Git/esp32-wifi-enterprise         | WPA2 enterprise wifi setup on an ESP32                     | esp32, esp-idf, wifi, enterprise, c
+
+[Sway]
+~/Git/SwAS                          | SwAS - app switcher for sway (SVG/PNG icons, themeable)   | sway, wayland, c, launcher
+~/Git/SwBr                          | SwBr - status bar for sway                                 | sway, wayland, c, bar
+~/Git/SWOv                          | SWOv - window overview for sway                            | sway, wayland, c, overview
+~/Git/seatd-n76310                  | seatd launcher for the n76310                              | seatd, wayland, c
+~/Git/seatd-u_vimb                  | seatd launcher for vimb                                    | seatd, wayland, c, vimb
+
+[SDL & Graphics]
+~/Git/sdl3_3d_oop_basic_game        | SDL3 3D OOP basic game (entities, systems, CMake)         | sdl3, 3d, c++, game, cmake
+~/Git/sdl_runtime_compiler          | SDL3 game for running and compiling code at runtime        | sdl3, c++, compiler, dlopen, cmake
+~/Git/mapcreator                    | SDL3 2.5D game and engine using assets, with map editor    | sdl3, c++, game, engine, map-editor, cmake
+~/Git/sdl3_opengl                   | SDL3 + OpenGL function loading                             | sdl3, opengl, c
+~/Git/blender_sdl                   | Render Blender models (sphere) in SDL                      | sdl, blender, 3d, c
+~/Git/draw_trad_img                 | Live viewer for streamed trading chart images (SSL client) | c, images, trading, ssl
+~/CodeBerg/SDL_Game_CPP             | SDL game in C++                                            | sdl, game, c++
+
+[Games]
+~/Git/Entropie                      | Entropie festival game; first steps                        | sdl, c, game, entropie, cmake
+~/Git/strichmaennchen               | Entropie festival game; first steps, second try            | opengl, glsl, c, 3d, game, entropie
+~/Git/2048_bash                     | 2048 in pure bash with highscores                          | game, bash, terminal
+~/Git/vier_gewinnt                  | Vier gewinnt (Connect Four) in the terminal                | game, c, terminal
+~/CodeBerg/Sokoban_in_C             | Sokoban in C                                               | game, c, terminal
+~/Git/ps5_vibration_tester          | PS5 controller vibration tester                            | ps5, controller, c
+
+[Linux & System]
+~/Git/Linux-disable-Middle-Mouse-Click | Disable middle mouse click paste (evdev + sysv script)  | linux, evdev, mouse, c
+~/Git/webcam-loopback-manipulation-screensharing-and-stuff | vcam: v4l2 loopback with overlays, filters, OpenCV and RVM matting | webcam, v4l2, opencv, onnx, c
+
+[Linux From Scratch]
+~/Git/Packagemanager-LFS-PackageUser-System | Package-user based package manager for LFS/BLFS   | lfs, blfs, package-manager, bash
+~/CodeBerg/dependency_collecter_for_lfs_packageuser_system | Dependency collector for the package-user system | lfs, blfs, package-manager, dependencies
+
+[Web & Tools]
+~/Git/Git-Server                    | gitsync + this read-only git browser                       | git, php, bash, apache, self-hosted
+~/Git/Browser                       | Minimal browser in C (mini and big variant)                | browser, c
+~/Git/dienstplancreator             | Dienstplan (roster) creator, PHP/JS web app                | php, javascript, roster, web
+~/Git/dvd                           | DVD collection database with IMDB lookup                   | c, imdb, movies, cmake
+~/Git/typing_10_fingers             | 10 finger typing trainer (CMake)                           | typing, terminal, c, cmake
+
+[Private]
+# ! = only reachable via /private with the password from .htpasswd; not scanned for secrets
+!~/LFS/lfs-config                   | My LFS system configuration                                | lfs, config, dotfiles
+
+[Mirrors & Forks]
+~/Git/jhalfs                        | jhalfs LFS build automation (mirror)                       | lfs, mirror, bash
+~/Git/syntastic                     | syntastic vim plugin (fork)                                | vim, fork
+~/Git/riscv.vim                     | RISC-V assembly syntax for vim                             | vim, riscv, assembly
+
+# not listed on purpose: binance, binance_idk (API keys), back.*/old.* backups, pointer.c
diff --git a/web/index.php b/web/index.php
new file mode 100644
index 0000000..2528418
--- /dev/null
+++ b/web/index.php
@@ -0,0 +1,569 @@
+<?php
+declare(strict_types=1);
+/* git.christianimmanuel.de - read-only git browser. 100% Vibecode but tested. Works in lynx. */
+const VERSION = '1.9.2';
+const SITE = 'Nimbin[12]?';
+const LEGAL = 'https://christianimmanuel.de';
+$ROOT   = __DIR__ . '/repos';
+$PREFIX = '';                                   /* '/private' when browsing the protected area */
+$HOST   = $_SERVER['HTTP_HOST'] ?? 'git.christianimmanuel.de';
+$SCHEME = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+$BASE   = "$SCHEME://$HOST";
+$LANG   = ['c'=>'c','h'=>'c','cpp'=>'cpp','cc'=>'cpp','hpp'=>'cpp','py'=>'python','sh'=>'bash','bash'=>'bash',
+           'php'=>'php','js'=>'javascript','ts'=>'typescript','html'=>'xml','xml'=>'xml','css'=>'css','json'=>'json',
+           'yml'=>'yaml','yaml'=>'yaml','rs'=>'rust','go'=>'go','java'=>'java','vim'=>'vim','mk'=>'makefile',
+           'makefile'=>'makefile','ini'=>'ini','conf'=>'ini','toml'=>'ini','sql'=>'sql','lua'=>'lua','glsl'=>'glsl',
+           'vert'=>'glsl','frag'=>'glsl','s'=>'x86asm','asm'=>'x86asm','diff'=>'diff','patch'=>'diff'];
+$MIME   = ['png'=>'image/png','jpg'=>'image/jpeg','jpeg'=>'image/jpeg','gif'=>'image/gif','svg'=>'image/svg+xml',
+           'webp'=>'image/webp','pdf'=>'application/pdf','ico'=>'image/x-icon'];
+$LCOLOR = ['C'=>'#555555','C++'=>'#f34b7d','Python'=>'#3572A5','Shell'=>'#89e051','JavaScript'=>'#f1e05a','TypeScript'=>'#3178c6',
+           'PHP'=>'#4F5D95','HTML'=>'#e34c26','CSS'=>'#663399','Rust'=>'#dea584','Go'=>'#00ADD8','Java'=>'#b07219',
+           'Vim Script'=>'#199f4b','Lua'=>'#000080','GLSL'=>'#5686a5','Assembly'=>'#6E4C13','Markdown'=>'#083fa1',
+           'Makefile'=>'#427819','Ruby'=>'#701516','Perl'=>'#0298c3','C#'=>'#178600','Kotlin'=>'#A97BFF','Swift'=>'#F05138','TeX'=>'#3D6117'];
+
+function h(?string $s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
+function valid(string $s): bool { return $s !== '.' && $s !== '..' && preg_match('/^[A-Za-z0-9._ +-]+$/', $s) === 1; }
+function git(string $dir, string ...$args): string {
+    $cmd = 'git -c safe.directory=* -C ' . escapeshellarg($dir);
+    foreach ($args as $a) $cmd .= ' ' . escapeshellarg($a);
+    return (string)shell_exec($cmd . ' 2>/dev/null');
+}
+function safe_path(array $segs): ?string {
+    foreach ($segs as $s) if (!valid($s)) return null;
+    return implode('/', $segs);
+}
+function ago(int $t): string {
+    if ($t <= 0) return '';
+    $d = time() - $t;
+    foreach ([31536000=>'year', 2592000=>'month', 86400=>'day', 3600=>'hour', 60=>'minute'] as $s => $n)
+        if ($d >= $s) { $v = intdiv($d, $s); return "$v $n" . ($v > 1 ? 's' : '') . ' ago'; }
+    return 'just now';
+}
+function ext(string $name): string {
+    $b = strtolower(basename($name));
+    return $b === 'makefile' ? 'makefile' : strtolower(pathinfo($b, PATHINFO_EXTENSION));
+}
+function fmt_size(int $b): string {
+    if ($b < 1024) return "$b B";
+    if ($b < 1048576) return round($b / 1024, 1) . ' KB';
+    return round($b / 1048576, 1) . ' MB';
+}
+function lcolor(string $l): string { global $LCOLOR; return $LCOLOR[$l] ?? '#' . substr(md5($l), 0, 6); }
+
+/* ---------- data ---------- */
+function categories(): array {
+    global $ROOT; $cats = [];
+    $f = "$ROOT/.categories";
+    if (is_file($f)) foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $l) {
+        [$slug, $title] = array_pad(explode('|', $l, 2), 2, '');
+        if (valid($slug) && is_dir("$ROOT/$slug")) $cats[$slug] = $title !== '' ? $title : $slug;
+    }
+    if (is_dir($ROOT)) foreach (scandir($ROOT) as $d)
+        if ($d[0] !== '.' && is_dir("$ROOT/$d") && !isset($cats[$d])) $cats[$d] = $d;
+    return $cats;
+}
+function load_repo(string $cat, string $name): ?array {
+    global $ROOT, $BASE, $PREFIX, $SCHEME, $HOST;
+    if (!valid($cat) || !valid($name)) return null;
+    if (is_dir("$ROOT/$cat/$name.git"))   { $dir = "$ROOT/$cat/$name.git"; $isgit = true;  $mf = "$dir/gitsync.meta"; }
+    elseif (is_dir("$ROOT/$cat/$name"))   { $dir = "$ROOT/$cat/$name";     $isgit = false; $mf = "$dir/.gitsync.meta"; }
+    else return null;
+    $m = ['description'=>'', 'tags'=>'', 'origin'=>'', 'created'=>'0', 'modified'=>'0', 'languages'=>'', 'synced'=>'0'];
+    if (is_file($mf)) foreach (file($mf, FILE_IGNORE_NEW_LINES) as $l)
+        if (str_contains($l, '=')) { [$k, $v] = explode('=', $l, 2); $m[$k] = trim($v); }
+    $langs = []; $total = 0;
+    foreach (array_filter(explode(',', $m['languages'])) as $p) { [$l, $b] = array_pad(explode(':', $p), 2, '0'); $langs[$l] = (int)$b; $total += (int)$b; }
+    foreach ($langs as $l => $b) $langs[$l] = $total ? $b / $total * 100 : 0;
+    $url = $PREFIX . '/' . rawurlencode($cat) . '/' . rawurlencode($name);
+    return ['cat'=>$cat, 'name'=>$name, 'dir'=>$dir, 'git'=>$isgit, 'desc'=>$m['description'], 'url'=>$url,
+            'tags'=>array_values(array_filter(explode(',', $m['tags']))), 'origin'=>$m['origin'],
+            'created'=>(int)$m['created'], 'modified'=>(int)$m['modified'] ?: ($isgit ? 0 : (int)filemtime($dir)), 'synced'=>(int)$m['synced'],
+            'langs'=>$langs, 'private'=>$PREFIX !== '',
+            'clone'=>($PREFIX ? "$SCHEME://USER@$HOST" : $BASE) . "$url.git", 'archive'=>"$BASE$url/archive/" . rawurlencode($name) . '.tar.gz'];
+}
+function repos_in(string $cat): array {
+    global $ROOT; $out = [];
+    foreach (scandir("$ROOT/$cat") as $d) {
+        if ($d[0] === '.' || !is_dir("$ROOT/$cat/$d")) continue;
+        $r = load_repo($cat, preg_replace('/\.git$/', '', $d));
+        if ($r) $out[$r['name']] = $r;
+    }
+    ksort($out, SORT_NATURAL | SORT_FLAG_CASE);
+    return $out;
+}
+function parse_commit(string $l): ?array {
+    if ($l === '') return null;
+    [$H, $h, $an, $at, $s] = array_pad(explode("\x1f", $l), 5, '');
+    return ['H'=>$H, 'h'=>$h, 'author'=>$an, 'time'=>(int)$at, 'subject'=>$s];
+}
+const LOGFMT = '--format=%H%x1f%h%x1f%an%x1f%at%x1f%s';
+function last_commit(array $r): ?array { return $r['git'] ? parse_commit(trim(git($r['dir'], 'log', '-1', LOGFMT))) : null; }
+function branch(array $r): string { $b = trim(git($r['dir'], 'symbolic-ref', '--short', 'HEAD')); return $b !== '' ? $b : 'HEAD'; }
+function obj_type(array $r, string $p): string {
+    if ($r['git']) return trim(git($r['dir'], 'cat-file', '-t', 'HEAD:' . $p));
+    $f = "{$r['dir']}/$p";
+    return is_dir($f) ? 'tree' : (is_file($f) ? 'blob' : '');
+}
+function tree(array $r, string $p): array {
+    $items = [];
+    if ($r['git']) {
+        $spec = $p === '' ? 'HEAD' : "HEAD:$p";
+        foreach (explode("\n", trim(git($r['dir'], 'ls-tree', '-l', $spec))) as $l) {
+            if ($l === '' || !preg_match('/^(\d+) (\w+) (\w+) +(-|\d+)\t(.+)$/', $l, $m)) continue;
+            $items[] = ['name'=>$m[5], 'dir'=>$m[2] === 'tree', 'size'=>$m[4] === '-' ? 0 : (int)$m[4]];
+        }
+    } else {
+        $base = rtrim("{$r['dir']}/$p", '/');
+        foreach (scandir($base) as $f) {
+            if ($f === '.' || $f === '..' || $f === '.gitsync.meta') continue;
+            $items[] = ['name'=>$f, 'dir'=>is_dir("$base/$f"), 'size'=>is_file("$base/$f") ? (int)filesize("$base/$f") : 0];
+        }
+    }
+    usort($items, fn($a, $b) => [$b['dir'], strtolower($a['name'])] <=> [$a['dir'], strtolower($b['name'])]);
+    return $items;
+}
+function blob(array $r, string $p): string {
+    return $r['git'] ? git($r['dir'], 'cat-file', 'blob', "HEAD:$p") : (string)file_get_contents("{$r['dir']}/$p");
+}
+function find_readme(array $r): ?string {
+    foreach (tree($r, '') as $i) if (!$i['dir'] && preg_match('/^readme(\.(md|markdown|txt))?$/i', $i['name'])) return $i['name'];
+    return null;
+}
+function activity_days(array $r): array {           /* day => commits, last 53 weeks */
+    $days = [];
+    foreach (explode("\n", trim(git($r['dir'], 'log', '--all', '--since=53 weeks ago', '--format=%at'))) as $t)
+        if ($t !== '') { $k = date('Y-m-d', (int)$t); $days[$k] = ($days[$k] ?? 0) + 1; }
+    return $days;
+}
+
+/* ---------- markdown (server side, keeps lynx happy) ---------- */
+function md_inline(string $s, string $rawbase): string {
+    $s = h($s);
+    $s = preg_replace_callback('/`([^`]+)`/', fn($m) => '<code>' . $m[1] . '</code>', $s);
+    $fix = fn($u) => preg_match('#^([a-z]+:|/|\#)#i', $u) ? $u : $rawbase . ltrim($u, './');
+    $s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/', fn($m) => '<img src="' . h($fix($m[2])) . '" alt="' . $m[1] . '">', $s);
+    $s = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/', fn($m) => '<a href="' . h($fix($m[2])) . '">' . $m[1] . '</a>', $s);
+    $s = preg_replace('#(?<![">])\bhttps?://[^\s<]+#', '<a href="$0">$0</a>', $s);
+    $s = preg_replace('/(\*\*|__)(.+?)\1/', '<b>$2</b>', $s);
+    $s = preg_replace('/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/', '<i>$2</i>', $s);
+    $s = preg_replace('/~~(.+?)~~/', '<s>$1</s>', $s);
+    return $s;
+}
+function md(string $src, string $rawbase): string {
+    $lines = explode("\n", str_replace("\r", '', $src)); $n = count($lines); $out = ''; $i = 0;
+    $list = ''; $para = [];
+    $close_list = function () use (&$list, &$out) { if ($list) { $out .= "</$list>"; $list = ''; } };
+    $flush = function () use (&$para, &$out, $rawbase) { if ($para) { $out .= '<p>' . md_inline(implode("\n", $para), $rawbase) . '</p>'; $para = []; } };
+    while ($i < $n) {
+        $l = $lines[$i];
+        if (preg_match('/^\s*(```|~~~)\s*(\w*)/', $l, $m)) {
+            $flush(); $close_list(); $buf = []; $i++;
+            while ($i < $n && !preg_match('/^\s*' . preg_quote($m[1]) . '/', $lines[$i])) $buf[] = $lines[$i++];
+            $i++; $out .= '<pre><code' . ($m[2] ? ' class="language-' . h($m[2]) . '"' : '') . '>' . h(implode("\n", $buf)) . "</code></pre>"; continue;
+        }
+        if (preg_match('/^(#{1,6})\s+(.*?)\s*#*$/', $l, $m)) { $flush(); $close_list(); $k = strlen($m[1]); $out .= "<h$k>" . md_inline($m[2], $rawbase) . "</h$k>"; }
+        elseif (preg_match('/^\s*[-*+]\s+(.*)/', $l, $m)) { $flush(); if ($list !== 'ul') { $close_list(); $out .= '<ul>'; $list = 'ul'; } $out .= '<li>' . md_inline($m[1], $rawbase) . '</li>'; }
+        elseif (preg_match('/^\s*\d+[.)]\s+(.*)/', $l, $m)) { $flush(); if ($list !== 'ol') { $close_list(); $out .= '<ol>'; $list = 'ol'; } $out .= '<li>' . md_inline($m[1], $rawbase) . '</li>'; }
+        elseif (preg_match('/^>\s?(.*)/', $l, $m)) { $flush(); $close_list(); $out .= '<blockquote>' . md_inline($m[1], $rawbase) . '</blockquote>'; }
+        elseif (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $l)) { $flush(); $close_list(); $out .= '<hr>'; }
+        elseif (str_starts_with(trim($l), '|')) {
+            $flush(); $close_list(); $rows = [];
+            while ($i < $n && str_starts_with(trim($lines[$i]), '|')) { $rows[] = array_map('trim', explode('|', trim(trim($lines[$i]), '|'))); $i++; }
+            $out .= '<table class="md">';
+            foreach ($rows as $ri => $cells) {
+                if ($ri === 1 && preg_match('/^:?-+:?$/', $cells[0] ?? 'x')) continue;
+                $tag = $ri === 0 ? 'th' : 'td';
+                $out .= '<tr>' . implode('', array_map(fn($c) => "<$tag>" . md_inline($c, $rawbase) . "</$tag>", $cells)) . '</tr>';
+            }
+            $out .= '</table>'; continue;
+        }
+        elseif (preg_match('/^(    |\t)(.*)/', $l, $m) && !$para && !$list) {
+            $buf = []; while ($i < $n && preg_match('/^(    |\t)(.*)/', $lines[$i], $m2)) { $buf[] = $m2[2]; $i++; }
+            $out .= '<pre><code>' . h(implode("\n", $buf)) . '</code></pre>'; continue;
+        }
+        elseif (trim($l) === '') { $flush(); $close_list(); }
+        else $para[] = $l;
+        $i++;
+    }
+    $flush(); $close_list();
+    return $out;
+}
+
+/* ---------- html ---------- */
+function page_start(string $title, array $crumbs = []): void {
+    global $HOST;
+    echo "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">";
+    echo '<title>' . h($title) . ' · ' . h(SITE) . '</title><link rel="stylesheet" href="/style.css">';
+    echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">';
+    echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)">';
+    $path0 = (string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
+    $onscripts = str_starts_with($path0, '/scripts'); $onhome = $path0 === '/' || str_starts_with($path0, '/search'); $onpriv = str_starts_with($path0, '/private');
+    echo '</head><body><header><a class="brand" href="/">' . h(SITE) . '</a>';
+    echo '<nav class="topnav"><a class="' . ($onscripts || $onhome || $onpriv ? '' : 'on') . '" href="/projects">projects</a>';
+    if (is_dir($GLOBALS['ROOT'] . '/.scripts')) echo '<a class="' . ($onscripts ? 'on' : '') . '" href="/scripts">scripts</a>';
+    if (is_dir(__DIR__ . '/repos/.private')) echo '<a class="' . ($onpriv ? 'on' : '') . '" href="/private">private</a>';
+    echo '</nav>';
+    if ($crumbs) echo '<span class="crumbs">';
+    $i = 0; foreach ($crumbs as $text => $href) echo ($i++ ? ' <span class="sep">/</span> ' : '') . ($href ? '<a href="' . h($href) . '">' . h((string)$text) . '</a>' : '<b>' . h((string)$text) . '</b>');
+    if ($crumbs) echo '</span>';
+    echo '<form class="search" action="/search" method="get"><input type="text" name="q" placeholder="search projects and scripts" value="' . h($_GET['q'] ?? '') . '"><input type="submit" value="search"></form>';
+    echo '</header><main>';
+}
+function page_end(): void {
+    global $ROOT; $sy = is_file("$ROOT/.synced") ? (int)file_get_contents("$ROOT/.synced") : 0;
+    echo '</main><footer>' . ($sy ? 'last synced ' . date('Y-m-d H:i', $sy) . ' (' . ago($sy) . ') · ' : '') . '<a href="' . LEGAL . '">Impressum</a> · <a href="' . LEGAL . '">Datenschutz</a> · <a href="' . LEGAL . '">christianimmanuel.de</a><br>read-only git browser · 100% Vibecode but tested · v' . VERSION . '</footer>';
+    echo '<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script><script>
+document.querySelectorAll("pre code").forEach(e=>hljs.highlightElement(e));
+document.querySelectorAll(".cmd").forEach(c=>{const b=document.createElement("button");b.className="copy";b.textContent="copy";b.onclick=()=>{navigator.clipboard.writeText(c.querySelector("code").textContent);b.textContent="copied";setTimeout(()=>b.textContent="copy",1500);};c.appendChild(b);});
+const tt=document.createElement("a");tt.href="#";tt.className="totop";tt.textContent="↑";tt.title="back to top";tt.onclick=e=>{e.preventDefault();window.scrollTo({top:0,behavior:"smooth"});};document.body.appendChild(tt);
+addEventListener("scroll",()=>tt.classList.toggle("show",scrollY>400),{passive:true});
+document.querySelectorAll(".get").forEach(g=>{const cmds=[...g.querySelectorAll(".cmd")];if(cmds.length<2)return;const sw=document.createElement("div");sw.className="getopts";cmds.forEach((c,i)=>{const b=document.createElement("button");b.textContent=c.dataset.label;b.className=i?"":"on";b.onclick=()=>{cmds.forEach((x,j)=>{x.style.display=j===i?"":"none";sw.children[j].className=j===i?"on":"";});};sw.appendChild(b);if(i)c.style.display="none";});g.prepend(sw);});
+</script></body></html>';
+}
+function not_found(): void { http_response_code(404); page_start('404'); echo '<h1>404</h1><p>Not found.</p>'; page_end(); exit; }
+function hue(string $t): int { return hexdec(substr(md5($t), 0, 2)) * 360 >> 8; }
+function tag_links(array $tags, string $cls = 'tag'): string {
+    return implode(' ', array_map(fn($t) => '<a class="' . $cls . '" style="--h:' . hue($t) . '" href="/projects?tag=' . rawurlencode($t) . '">' . h($t) . '</a>', $tags));
+}
+function lang_bar(array $langs, bool $text): string {
+    if (!$langs) return '';
+    $bar = '<span class="langbar">';
+    foreach ($langs as $l => $p) if ($p >= 0.5) $bar .= '<span style="width:' . round($p, 1) . '%;background:' . lcolor($l) . '" title="' . h($l) . '"></span>';
+    $bar .= '</span>';
+    if ($text) { $parts = []; foreach ($langs as $l => $p) if ($p >= 1) $parts[] = '<span class="lang"><span class="dot" style="background:' . lcolor($l) . '"></span>' . h($l) . ' ' . round($p, 1) . '%</span>';
+                 $bar .= '<span class="langs">' . implode(' ', $parts) . '</span>'; }
+    return $bar;
+}
+function cmd_box(string $cmd, string $label): string {
+    return '<span class="cmd" data-label="' . h($label) . '"><code>' . h($cmd) . '</code></span>';
+}
+function activity_svg(array $days, string $label): string {
+    $total = array_sum($days);
+    $start = strtotime('monday this week') - 52 * 7 * 86400;
+    $max = max(1, ...array_values($days ?: [0]));
+    $svg = '<svg class="activity" viewBox="0 0 ' . (53 * 13 + 30) . ' 110" role="img" aria-label="' . h($label) . '">';
+    for ($w = 0; $w < 53; $w++) for ($d = 0; $d < 7; $d++) {
+        $t = $start + ($w * 7 + $d) * 86400;
+        if ($t > time()) continue;
+        $k = date('Y-m-d', $t); $c = $days[$k] ?? 0;
+        $lvl = $c === 0 ? 0 : min(4, (int)ceil($c / $max * 4));
+        $svg .= '<rect x="' . ($w * 13 + 28) . '" y="' . ($d * 13 + 14) . '" width="11" height="11" rx="2" class="l' . $lvl . '"><title>' . $k . ': ' . $c . ' commits</title></rect>';
+        if ($d === 0 && date('j', $t) <= 7) $svg .= '<text x="' . ($w * 13 + 28) . '" y="10">' . date('M', $t) . '</text>';
+    }
+    foreach ([1=>'Mon', 3=>'Wed', 5=>'Fri'] as $d => $n) $svg .= '<text x="0" y="' . ($d * 13 + 23) . '">' . $n . '</text>';
+    return '<div class="activitybox"><p class="meta">' . h($label) . ': <b>' . $total . '</b> commits in the last year</p>' . $svg . '</svg></div>';
+}
+function repo_header(array $r, string $active): void {
+    echo '<div class="repohead"><h1><a href="' . h($r['url']) . '">' . h($r['name']) . '</a> <span class="badge">' . ($r['git'] ? 'git · ' . h(branch($r)) : 'files') . '</span></h1>';
+    if ($r['desc'] !== '') echo '<p class="desc">' . h($r['desc']) . '</p>';
+    echo '<p class="meta">';
+    if ($r['tags']) echo tag_links($r['tags']) . ' · ';
+    if ($r['created']) echo ($r['git'] ? 'first commit ' : 'oldest file ') . date('Y-m-d', $r['created']) . ' · ';
+    if ($r['modified']) echo ($r['git'] ? 'last commit ' : 'last change ') . date('Y-m-d', $r['modified']) . ' (' . ago($r['modified']) . ')';
+    if ($r['synced']) echo ' · synced ' . ago($r['synced']);
+    if ($r['origin'] !== '') echo ' · upstream: <a href="' . h($r['origin']) . '">' . h(preg_replace('#^https?://#', '', $r['origin'])) . '</a>';
+    echo '</p>' . lang_bar($r['langs'], true);
+    echo '<nav class="tabs">';
+    foreach (['files'=>$r['url'], 'commits'=>$r['url'] . '/commits'] as $t => $u) {
+        if ($t === 'commits' && !$r['git']) continue;
+        echo '<a class="' . ($t === $active ? 'on' : '') . '" href="' . h($u) . '">' . $t . '</a>';
+    }
+    echo '</nav><div class="get">';
+    if ($r['git']) echo cmd_box('git clone ' . $r['clone'], 'git clone');
+    echo cmd_box('wget ' . ($r['private'] ? '--user=USER --ask-password ' : '') . $r['archive'], 'wget tar.gz');
+    echo '</div></div>';
+}
+function tree_link(array $r, string $p, string $kind): string {
+    return $r['url'] . "/$kind/" . implode('/', array_map('rawurlencode', $p === '' ? [] : explode('/', $p)));
+}
+function render_tree(array $r, string $p): void {
+    $items = tree($r, $p);
+    echo '<table class="tree">';
+    if ($p !== '') echo '<tr><td class="ico">..</td><td><a href="' . h(dirname($p) === '.' ? $r['url'] : tree_link($r, dirname($p), 'tree')) . '">parent directory</a></td><td></td></tr>';
+    foreach ($items as $i) {
+        $sub = ($p === '' ? '' : "$p/") . $i['name'];
+        $href = tree_link($r, $sub, $i['dir'] ? 'tree' : 'blob');
+        echo '<tr><td class="ico">' . ($i['dir'] ? 'd' : '-') . '</td><td><a href="' . h($href) . '">' . h($i['name']) . ($i['dir'] ? '/' : '') . '</a></td><td class="size">' . ($i['dir'] ? '' : fmt_size($i['size'])) . '</td></tr>';
+    }
+    if (!$items) echo '<tr><td colspan="3"><i>empty</i></td></tr>';
+    echo '</table>';
+}
+function render_readme(array $r): void {
+    $f = find_readme($r);
+    if ($f === null) return;
+    $c = blob($r, $f);
+    echo '<section class="readme"><h3>' . h($f) . '</h3>';
+    if (preg_match('/\.(md|markdown)$/i', $f)) echo '<div class="markdown">' . md($c, rtrim(tree_link($r, '', 'raw'), '/') . '/') . '</div>';
+    else echo '<pre>' . h($c) . '</pre>';
+    echo '</section>';
+}
+function commit_row(array $r, array $c): void {
+    echo '<tr><td><a class="hash" href="' . h($r['url'] . '/commit/' . $c['H']) . '">' . h($c['h']) . '</a></td><td>' . h($c['subject']) . '</td><td class="meta">' . h($c['author']) . ' · ' . ago($c['time']) . '</td></tr>';
+}
+function card(array $r): void {
+    echo '<div class="card"><div class="top"><a class="name" href="' . h($r['url']) . '">' . h($r['name']) . '</a><span class="badge">' . ($r['git'] ? 'git' : 'files') . '</span></div>';
+    if ($r['desc'] !== '') echo '<p class="desc">' . h($r['desc']) . '</p>';
+    if ($r['tags']) echo '<p class="tags">' . tag_links($r['tags'], 'tagt') . '</p>';
+    echo lang_bar($r['langs'], false);
+    echo '<p class="meta">' . ($r['modified'] ? ($r['git'] ? 'last commit ' : 'last change ') . ago($r['modified']) : '');
+    $top = array_key_first($r['langs']); if ($top) echo ' · ' . h($top);
+    echo '</p></div>';
+}
+
+/* ---------- scripts collection ---------- */
+function script_hint(string $file): string {
+    $out = ''; $in = false;
+    foreach (file($file, FILE_IGNORE_NEW_LINES) as $l) {
+        if (str_starts_with($l, '###') && !str_starts_with($l, '######')) { $out .= substr($l, 4) . "\n"; $in = true; }
+        elseif ($in) break;
+    }
+    return trim($out);
+}
+function script_interp(string $file): string {
+    $l = (string)fgets(fopen($file, 'r'));
+    if (!str_starts_with($l, '#!')) return 'text';
+    $i = basename(trim(explode(' ', trim(substr($l, 2)))[0]));
+    if ($i === 'env') { $p = preg_split('/\s+/', trim(substr($l, 2))); $i = basename($p[1] ?? 'sh'); }
+    return preg_replace('/[0-9.]+$/', '', $i) ?: $i;
+}
+function script_groups(): array {
+    global $ROOT; $g = [];
+    $base = "$ROOT/.scripts";
+    if (!is_dir($base)) return $g;
+    foreach (scandir($base) as $d) {
+        if ($d[0] === '.' || !is_dir("$base/$d") || !valid($d)) continue;
+        $files = [];
+        foreach (scandir("$base/$d") as $f) if ($f[0] !== '.' && is_file("$base/$d/$f") && valid($f)) $files[] = $f;
+        if ($files) $g[$d] = $files;
+    }
+    return $g;
+}
+function script_url(string $g, string $f, string $kind = ''): string {
+    return '/scripts/' . ($kind ? "$kind/" : '') . rawurlencode($g) . '/' . rawurlencode($f);
+}
+function scripts_page(array $seg): void {
+    global $ROOT, $BASE, $LANG;
+    $base = "$ROOT/.scripts";
+    if (!is_dir($base)) not_found();
+    $groups = script_groups();
+    if (isset($seg[1]) && $seg[1] === 'raw' && isset($seg[2], $seg[3]) && valid($seg[2]) && valid($seg[3]) && is_file("$base/$seg[2]/$seg[3]")) {
+        header('Content-Type: text/plain; charset=utf-8'); header('X-Content-Type-Options: nosniff');
+        readfile("$base/$seg[2]/$seg[3]"); exit;
+    }
+    if (isset($seg[1], $seg[2]) && valid($seg[1]) && valid($seg[2]) && is_file("$base/$seg[1]/$seg[2]")) {   /* one script */
+        [$g, $f] = [$seg[1], $seg[2]]; $c = (string)file_get_contents("$base/$g/$f");
+        page_start($f, [$g => '/scripts#' . rawurlencode($g), $f => '']);
+        echo '<div class="repohead"><h1>' . h($f) . ' <span class="badge">' . h($g) . '</span></h1>';
+        $hint = script_hint("$base/$g/$f"); if ($hint) echo '<p class="desc hint">' . nl2br(h($hint)) . '</p>';
+        echo '<div class="get">' . cmd_box("wget $BASE" . script_url($g, $f, 'raw') . " && chmod 740 " . $f, 'wget') . '</div></div>';
+        echo '<div class="filehead"><b>' . h($f) . '</b> <span class="meta">' . fmt_size(strlen($c)) . ' · ' . substr_count($c, "\n") . ' lines</span> <a class="btn" href="' . h(script_url($g, $f, 'raw')) . '">raw</a></div>';
+        echo '<pre><code class="language-bash">' . h($c) . '</code></pre>';
+        page_end(); exit;
+    }
+    if (isset($seg[1])) not_found();
+    page_start('Scripts');
+    echo '<h1>Scripts</h1><p class="meta">Standalone shell scripts. Every entry shows its header comment; grab one with the wget line or view the source.</p>';
+    echo '<p class="catnav">' . implode(' ', array_map(fn($g) => '<a href="#' . h($g) . '">' . h($g) . ' <small>' . count($groups[$g]) . '</small></a>', array_keys($groups))) . '</p>';
+    foreach ($groups as $g => $files) {
+        echo '<h2 id="' . h($g) . '">' . h($g) . ' <small>' . count($files) . '</small></h2><div class="cards">';
+        foreach ($files as $f) {
+            $hint = script_hint("$base/$g/$f"); $short = trim(explode("\n", $hint)[0]);
+            $c = (string)file_get_contents("$base/$g/$f");
+            echo '<div class="card"><div class="top"><a class="name" href="' . h(script_url($g, $f)) . '">' . h($f) . '</a><span class="badge">' . h(script_interp("$base/$g/$f")) . '</span></div>';
+            if ($short !== '') echo '<p class="desc">' . h($short) . '</p>';
+            echo '<p class="meta">' . substr_count($c, "\n") . ' lines · ' . fmt_size(strlen($c)) . ' · ' . date('Y-m-d', (int)filemtime("$base/$g/$f")) . '</p></div>';
+        }
+        echo '</div>';
+    }
+    page_end(); exit;
+}
+
+/* ---------- routing ---------- */
+$path = rawurldecode((string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH));
+$seg  = array_values(array_filter(explode('/', $path), fn($s) => $s !== ''));
+if ($seg && $seg[0] === 'private') {           /* protected area */
+    /* Apache should already have asked for the password (<Location /private>); this is the safety net
+       in case the vhost is old: verify HTTP Basic auth against .htpasswd (bcrypt entries, htpasswd -B). */
+    $ok = false; $u = $_SERVER['PHP_AUTH_USER'] ?? ''; $pw = $_SERVER['PHP_AUTH_PW'] ?? '';
+    if (!empty($_SERVER['REMOTE_USER'])) $ok = true;                       /* Apache did the auth */
+    elseif ($u !== '' && is_file(__DIR__ . '/.htpasswd'))
+        foreach (file(__DIR__ . '/.htpasswd', FILE_IGNORE_NEW_LINES) as $l) { [$n, $h] = array_pad(explode(':', $l, 2), 2, ''); if ($n === $u && $h !== '' && password_verify($pw, $h)) { $ok = true; break; } }
+    if (!$ok) { header('WWW-Authenticate: Basic realm="private"'); http_response_code(401); echo '401 - private area, login required'; exit; }
+    $ROOT .= '/.private'; $PREFIX = '/private'; array_shift($seg);
+    if (!is_dir($ROOT)) not_found();
+    if (!$seg) {
+        page_start('Private', ['private' => '']);
+        echo '<h1>Private</h1><p class="meta">Only for logged-in users. Clone with <code>git clone https://USER@' . h($HOST) . '/private/…</code>, download with <code>wget --user=USER --ask-password …</code>.</p>';
+        $n = 0;
+        foreach (categories() as $slug => $title) { $rs = repos_in($slug); if (!$rs) continue;
+            echo '<h2 id="' . h($slug) . '">' . h($title) . ' <small>' . count($rs) . '</small></h2><div class="cards">'; foreach ($rs as $r) { $n++; card($r); } echo '</div>'; }
+        if (!$n) echo '<p>Nothing here.</p>';
+        page_end(); exit;
+    }
+    if (count($seg) === 1) not_found();
+}
+$cats = categories();
+
+
+function latest_commits(array $all, int $n = 12): array {
+    $out = [];
+    foreach ($all as $rs) foreach ($rs as $r) if ($r['git'])
+        foreach (explode("\n", trim(git($r['dir'], 'log', '-5', LOGFMT))) as $l) if ($c = parse_commit($l)) { $c['repo'] = $r; $out[] = $c; }
+    usort($out, fn($a, $b) => $b['time'] <=> $a['time']);
+    return array_slice($out, 0, $n);
+}
+if (!$seg) {                                  /* landing page */
+    $all = []; foreach ($cats as $slug => $title) $all[$slug] = repos_in($slug);
+    $repos = array_merge(...array_values($all ?: [[]]));
+    $groups = script_groups(); $nscripts = array_sum(array_map('count', $groups));
+    page_start(SITE);
+    echo '<h1>' . h(SITE) . '</h1>';
+    echo '<div class="cards intro"><div class="card"><div class="top"><a class="name" href="/projects">Projects</a><span class="badge">' . count($repos) . '</span></div><p class="desc">Git repositories and plain file dumps, sorted by category, with tags, languages and commit history.</p><p class="meta">' . implode(' · ', array_map(fn($t) => h($t), $cats)) . '</p></div>';
+    if ($nscripts) echo '<div class="card"><div class="top"><a class="name" href="/scripts">Scripts</a><span class="badge">' . $nscripts . '</span></div><p class="desc">Standalone shell scripts with a wget line each.</p><p class="meta">' . implode(' · ', array_map(fn($g) => h($g), array_keys($groups))) . '</p></div></div>';
+    else echo '</div>';
+    $commits = latest_commits($all);
+    if ($commits) {
+        echo '<h2>Latest commits</h2><table class="commits feed">';
+        foreach ($commits as $c) echo '<tr><td><a class="hash" href="' . h($c['repo']['url'] . '/commit/' . $c['H']) . '">' . h($c['h']) . '</a></td><td><a class="repo" href="' . h($c['repo']['url']) . '">' . h($c['repo']['name']) . '</a> ' . h($c['subject']) . '</td><td class="meta">' . ago($c['time']) . '</td></tr>';
+        echo '</table>';
+    }
+    usort($repos, fn($a, $b) => $b['modified'] <=> $a['modified']);
+    $recent = array_slice(array_filter($repos, fn($r) => $r['modified'] > 0), 0, 6);
+    if ($recent) { echo '<h2>Recently updated</h2><div class="cards recent">'; foreach ($recent as $r) card($r); echo '</div>'; }
+    $days = []; foreach ($repos as $r) if ($r['git']) foreach (activity_days($r) as $k => $c) $days[$k] = ($days[$k] ?? 0) + $c;
+    if ($days) echo activity_svg($days, 'All repositories');
+    page_end(); exit;
+}
+if ($seg[0] === 'search') {                    /* global search */
+    $q = strtolower(trim($_GET['q'] ?? ''));
+    page_start("search: $q");
+    echo '<h1>Search</h1>';
+    if ($q === '') { echo '<p class="meta">Type something into the search box.</p>'; page_end(); exit; }
+    echo '<p class="meta">results for <b>' . h($q) . '</b></p>';
+    $n = 0;
+    foreach ($cats as $slug => $title) {
+        $rs = array_filter(repos_in($slug), fn($r) => str_contains(strtolower($r['name'] . ' ' . $r['desc'] . ' ' . implode(' ', $r['tags']) . ' ' . implode(' ', array_keys($r['langs']))), $q));
+        if (!$rs) continue;
+        echo '<h2>' . h($title) . ' <small>' . count($rs) . '</small></h2><div class="cards">'; foreach ($rs as $r) { $n++; card($r); } echo '</div>';
+    }
+    $base = "$ROOT/.scripts";
+    foreach (script_groups() as $g => $files) {
+        $hits = array_filter($files, fn($f) => str_contains(strtolower($f . ' ' . script_hint("$base/$g/$f")), $q));
+        if (!$hits) continue;
+        echo '<h2>scripts / ' . h($g) . ' <small>' . count($hits) . '</small></h2><div class="cards">';
+        foreach ($hits as $f) { $n++; $hint = script_hint("$base/$g/$f"); $short = trim(explode("\n", $hint)[0]);
+            echo '<div class="card"><div class="top"><a class="name" href="' . h(script_url($g, $f)) . '">' . h($f) . '</a><span class="badge">' . h(script_interp("$base/$g/$f")) . '</span></div>' . ($short !== '' ? '<p class="desc">' . h($short) . '</p>' : '') . '</div>'; }
+        echo '</div>';
+    }
+    if (!$n) echo '<p>Nothing found.</p>';
+    page_end(); exit;
+}
+if ($seg[0] === 'scripts') scripts_page($seg);
+if ($seg && $seg[0] === 'projects' && count($seg) === 1) {   /* projects index */
+    $q = strtolower(trim($_GET['q'] ?? '')); $tag = strtolower(trim($_GET['tag'] ?? ''));
+    $all = []; foreach ($cats as $slug => $title) $all[$slug] = repos_in($slug);
+    $tagcount = []; foreach ($all as $rs) foreach ($rs as $r) foreach ($r['tags'] as $t) $tagcount[$t] = ($tagcount[$t] ?? 0) + 1;
+    arsort($tagcount);
+    page_start($tag ? "tag: $tag" : 'Projects');
+    echo '<h1>Projects</h1>';
+    $nav = []; foreach ($all as $slug => $rs) if ($rs) $nav[] = '<a href="#' . h($slug) . '">' . h($cats[$slug]) . '</a>';
+    if ($nav && !$q && !$tag) echo '<p class="catnav">' . implode(' ', $nav) . '</p>';
+    $alltags = isset($_GET['tags']); $shown = $alltags ? $tagcount : array_slice($tagcount, 0, 18, true);
+    if ($tag && !isset($shown[$tag])) $shown[$tag] = $tagcount[$tag] ?? 0;
+    if ($tagcount) echo '<p class="tagcloud">' . implode(' ', array_map(fn($t, $c) => '<a class="tag' . ($t === $tag ? ' on' : '') . '" style="--h:' . hue($t) . '" href="/projects?tag=' . rawurlencode($t) . '">' . h($t) . ' <small>' . $c . '</small></a>', array_keys($shown), $shown))
+        . (!$alltags && count($tagcount) > count($shown) ? ' <a class="tag more" href="/projects?tags">+' . (count($tagcount) - count($shown)) . ' more</a>' : '') . '</p>';
+    if ($q || $tag) echo '<p class="meta">filter: <b>' . h($q ?: "tag $tag") . '</b> · <a href="/projects">show all</a></p>';
+    $n = 0; $days = [];
+    foreach ($all as $slug => $rs) {
+        $rs = array_filter($rs, function ($r) use ($q, $tag) {
+            if ($tag && !in_array($tag, $r['tags'])) return false;
+            return !$q || str_contains(strtolower($r['name'] . ' ' . $r['desc'] . ' ' . implode(' ', $r['tags']) . ' ' . implode(' ', array_keys($r['langs']))), $q);
+        });
+        if (!$rs) continue;
+        echo '<h2 id="' . h($slug) . '">' . h($cats[$slug]) . ' <small>' . count($rs) . '</small></h2><div class="cards">';
+        foreach ($rs as $r) {
+            $n++; card($r);
+        }
+        echo '</div>';
+    }
+    if (!$n) echo '<p>Nothing found.</p>';
+    page_end(); exit;
+}
+
+$cat = $seg[0];
+$name = preg_replace('/\.git$/', '', $seg[1] ?? '');
+if (count($seg) === 1) {                      /* category → index anchor */
+    if (!isset($cats[$cat])) not_found();
+    header('Location: /projects#' . rawurlencode($cat)); exit;
+}
+$r = load_repo($cat, $name);
+if (!$r) not_found();
+$title = $cats[$cat] ?? $cat;
+$crumbs = [$title => ($PREFIX ?: '/projects') . '#' . rawurlencode($cat), $r['name'] => $r['url']];
+$action = $seg[2] ?? 'tree';
+$sub = safe_path(array_slice($seg, 3));
+if ($sub === null) not_found();
+
+if ($action === 'archive') {
+    header('Content-Type: application/gzip');
+    header('Content-Disposition: attachment; filename="' . $r['name'] . '.tar.gz"');
+    if ($r['git']) passthru('git -c safe.directory=* -C ' . escapeshellarg($r['dir']) . ' archive --format=tar.gz --prefix=' . escapeshellarg($r['name'] . '/') . ' HEAD');
+    else passthru('tar -C ' . escapeshellarg(dirname($r['dir'])) . ' --exclude=.gitsync.meta -czf - ' . escapeshellarg($r['name']));
+    exit;
+}
+if ($action === 'raw') {
+    if (obj_type($r, $sub) !== 'blob') not_found();
+    header('Content-Type: ' . ($MIME[ext($sub)] ?? 'text/plain; charset=utf-8'));
+    header('X-Content-Type-Options: nosniff');
+    echo blob($r, $sub); exit;
+}
+if ($action === 'commits' && $r['git']) {
+    page_start($r['name'] . ' commits', $crumbs + ['commits' => '']);
+    repo_header($r, 'commits');
+    echo activity_svg(activity_days($r), $r['name']);
+    echo '<table class="commits">';
+    foreach (explode("\n", trim(git($r['dir'], 'log', '-100', LOGFMT))) as $l) if ($c = parse_commit($l)) commit_row($r, $c);
+    echo '</table>';
+    page_end(); exit;
+}
+if ($action === 'commit' && $r['git'] && preg_match('/^[0-9a-f]{4,64}$/', $sub)) {
+    $c = parse_commit(trim(git($r['dir'], 'log', '-1', LOGFMT, $sub)));
+    if (!$c) not_found();
+    page_start($c['h'], $crumbs + ['commits' => $r['url'] . '/commits', $c['h'] => '']);
+    repo_header($r, 'commits');
+    echo '<div class="commit"><h2>' . h($c['subject']) . '</h2><p class="meta">' . h($c['author']) . ' · ' . date('Y-m-d H:i', $c['time']) . ' · <code>' . h($c['H']) . '</code></p>';
+    $body = trim(git($r['dir'], 'log', '-1', '--format=%b', $sub));
+    if ($body !== '') echo '<pre class="body">' . h($body) . '</pre>';
+    echo '<pre class="diff">';
+    foreach (explode("\n", git($r['dir'], 'show', '--format=', '--stat', '-p', $sub)) as $l) {
+        $cls = match (true) { str_starts_with($l, '+++') || str_starts_with($l, '---') => 'h', str_starts_with($l, '+') => 'a',
+                              str_starts_with($l, '-') => 'd', str_starts_with($l, '@@') => 'r', str_starts_with($l, 'diff ') => 'f', default => '' };
+        echo $cls ? '<span class="' . $cls . '">' . h($l) . "</span>\n" : h($l) . "\n";
+    }
+    echo '</pre></div>';
+    page_end(); exit;
+}
+if ($action === 'blob') {
+    if (obj_type($r, $sub) !== 'blob') not_found();
+    $c = blob($r, $sub); $e = ext($sub);
+    page_start(basename($sub), $crumbs + [$sub => '']);
+    repo_header($r, 'files');
+    $raw = tree_link($r, $sub, 'raw');
+    echo '<div class="filehead"><b>' . h($sub) . '</b> <span class="meta">' . fmt_size(strlen($c)) . ' · ' . substr_count($c, "\n") . ' lines</span> <a class="btn" href="' . h($raw) . '">raw</a></div>';
+    if (isset($MIME[$e]) && $MIME[$e] !== 'application/pdf') echo '<p><img class="preview" src="' . h($raw) . '" alt=""></p>';
+    elseif (strlen($c) > 1048576) echo '<p><i>File too large to display.</i></p>';
+    elseif (str_contains(substr($c, 0, 8000), "\0")) echo '<p><i>Binary file.</i></p>';
+    elseif (in_array($e, ['md', 'markdown'])) echo '<div class="markdown readme">' . md($c, rtrim(tree_link($r, dirname($sub) === '.' ? '' : dirname($sub), 'raw'), '/') . '/') . '</div>';
+    else echo '<pre><code class="' . (isset($LANG[$e]) ? 'language-' . $LANG[$e] : 'nohighlight') . '">' . h($c) . '</code></pre>';
+    page_end(); exit;
+}
+if ($action === 'tree') {                     /* repo overview / subtree */
+    if ($sub !== '' && obj_type($r, $sub) !== 'tree') not_found();
+    page_start($r['name'] . ($sub ? "/$sub" : ''), $crumbs + ($sub ? [$sub => ''] : []));
+    repo_header($r, 'files');
+    if ($sub === '' && ($c = last_commit($r))) { echo '<table class="commits last">'; commit_row($r, $c); echo '</table>'; }
+    render_tree($r, $sub);
+    if ($sub === '') render_readme($r);
+    page_end(); exit;
+}
+not_found();
diff --git a/web/style.css b/web/style.css
new file mode 100644
index 0000000..19208f8
--- /dev/null
+++ b/web/style.css
@@ -0,0 +1,97 @@
+:root{--bg:#f3f5f9;--surface:#ffffff;--surface2:#e9edf3;--fg:#1a1d23;--muted:#6b7280;--line:#d9dfe8;--shadow:0 1px 2px rgba(20,30,50,.05);--accent:#e8590c;--accent-soft:#fdeee4;--link:#0b6e99;--add:#e3f7e8;--del:#fde8e8;
+  --l0:#e2e6ed;--l1:#c3e6c8;--l2:#83cc92;--l3:#3ea55b;--l4:#1f6b37;--radius:14px}
+@media(prefers-color-scheme:dark){:root{--bg:#0f1218;--surface:#171b23;--surface2:#1f2530;--fg:#e6e9ef;--muted:#8b93a3;--line:#2a3140;--shadow:0 1px 2px rgba(0,0,0,.3);--accent:#ff7a2e;--accent-soft:#33221a;--link:#62c1e6;--add:#13301c;--del:#3a1417;
+  --l0:#24272c;--l1:#1f4d2c;--l2:#2b7a3f;--l3:#3fa55a;--l4:#6fdc8c}}
+*{box-sizing:border-box;min-width:0}
+body{margin:0;font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--fg);-webkit-font-smoothing:antialiased}
+a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
+code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px}
+h1,h2,h3,.name,.desc,code,td{overflow-wrap:anywhere}
+header{position:-webkit-sticky;position:sticky;top:0;z-index:10;display:flex;flex-wrap:wrap;align-items:center;gap:6px;background:var(--surface);padding:12px 28px;border-bottom:1px solid var(--line);-webkit-backface-visibility:hidden;backface-visibility:hidden;will-change:transform}
+header .brand{font-weight:700;color:var(--fg);letter-spacing:-.01em;margin-right:10px}
+.topnav{display:inline-flex;gap:2px;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:3px;margin-right:12px}
+.topnav a{color:var(--muted);font-size:13px;font-weight:600;padding:4px 14px;border-radius:999px}
+.topnav a.on{background:var(--surface);color:var(--fg);border:1px solid var(--line)}.topnav a:hover{color:var(--accent);text-decoration:none}
+header .crumbs{font-size:14px;color:var(--muted)}header .crumbs a{color:var(--fg)}header .crumbs b{color:var(--fg)}header .brand small{font-weight:400;color:var(--muted);margin-left:8px;font-size:12px}header .sep{color:var(--muted)}
+header .search{margin-left:auto;display:flex;gap:6px}
+header input[type=text]{border:1px solid var(--line);border-radius:999px;padding:7px 14px;background:var(--surface2);color:var(--fg);width:240px;max-width:60vw;font:inherit;font-size:14px}
+header input[type=submit],.copy,.btn{font:inherit;font-size:13px;cursor:pointer;background:var(--surface2);border:0;border-radius:999px;padding:6px 14px;color:var(--fg)}
+header input[type=submit]:hover,.copy:hover,.btn:hover{background:var(--accent-soft);color:var(--accent);text-decoration:none}
+main{max-width:1120px;margin:0 auto;padding:28px 28px 40px}
+footer{text-align:center;color:var(--muted);font-size:12px;padding:30px;line-height:1.9}footer a{color:var(--muted)}
+h1{font-size:26px;font-weight:700;letter-spacing:-.02em;margin:0 0 14px}
+h2{font-size:15px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:36px 0 14px;scroll-margin-top:70px}h2 small{font-weight:400;margin-left:6px}
+.badge{flex:none;align-self:flex-start;font-size:11px;font-weight:600;color:var(--muted);background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:2px 9px;line-height:1.6;white-space:nowrap}
+h1 .badge{vertical-align:middle;display:inline-block}
+.meta,.desc{color:var(--muted)}.meta{font-size:13px}
+.tag{display:inline-block;font-size:12px;background:hsl(var(--h,210),45%,93%);border:1px solid hsl(var(--h,210),35%,85%);border-radius:999px;padding:1px 10px;color:hsl(var(--h,210),45%,28%);margin:2px 2px 2px 0}
+.tag:hover,.tag.on{background:hsl(var(--h,210),55%,86%);border-color:hsl(var(--h,210),50%,55%);text-decoration:none}.tag small{opacity:.6}
+.tag.more{background:var(--surface2);border-color:var(--line);color:var(--muted)}
+@media(prefers-color-scheme:dark){.tag{background:hsl(var(--h,210),25%,20%);border-color:hsl(var(--h,210),25%,28%);color:hsl(var(--h,210),55%,78%)}.tag:hover,.tag.on{background:hsl(var(--h,210),30%,28%);border-color:hsl(var(--h,210),45%,55%)}}
+.tagcloud{line-height:1.9;margin:0 0 4px}.tagcloud .tag{font-size:11px;padding:0 8px}.tag.more{color:var(--muted)}
+.catnav{margin:0 0 10px;font-size:13px}.catnav a{margin-right:14px;color:var(--fg);font-weight:600;white-space:nowrap}.catnav a:hover{color:var(--accent);text-decoration:none}
+.tagt{font-size:12px;color:hsl(var(--h,210),45%,40%)}.tagt:hover{text-decoration:underline}
+@media(prefers-color-scheme:dark){.tagt{color:hsl(var(--h,210),50%,70%)}}
+.lead{font-size:16px;color:var(--muted);max-width:720px;margin:0 0 18px}
+.intro .card .name{font-size:18px}
+.commits.feed td:nth-child(2){overflow-wrap:anywhere}.commits.feed .repo{font-weight:600;color:var(--fg);margin-right:6px}
+.card .tags{position:relative;z-index:1;font-size:12px;color:var(--muted)}
+.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:14px}
+.card{position:relative;display:flex;flex-direction:column;gap:5px;border-radius:var(--radius);padding:16px 18px;background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);transition:border-color .2s,box-shadow .2s}
+.card:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(0,0,0,.06)}
+.recent .card{box-shadow:inset 3px 0 0 var(--accent),var(--shadow)}
+.recent .card:hover{box-shadow:inset 0 0 0 0 var(--accent),0 4px 14px rgba(0,0,0,.06)}
+.card .top{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
+.card .name{font-weight:600;font-size:16px;color:var(--fg)}
+.card .name::after{content:"";position:absolute;inset:0;border-radius:var(--radius)}
+.card:hover .name{color:var(--accent);text-decoration:none}
+.card .tag,.card .tagt,.card .badge{position:relative;z-index:1}
+.card p{margin:0}.card .desc{font-size:14px}.card .meta{margin-top:auto;padding-top:6px}
+.langbar{display:flex;height:6px;border-radius:999px;overflow:hidden;background:var(--l0);margin:8px 0 2px}
+.langbar>span{display:block}
+.langs{display:block;font-size:12px;color:var(--muted);margin-bottom:8px}.lang{margin-right:12px;white-space:nowrap}
+.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:-1px}
+.repohead{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);padding:20px 24px 16px;margin-bottom:18px}
+.repohead h1 a{color:var(--fg)}.repohead h1{margin-bottom:4px}.repohead .desc{margin:0 0 8px;font-size:15px}.repohead p.meta{margin:0 0 8px}
+.tabs{display:flex;gap:4px;margin:10px 0 12px;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:3px;width:max-content}
+.tabs a{color:var(--muted);padding:5px 16px;border-radius:999px;font-size:14px}.tabs a.on{background:var(--surface);color:var(--fg);font-weight:600;border:1px solid var(--line)}.tabs a:hover{text-decoration:none;color:var(--fg)}
+.get{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
+.getopts{display:inline-flex;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:2px}
+.getopts button{font:inherit;font-size:12px;border:0;background:none;color:var(--muted);border-radius:999px;padding:4px 12px;cursor:pointer}.getopts button.on{background:var(--surface);color:var(--fg);font-weight:600;border:1px solid var(--line)}
+.cmd{display:inline-flex;gap:8px;align-items:center;background:var(--surface2);border:1px solid var(--line);border-radius:10px;padding:6px 12px;max-width:100%}
+.cmd code{overflow-wrap:anywhere}
+table{border-collapse:collapse;width:100%}
+.tree td,.commits td{padding:9px 14px;border-top:1px solid var(--line)}.tree tr:first-child td,.commits tr:first-child td{border-top:0}
+.tree,.commits{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);overflow:hidden}
+.tree tr:hover td,.commits tr:hover td{background:var(--surface2)}
+.tree .ico{width:28px;text-align:center;color:var(--muted);font-family:monospace}.tree .size,.commits .meta{text-align:right;color:var(--muted);white-space:nowrap}
+.commits.last{margin-bottom:14px}.hash{font-family:monospace}
+.readme{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);margin-top:18px;padding:20px 28px}.readme h3{margin-top:0;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
+.markdown img{max-width:100%;border-radius:8px}.markdown pre{background:var(--surface2);border:1px solid var(--line);padding:12px 14px;border-radius:10px;overflow:auto}
+.markdown code{background:var(--surface2);padding:1px 5px;border-radius:5px}.markdown pre code{padding:0}
+.markdown blockquote{border-left:3px solid var(--accent);margin:0;padding:2px 14px;color:var(--muted)}
+.markdown table.md{width:auto}.markdown table.md td,.markdown table.md th{border:1px solid var(--line);padding:5px 12px}
+.markdown h1,.markdown h2,.markdown h3{text-transform:none;letter-spacing:0;color:var(--fg);margin:20px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:18px}.markdown h3{font-size:15px}
+.filehead{display:flex;flex-wrap:wrap;gap:12px;align-items:center;background:var(--surface2);border:1px solid var(--line);border-radius:var(--radius) var(--radius) 0 0;padding:10px 16px}
+.filehead+pre,.filehead+div{background:var(--surface);border:1px solid var(--line);border-top:0;border-radius:0 0 var(--radius) var(--radius);margin:0;padding:14px 16px;overflow:auto}
+pre{margin:0;overflow:auto;white-space:pre}
+.preview{max-width:100%;border-radius:8px}
+.commit h2{text-transform:none;letter-spacing:0;color:var(--fg);font-size:20px;margin:0}.commit .body{background:var(--surface2);padding:12px;border-radius:10px;margin:10px 0}
+.diff{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px;line-height:1.45}
+.diff .a{background:var(--add);display:block}.diff .d{background:var(--del);display:block}
+.diff .r{color:var(--link);display:block}.diff .f,.diff .h{font-weight:700;display:block}
+.activitybox{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);padding:16px 20px;margin:18px 0}.activitybox p{margin:0 0 6px}
+.activity{width:100%;max-width:760px;display:block}
+.activity text{font-size:9px;fill:var(--muted)}
+.activity .l0{fill:var(--l0)}.activity .l1{fill:var(--l1)}.activity .l2{fill:var(--l2)}.activity .l3{fill:var(--l3)}.activity .l4{fill:var(--l4)}
+@media(max-width:640px){
+header{padding:10px 14px}header .search{margin-left:0;width:100%}header input[type=text]{width:100%;max-width:none;flex:1}
+main{padding:16px 14px 30px}h1{font-size:22px}
+.cards{grid-template-columns:1fr}
+.repohead{padding:14px 16px 12px}.tabs{width:100%}.tabs a{flex:1;text-align:center}
+.tree td,.commits td{padding:8px 10px}.commits .meta{display:none}
+.activity{max-width:none}
+}
+.hint{white-space:pre-wrap}
+.totop{position:fixed;right:22px;bottom:22px;width:40px;height:40px;border-radius:50%;background:var(--surface);border:1px solid var(--line);box-shadow:0 4px 14px rgba(0,0,0,.12);color:var(--fg);font-size:18px;line-height:38px;text-align:center;opacity:0;pointer-events:none;transition:opacity .2s;z-index:9}
+.totop.show{opacity:1;pointer-events:auto}.totop:hover{color:var(--accent);border-color:var(--accent);text-decoration:none}