Browse Source

pisg 1.0.a: modern look, new sections, landing page, guided setup

Guided setup (perl setup.pl); five modern themes with a left-hand menu on wide
screens; overview, relation map, closest pairs, social roles, time
personalities, concentration and signature-word sections; a landing page with
the statistics of the statistics; HomeLink and BadUrls options; several
channels in one run (a broken channel is skipped); safer output (pages are
written to a temporary file first, exit status 1 on failure); eggdrop profile
commands, automatic nick merging and log converters in scripts/; a complete
pisg.cfg.example and a one-page manual.

See docs/Changelog and docs/RELEASE-NOTES-1.0.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pisg 4 days ago
parent
commit
a89ec72625

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+output/
+pisg_cron.log
+*.tar.gz
+*.swp
+*.dump
+__pycache__/
+*.pyc
+aliases.auto.cfg
+users.cfg
+pisg.cfg.new
+pisg.cfg.bak-*

+ 11 - 2
Makefile

@@ -21,6 +21,7 @@ FILES = pisg \
 	 COPYING \
 	 README \
 	 pisg.cfg \
+	 pisg.cfg.example \
 	 lang.txt
 
 DOCS = docs/FORMATS \
@@ -47,6 +48,10 @@ SCRIPTS = scripts/crontab \
 	   scripts/dropegg.pl \
 	   scripts/egg2mirc.awk \
 	   scripts/eggdrop-pisg.tcl \
+	   scripts/eggdrop-pisg-test.tcl \
+	   scripts/pisg-autoalias.py \
+	   scripts/pisg-autoalias-test.py \
+	   scripts/adiirc2eggdrop.py scripts/znc-setup.sh \
 	   scripts/mirc2egg.sed \
 	   scripts/sirc-timestamp.pl \
 	   scripts/windows-ftp-upload.txt
@@ -62,7 +67,8 @@ MODULESDIR = modules
 MAIN_MODULE = $(MODULESDIR)/Pisg.pm
 
 PISG_MODULES = $(MODULESDIR)/Pisg/Common.pm \
-	       $(MODULESDIR)/Pisg/HTMLGenerator.pm
+	       $(MODULESDIR)/Pisg/HTMLGenerator.pm \
+	       $(MODULESDIR)/Pisg/Insights.pm
 
 PARSER_MODULES = $(MODULESDIR)/Pisg/Parser/Logfile.pm
 
@@ -135,7 +141,10 @@ release: docs
 	cp -r $(DOCS) $(DIRNAME)/docs
 
 	mkdir $(DIRNAME)/layout
-	cp layout/*.css $(DIRNAME)/layout
+	cp layout/*.css layout/*.js layout/build-themes.py $(DIRNAME)/layout
+
+	mkdir $(DIRNAME)/site
+	cp site/index.html site/README.txt $(DIRNAME)/site
 
 	mkdir $(DIRNAME)/docs/dev
 	cp $(DEVDOCS) $(DIRNAME)/docs/dev

+ 33 - 2
README.md

@@ -5,10 +5,24 @@ show for the users of your channel.
 The supported logfile formats is explained in the FORMATS file included with
 this distribution in the 'docs' directory.
 
+QUICK START (NEW TO PISG?)
+-------------------------
+Run the guided setup. It finds the IRC logs already on your computer (eggdrop,
+ZNC, irssi, WeeChat, HexChat, mIRC, AdiIRC), asks a few questions, writes
+pisg.cfg, makes your first stats page and explains how to keep it up to date
+and host it for free:
+
+    $ perl setup.pl
+
+It works on Linux, macOS and Windows (with Perl installed, for example
+Strawberry Perl). 'perl setup.pl --dry-run' shows what it would do without
+changing anything.
+
 SETTING UP PISG
 ---------------
-Full documentation for pisg is located in 'docs/pisg-doc.txt' and
-'docs/html/index.html' for a HTML version.
+Full documentation for pisg is located in 'docs/pisg-doc.html' (one web page,
+with a complete example configuration at the end) and in 'docs/pisg-doc.xml'.
+A ready-to-copy configuration listing every option is in 'pisg.cfg.example'.
 
 Quick usage instructions below:
 
@@ -23,6 +37,23 @@ where you can insert your own data.
 The commandline version has the disadvantage that you can only set up one
 channel to be run.
 
+WHAT'S NEW IN 1.0.a
+-------------------
+ * A guided setup: 'perl setup.pl'.
+ * Modern light/dark themes, a menu down the left side on wide screens (a slim
+   section bar on phones), and an interactive relation map ('who talks to
+   whom'), plus overview, roles, time personalities and more.
+ * A landing page with the statistics of your statistics and a page per
+   channel: copy 'site/index.html' next to your stats pages (pisg writes
+   'channels.json' for it).
+ * Several channels in one run: a channel that cannot be read is skipped and
+   the others are still made.
+ * BadUrls (keep spam links out of the stats).
+ * Safer output: a page is only replaced once it is complete.
+ * Optional tools in 'scripts/': profiles people edit from IRC (eggdrop),
+   automatic nick merging, and log converters.
+ See 'docs/Changelog' for the full list.
+
 RUNNING PISG
 ------------
 With ZNC, you might need to use this: 

+ 76 - 0
docs/Changelog

@@ -1,3 +1,79 @@
+pisg (1.0.a) - Mon Sep, 21st 2026
+    The "Modern Look" Release.
+    pisg maintainers:
+    * Guided setup: run "perl setup.pl". It looks for logs already on the
+      computer (eggdrop, ZNC, irssi, WeeChat, HexChat, mIRC, AdiIRC), asks a few
+      questions, writes pisg.cfg, makes the first page, offers to run pisg on a
+      schedule (cron, or a Windows scheduled task) and lists free places to
+      host the pages. It never overwrites an existing pisg.cfg.
+    * New look: five modern themes (modern, midnight, amoled, terminal,
+      canada), generated from one stylesheet by layout/build-themes.py. The
+      old themes are unchanged. Ink on paper with colour reserved for data:
+      four time-of-day colours checked for colour-blind safety in light and
+      dark, nicks and figures in monospace, sections separated by a rule
+      instead of boxes, bars standing on one baseline. Scales from phones to
+      wide screens.
+    * Section navigation (ShowNavBar): on wide screens a menu down the left
+      side with the section being read marked, and the page uses the full width
+      with short sections side by side; on narrow screens a slim bar with a
+      "Sections" menu listing every section at once. No sideways scrolling, and
+      it works without JavaScript.
+    * New sections: channel overview (ShowOverview), relation map, closest pairs
+      and social roles (ShowRelations, RelationNicks, RelationMinWeight),
+      time personalities (ShowTimePersonalities), who carries the channel
+      (ShowConcentration) and signature words (ShowSignatureWords).
+      The relation map is interactive (click a nick or a line for details) and
+      spreads its nicks evenly instead of drawing them to scale.
+      Bots (sex="b" or ignore="y") are left off it. English and French texts.
+    * Landing page: site/index.html is a static page (no PHP, no libraries, works
+      on GitHub Pages) with the statistics of the statistics: a headline and facts
+      for all channels, one row per channel to compare them (share of lines,
+      busiest hour, a 24-hour profile, the last 30 days, top talker) with a "View
+      stats" button, and the busiest people of each channel. It reads
+      channels.json, which pisg writes next to its pages (option ChannelIndex).
+    * New option HomeLink: an "All channels" button at the top of every stats page
+      that leads back to the landing page.
+    * New option BadUrls: URLs containing a listed word are left out of the URL
+      statistics. Understands the wildcards * and ?.
+    * Several channels in one run: a channel that cannot be read (missing log
+      folder, wrong format) is skipped with a message ("Skipped channel #name:
+      reason") and the others are still made; pisg exits with status 1 so cron
+      or monitoring notices.
+    * New pisg.cfg.example listing every option with what it does, and
+      docs/pisg-doc.html, the documentation as one web page that includes it.
+      docs/gen-example-config.py and docs/xml2html.py rebuild both.
+    * scripts/adiirc2eggdrop.py converts an AdiIRC channel log to eggdrop or
+      ZNC log format (--format znc), public events only, local time to UTC, so
+      old logs can be added to the stats. It never overwrites a file.
+    * scripts/znc-setup.sh puts converted logs into a ZNC account's log folder
+      and lets pisg, running as another unix user, read only those channel
+      folders. It shows what it would do before it changes anything.
+    * Profiles people edit themselves: scripts/eggdrop-pisg.tcl adds !pisghelp,
+      !pisginfo (sex, picture, link), !pisgmerge / !pisgunmerge (count their other
+      nicks as one), !pisgshow, !pisgdel and, for bot masters, !pisgdeluser. Each
+      also works in a private message. The bot identifies people by their
+      services account, only lets them claim the nick they are using, and writes
+      users.cfg, which pisg reads with <include="users.cfg">. Your own settings go
+      in pisg.local.tcl next to the script. Test suite: scripts/eggdrop-pisg-test.tcl.
+    * scripts/pisg-autoalias.py merges nicks that used the same authenticated
+      account host (on Undernet *.users.undernet.org). It writes a file for pisg
+      to include; your own <user> lines always win. It has its own tests.
+    * eggdrop-pisg.tcl: !pisgstats is open to everybody, gives the right address
+      and no longer runs pisg inside the bot; the broken timer (it posted to a nick
+      called "gchan" and ran pisg a second time) is off by default.
+    Fixes:
+    * A page is now written to a temporary file and moved into place only when
+      it is complete, so an error can no longer leave a half-written page. pisg
+      now exits with status 1 when it fails (it used to exit 0).
+    * Long quotes are shortened on a character boundary, so UTF-8 text is no
+      longer cut in half.
+    * A section that fails is skipped with a warning instead of stopping pisg.
+    * An error in one channel no longer leaks its nick aliases into the next.
+    * "Most used words" no longer lists punctuation or ASCII art as words.
+    * Tables share one left edge; column titles and "3 days ago" stay on one
+      line; the default user picture is shown as a small faded placeholder; no
+      useless sideways scrollbars in the sections (checked from 360 to 1280 px).
+
 pisg (0.73) - Wed Jan, 18th 2012
     The "Long Awaited" Release.
     HM2K:

+ 195 - 0
docs/RELEASE-NOTES-1.0.md

@@ -0,0 +1,195 @@
+# pisg 1.0: what changed since 0.80-preview2
+
+pisg 1.0 keeps everything 0.80-preview2 does: the same parsers, the same statistics, the same
+`pisg.cfg`. On top of that it has a new look, a set of new sections, a way to run several channels
+from one folder, safer output, a guided setup, and tools to keep the statistics tidy without editing files
+by hand.
+
+Nothing you have set up has to change. The default colour scheme is still `default`, and every old theme
+looks as it did. Everything new is opt-in through a colour scheme or an option, except the extra
+sections, which are on by default (see [Upgrading](#upgrading)).
+
+## Highlights
+
+- **A guided setup**: `perl setup.pl` finds your logs, writes the configuration, makes the first page and
+  explains how to run it on a schedule and host it for free. Made for people new to IRC tools.
+- **Five modern themes**, light and dark, that follow the reader's system setting: `modern`, `midnight`,
+  `amoled`, `terminal` and `canada`.
+- **Seven new sections**: an overview, an interactive *who talks to whom* map, closest pairs, social roles,
+  time personalities, *who carries the channel* and signature words.
+- **Section navigation**: a menu down the left side on wide screens, a slim bar on phones.
+- **A landing page** with the statistics of your statistics: totals, a comparison of every channel, and a
+  button to open each one.
+- **Several channels, one run**: a channel that cannot be read no longer stops the others.
+- **Profiles people manage themselves** from IRC (`!pisginfo`, `!pisgmerge` ...) and **automatic nick
+  merging** for networks with account services.
+- **Safer**: a page is replaced only when it is complete, and pisg exits with an error status when it fails.
+
+## Guided setup
+
+`perl setup.pl` is the easy way in. It needs nothing but Perl, so it works on Linux, macOS and Windows.
+
+1. **Finds your logs.** It looks for eggdrop (reads `eggdrop.conf` for the log files), ZNC (all three
+   log-module layouts), irssi, WeeChat, HexChat/XChat and mIRC/AdiIRC, and shows what it found. If it finds
+   nothing it says how to turn logging on, or lets you point it at a folder and pick the log format.
+2. **Asks a few questions** (channels, your name, network name, colour scheme, output folder) and shows the
+   configuration before it writes anything.
+3. **Writes `pisg.cfg`**, never overwriting an existing one (it backs it up first, or saves the new one as
+   `pisg.cfg.new`), copies the landing page, and makes the first statistics.
+4. **Schedules it**: prints the cron line (and adds it if you say yes) on Linux and macOS, or the
+   `schtasks` command on Windows.
+5. **Explains free hosting**: GitHub Pages, Cloudflare Pages, Netlify, GitLab Pages or your own server,
+   with a reminder that the pages need a web server for the landing page to list the channels, and that they
+   show nicknames and a random line from the chat.
+
+`perl setup.pl --dry-run` shows what would be written without changing anything.
+
+## New look
+
+- Five new colour schemes, all generated from one stylesheet (`layout/build-themes.py`), so they stay
+  consistent. Use one with `<set ColorScheme="modern">`. The old themes are unchanged.
+- Design: ink on paper with colour kept for data. Sections are separated by a thin rule instead of boxes,
+  nicks and figures are set in a monospace face, bars stand on one baseline, and the peak of a chart is the
+  only value labelled.
+- Four time-of-day colours (night, morning, afternoon, evening) used the same way in every chart. They
+  were checked for colour-blind safety on light, dark and pure black backgrounds.
+- Responsive from 360 px phones to wide desktop screens. Wide screens get a fixed menu on the left, use
+  the full width, and put short sections side by side in two columns. Wide tables scroll inside their own
+  section and never make the whole page scroll sideways.
+- A viewport tag, so pages scale correctly on phones.
+- Full UTF-8 handling with `<set Charset="utf-8">`: quotes, words and nicks in the new sections keep their
+  accented and non-Latin letters.
+- Column titles stay on one line, tables share one left edge, "3 days ago" no longer breaks, and the shared
+  default avatar is shown small and faded.
+
+## New sections
+
+All are on by default and can be switched off individually.
+
+| Section | What it shows | Option |
+|---|---|---|
+| Channel overview | A headline sentence and the key facts: words, lines per day, busiest and quietest hour, questions, links, top talker, joins, kicks | `ShowOverview` |
+| Who talks to whom | An interactive map of who addresses, mentions or answers whom. Click a nick or a line for details. Bots are left out | `ShowRelations`, `RelationNicks`, `RelationMinWeight` |
+| Closest pairs | The strongest two-way conversations, with how they talk | `ShowRelations` |
+| Social roles | Most talked to, most outgoing, connector, best listener, lone wolf | `ShowRelations` |
+| Time personalities | Who owns the night, morning, afternoon and evening | `ShowTimePersonalities` |
+| Who carries the channel | How much of the talk comes from the top 1, 3, 5, 10 and 20 | `ShowConcentration` |
+| Signature words | The word each person uses a lot and hardly anyone else does | `ShowSignatureWords` |
+
+The relation map spaces its nicks by how close they are, not to scale, so a busy channel does not crush
+the middle of the map. Nicks marked `sex="b"` or `ignore="y"` are treated as bots.
+
+English and French texts are included. Other languages fall back to English.
+
+## Navigation
+
+- **Left menu** on screens 1000 px and wider: the channel name, every section, and a marker on the section
+  you are reading.
+- **Section bar** on narrower screens: a slim bar showing where you are, with a "Sections" button that opens
+  the whole list. It works without JavaScript. (`ShowNavBar`, on by default)
+- **Back button** (`HomeLink`): an "All channels" button above the title of every stats page that leads to
+  your landing page.
+
+## Landing page
+
+`site/index.html` is a single static file: no PHP, no cron job, no libraries. It works on any static host,
+including GitHub Pages. Copy it next to your stats pages.
+
+pisg writes `channels.json` beside each page (option `ChannelIndex`) and the landing page turns it into
+the statistics of the statistics:
+
+- a headline (lines, channels, days of history) and facts across every channel;
+- one row per channel: share of all lines, nicks, lines per day, its busiest hour, a 24-hour profile in
+  the time-of-day colours, a small chart of the last 30 days, and a **View stats** button;
+- the five busiest people in each channel.
+
+Old links such as `index.html#canada` still jump to that channel. Names from the JSON are always shown as
+plain text, and only plain relative `.html` paths are accepted as links.
+
+## New options
+
+| Option | Default | What it does |
+|---|---|---|
+| `ShowOverview` | on | The channel overview section |
+| `ShowRelations` | on | The relation map, closest pairs and social roles |
+| `RelationNicks` | 30 | How many nicks the relation map shows |
+| `RelationMinWeight` | 3 | How strong a connection must be to be drawn |
+| `ShowTimePersonalities` | on | Time personalities section |
+| `ShowConcentration` | on | *Who carries the channel* section |
+| `ShowSignatureWords` | on | Signature words section |
+| `ShowNavBar` | on | The section bar and left menu |
+| `HomeLink` | empty | Page the "All channels" button leads to (`index.html`, or an http(s) address). Empty or `none`: no button |
+| `BadUrls` | empty | Words that keep a URL out of the URL statistics. Case-insensitive, matched anywhere in the URL, with `*` and `?` wildcards (for example `postimg.cc/*`) |
+| `ChannelIndex` | `channels.json` | File pisg writes for the landing page. `none` turns it off |
+
+`pisg.cfg.example` now lists every option with what it does, so it can be copied and used at once.
+
+## Tools for the people who run the channel
+
+These are optional and live in `scripts/`.
+
+- **`eggdrop-pisg.tcl`**: profile commands for an eggdrop, in the channel (`!pisg...`) and in a private
+  message (`pisg...`). Nothing is stored in pisg itself: the bot writes `users.cfg`, which pisg reads
+  with `<include="users.cfg">`.
+  - `!pisghelp [command]`: explains each command.
+  - `!pisginfo`: set your sex, picture and link.
+  - `!pisgmerge` / `!pisgunmerge`: count your other nicks as one person, or stop.
+  - `!pisgshow`, `!pisgdel`, and `!pisgdeluser` (bot masters only).
+  - `!pisgstats`: replies with the address of the stats page. It is open to everyone, and it no longer runs
+    pisg inside the bot.
+  - Your own settings (paths, the address of your stats) go in `pisg.local.tcl` next to the script, so an
+    update never overwrites them.
+  - People are identified by their network account (`getaccount`, or the account host on Undernet),
+    can only claim the nick they are using, and cannot overwrite a `<user>` line you wrote by hand.
+    Changes are rate limited. There is a test suite: `eggdrop-pisg-test.tcl` (107 checks).
+- **`pisg-autoalias.py`**: nicks seen with the same authenticated host (`*.users.undernet.org`) are one
+  person. It writes a file for pisg to include and can run before every pisg run. Your own `<user>` lines
+  always win. Shared hosts, gateways and bouncers are never merged. Test suite included.
+- **`adiirc2eggdrop.py`**: turns an AdiIRC (or similar) channel log into eggdrop or **ZNC** logs, so old history
+  can be added to the statistics. It keeps public events only, converts local time to UTC, never
+  overwrites a file, and stops at the moment your real logging starts. `--format znc` writes what the ZNC
+  log module writes (one file per day, `energymech` format).
+- **`znc-setup.sh`**: puts converted logs into a ZNC account's log folder and lets pisg, which runs as a different
+  Unix user, read only the channel folders it needs. It shows what it would do before it changes anything.
+- pisg reads ZNC logs with `Format="energymech"` and a `LogDir` per channel.
+
+## Running several channels
+
+- One `pisg.cfg` can hold any number of `<channel>` blocks. If a channel cannot be read (a missing log
+  folder, a wrong format), pisg prints `Skipped channel #name: reason`, carries on with the others, and exits
+  with status 1 so cron or monitoring notices.
+- An error in one channel no longer leaks that channel's nick aliases into the next one.
+- Each channel reads its own `LogDir` (a folder of dated log files, read in date order), so eggdrop's daily
+  logs and ZNC's one-file-a-day logs can sit side by side in one config.
+
+## Reliability
+
+- A page is written to a temporary file and moved into place only once it is complete, so an error can
+  never leave a half-written page where the good one was.
+- pisg now exits with status 1 when it fails (it used to exit 0).
+- A section that fails is skipped with a warning; the rest of the page is still written.
+- Long quotes are shortened on a character boundary, so UTF-8 text is no longer cut in half.
+- "Most used words" no longer lists punctuation or ASCII art.
+- URL statistics honour `BadUrls`, so spam and image-host links can be left out.
+
+## Documentation
+
+- `docs/pisg-doc.html`: the whole manual as one web page, with a complete example configuration for a
+  `#example` channel at the end. Built from `docs/pisg-doc.xml` by `docs/xml2html.py`.
+- `pisg.cfg.example`: every option, built by `docs/gen-example-config.py`.
+- New options are documented in the manual, and `README.md` has a short "what's new".
+
+## Upgrading
+
+1. Replace the program files. Your `pisg.cfg` keeps working as it is. (New to pisg? Run `perl setup.pl`.)
+2. **The new sections are on by default**, in every colour scheme (the old ones show them in a plain
+   style). If you want the old page exactly, set `ShowOverview`, `ShowRelations`, `ShowTimePersonalities`,
+   `ShowConcentration`, `ShowSignatureWords` and `ShowNavBar` to `0`.
+3. To use the new look, add `<set ColorScheme="modern">` (or `midnight`, `amoled`, `terminal`, `canada`).
+4. `pisg` now returns exit status 1 on failure. If a script or cron job treated any exit as success,
+   check what it does with the error.
+5. For the landing page, copy `site/index.html` into your output folder and run pisg once so
+   `channels.json` exists. Add `<set HomeLink="index.html">` for the back button.
+
+**Requirements** are unchanged for pisg itself (Perl). The optional tools need Python 3 (converter, alias
+generator), Tcl 8.6 and an eggdrop (the bot script), and `JSON::PP`, part of core Perl, for `ChannelIndex`.

+ 265 - 0
docs/gen-example-config.py

@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Generate pisg.cfg.example: a complete, ready-to-use configuration.
+
+Every option comes from two sources so nothing can be missed or drift:
+  * docs/pisg-doc.xml   - name, description, documented default, grouping
+  * modules/Pisg.pm     - the real default value used by the code
+Options the old docs never mention (BotNicks, TableWidth, the bar image
+options) are described in EXTRA below.
+
+Usage: python3 docs/gen-example-config.py      -> writes pisg.cfg.example
+Then:  python3 docs/xml2html.py                -> embeds it in pisg-doc.html
+"""
+import re
+import textwrap
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+root_dir = Path(__file__).resolve().parent.parent
+xml_raw = (root_dir / "docs" / "pisg-doc.xml").read_text(encoding="utf-8")
+xml_raw = re.sub(r"^\s*<!--.*?-->\s*", "", xml_raw, count=1, flags=re.S)
+xml_raw = re.sub(r"<\?xml[^>]*\?>", "", xml_raw, count=1)
+xml_raw = re.sub(r"<!DOCTYPE.*?>", "", xml_raw, count=1, flags=re.S)
+tree = ET.fromstring(xml_raw)
+
+# ---- real defaults from the code ------------------------------------------------
+code = (root_dir / "modules" / "Pisg.pm").read_text(encoding="utf-8")
+block = code[code.index("$self->{cfg} = {"):code.index("version =>")]
+DEFAULTS = {}
+for m in re.finditer(r"^\s+(\w+)\s*=>\s*(?:'((?:[^'\\]|\\.)*)'|\"([^\"]*)\"|(-?\d+)|(\[\]))", block, re.M):
+    val = next((g for g in m.groups()[1:] if g is not None), "")
+    DEFAULTS[m.group(1).lower()] = "" if val == "[]" else val.replace("\\\\", "\\").replace("\\'", "'")
+
+# ---- options the old documentation does not cover --------------------------------
+EXTRA = {
+    "BotNicks": "nicks of bots, for log formats that cannot tell bots from people (DCpp)",
+    "TableWidth": "width in pixels of the statistics tables",
+    "Pic_H_0": "image id of the horizontal bar for hours 0-5",
+    "Pic_H_6": "image id of the horizontal bar for hours 6-11",
+    "Pic_H_12": "image id of the horizontal bar for hours 12-17",
+    "Pic_H_18": "image id of the horizontal bar for hours 18-23",
+    "Pic_V_0": "image id of the vertical bar for hours 0-5",
+    "Pic_V_6": "image id of the vertical bar for hours 6-11",
+    "Pic_V_12": "image id of the vertical bar for hours 12-17",
+    "Pic_V_18": "image id of the vertical bar for hours 18-23",
+}
+
+# ---- choices for the example ---------------------------------------------------------
+# Recommended values that differ from pisg's stock defaults, with the reason.
+RECOMMENDED = {
+    "ColorScheme": ("modern", "modern, light/dark theme; try midnight, amoled, terminal, canada, or default"),
+    "Charset": ("utf-8", "UTF-8 shows accents, emoji and non-Latin scripts correctly"),
+    "NickTracking": ("1", "follow nick changes so Alice, Alice_ and Alice- count as one person"),
+    "DailyActivity": ("14", "show the last 14 days as a bar chart"),
+    "ShowWords": ("1", "also show total words"),
+    "ShowWpl": ("1", "also show words per line"),
+    "ShowCpl": ("1", "also show characters per line"),
+    "ShowMostNicks": ("1", "show who changed nick most often"),
+    "ShowSmileys": ("1", "show the most used smileys"),
+    "ShowKarma": ("1", "show karma (nick++ / nick--)"),
+    "ShowMostActiveByHour": ("1", "show the most active nicks by time of day"),
+    "TopicHistory": ("5", "show the last 5 topics"),
+}
+
+# Options that are alternatives, need a file, or are off/empty by default. They stay
+# commented out with an example value so the file still works exactly as it stands.
+COMMENTED = {
+    "LogDir": ("/path/to/logs/", "use INSTEAD of Logfile to read a whole folder of dated logs"),
+    "LogPrefix": ("example.log.", "with LogDir: only read files starting with this"),
+    "LogSuffix": (".%d%b%Y", "with LogDir: date format at the end of the file names, so they sort by date"),
+    "NFiles": ("30", "with LogDir: only parse the newest N files (0 = all)"),
+    "OutputTag": ("-week", "replaces %t in OutputFile; used with the -nf / -t command line options"),
+    "AltColorScheme": ("midnight.css amoled.css", "extra stylesheets the visitor can switch to"),
+    "PageHead": ("header.html", "HTML file inserted above the statistics"),
+    "PageFoot": ("footer.html", "HTML file inserted below the statistics"),
+    "CacheDir": ("cache/", "cache parsed logs here to speed up runs (delete it when you change settings)"),
+    "IgnoreWords": ("badword otherword", "words to leave out of the word statistics"),
+    "BadUrls": ("imagetwist imgur.com postimg.cc/*", "matched anywhere in a URL, any case; * = any characters, ? = one; for spam and unwanted image hosts"),
+    "DefaultPic": ("images/nobody.png", "picture shown for users who have none"),
+    "ImagePath": ("images/", "folder of user pictures, as seen by the web page"),
+    "ImageGlobPath": ("/var/www/pisg/images/", "folder of user pictures, as seen by pisg (for pic=\"x_*.jpg\" globs)"),
+    "PicWidth": ("55", "show every user picture this wide, in pixels"),
+    "PicHeight": ("55", "show every user picture this tall, in pixels"),
+    "LogCharset": ("iso-8859-1", "convert logs from this charset (needs the Text::Iconv perl module)"),
+    "LogCharsetFallback": ("iso-8859-1", "used for lines that are not valid in LogCharset (needs Text::Iconv)"),
+    "BotNicks": ("bot1 bot2", "only needed for the DCpp log format"),
+    "StatsDump": ("stats.dump", "debugging: dump the raw statistics to this file"),
+}
+
+# Options that live in the <channel> block (set per channel), in display order.
+CHANNEL_OPTS = ["Logfile", "LogDir", "LogPrefix", "LogSuffix", "NFiles", "Format", "Network",
+                "OutputFile", "OutputTag", "Maintainer", "LogType"]
+CHANNEL_VALUES = {
+    "Logfile": ("/path/to/example.log", "the log to read; you can list several Logfile lines"),
+    "Format": ("eggdrop", "your log format: eggdrop, mIRC, xchat, irssi, ... (see docs/FORMATS)"),
+    "Network": ("ExampleNet", "the IRC network, shown on the page"),
+    "OutputFile": ("/var/www/html/example.html", "the page pisg writes"),
+    "Maintainer": ("Your Name", "who is named as maintainer on the page"),
+    "LogType": ("Logfile", "only \"Logfile\" exists, leave it"),
+}
+
+
+def parse_options():
+    """[(section title, [(name, purpose, documented default)])] in document order."""
+    out = []
+    for ch in tree.findall("chapter"):
+        items = []
+        for r in ch.findall("refentry"):
+            name = r.findtext("refnamediv/refname").strip()
+            purpose = " ".join((r.findtext("refnamediv/refpurpose") or "").split())
+            dflt = ""
+            for rs in r.findall("refsect1"):
+                if (rs.findtext("title") or "").strip() == "Default":
+                    dflt = " ".join("".join(rs.find("para").itertext()).split())
+            for n in [x.strip() for x in name.split(",")]:      # "HiCell, HiCell2"
+                items.append((n, purpose, dflt))
+        if items:
+            out.append((ch.findtext("title").strip(), items))
+    return out
+
+
+def comment(text, indent=""):
+    return "\n".join(textwrap.wrap(text, 88, initial_indent=indent + "# ", subsequent_indent=indent + "# "))
+
+
+def value_for(name):
+    if name in RECOMMENDED:
+        return RECOMMENDED[name][0]
+    v = DEFAULTS.get(name.lower(), "")
+    return "1" if (name == "UserPics" and v == "y") else v
+
+
+lines = []
+add = lines.append
+
+add("""\
+# pisg.cfg.example - a complete configuration for pisg, ready to copy.
+#
+#   1. Copy this file to pisg.cfg
+#   2. Change the values marked  EDIT  (log path, format, network, output file, name)
+#   3. Run ./pisg
+#
+# Every option pisg understands is listed below with its meaning. Options are set to
+# pisg's own default unless the comment says "recommended". Lines starting with # are
+# comments; an option shown commented out is off/unset - remove the # to use it.
+#
+# Syntax reminders:
+#   <set Name="value">      a global option, applies to every channel
+#   <channel="#name"> ... </channel>   settings for one channel (they override <set>)
+#   <user nick="..." ...>   per-user settings
+# Each <set> must be on one line, and every value must be in quotes.
+""")
+
+sections = parse_options()
+documented = {n for _, items in sections for n, _, _ in items}
+documented |= set(CHANNEL_VALUES)
+
+add("")
+add("#" * 78)
+add("#  GLOBAL OPTIONS - apply to every channel below")
+add("#" * 78)
+
+seen = set(CHANNEL_OPTS) | {"Channel"}
+emitted = set()
+for title, items in sections:
+    body = [(n, p, d) for n, p, d in items if n not in seen]
+    if title.startswith("General"):
+        title = "General options"
+    if not body:
+        continue
+    add("")
+    add(f"# ---- {title} " + "-" * max(4, 70 - len(title)))
+    for name, purpose, dflt in body:
+        emitted.add(name)
+        add("")
+        if name in RECOMMENDED:
+            add(comment(f"{purpose} - recommended: {RECOMMENDED[name][1]}"))
+        elif name in COMMENTED:
+            add(comment(f"{purpose} - {COMMENTED[name][1]}"))
+        else:
+            add(comment(purpose))
+        if name in COMMENTED:
+            add(f'#<set {name}="{COMMENTED[name][0]}">')
+        else:
+            add(f'<set {name}="{value_for(name)}">')
+            if name == "HiCell":            # documented together with HiCell2
+                add(f'<set HiCell2="{DEFAULTS["hicell2"]}">')
+                emitted.add("HiCell2")
+
+# options that exist in the code but not in the old docs
+add("")
+add("# ---- Advanced (not in the original documentation) " + "-" * 25)
+for name, purpose in EXTRA.items():
+    add("")
+    add(comment(purpose))
+    if name in COMMENTED:
+        add(f'#<set {name}="{COMMENTED[name][0]}">')
+    else:
+        add(f'<set {name}="{value_for(name)}">')
+    emitted.add(name)
+
+add("")
+add("")
+add("#" * 78)
+add("#  YOUR CHANNEL - copy this block for each extra channel")
+add("#" * 78)
+add("")
+add('<channel="#example">')
+by_name = {n: (p, d) for _, items in sections for n, p, d in items}
+for name in CHANNEL_OPTS:
+    purpose = by_name[name][0] if name in by_name else ""
+    if name in CHANNEL_VALUES:
+        val, note = CHANNEL_VALUES[name]
+        add(comment(f"{name}: {note}" + ("  EDIT" if name in ("Logfile", "Format", "Network", "OutputFile", "Maintainer") else ""), "  "))
+        add(f'  {name}="{val}"')
+    else:
+        val, note = COMMENTED[name]
+        add(comment(f"{name}: {note}", "  "))
+        add(f'  #{name}="{val}"')
+    add("")
+add(comment("Any global option can be overridden for this channel only, for example:", "  "))
+add('  #Lang="FR"')
+add('  #ColorScheme="midnight"')
+add("</channel>")
+
+add("""
+
+##############################################################################
+#  USERS - link nicks together, add pictures, mark bots
+##############################################################################
+# nick    the name shown in the stats (required)
+# alias   other nicks of the same person, space separated; * matches anything
+#         (Joe* also counts Joe_, Joe^away ...). NickTracking="1" also finds many
+# pic     picture shown next to the user      bigpic  larger picture it links to
+# link    a web address or e-mail address      sex     m, f or b (bot)
+# ignore  y = leave this nick out of the stats altogether (for bots)
+
+<user nick="Alice" alias="Alice_ Alice-* AliceAway" pic="alice.png" link="https://example.com/alice" sex="f">
+<user nick="Bob" alias="Bob_ Bobby" pic="bob.png" bigpic="bob-big.png" link="bob@example.com" sex="m">
+<user nick="ChanBot" sex="b" ignore="y">
+
+##############################################################################
+#  LINKS - keep addresses out of "Most referenced URLs"
+##############################################################################
+<link url="https://example.com/spam" ignore="y">
+
+##############################################################################
+#  INCLUDE - share users between channels or config files
+##############################################################################
+# Put your <user> lines in users.cfg and load them here (an included file cannot
+# include another file):
+#<include="users.cfg">
+""")
+
+text = "\n".join(lines).rstrip() + "\n"
+
+# completeness check: every documented option + every code default a user can set
+internal = {"configfile", "cchannels", "modules_dir", "channel"}
+present = {m.lower() for m in re.findall(r'^#?\s*<set (\w+)=', text, re.M)}
+present |= {m.lower() for m in re.findall(r'^\s+#?(\w+)="', text, re.M)}
+missing = sorted(k for k in DEFAULTS if k not in present and k not in internal)
+assert not missing, f"options missing from the example: {missing}"
+
+(root_dir / "pisg.cfg.example").write_text(text, encoding="utf-8")
+print(f"wrote pisg.cfg.example: {len(text.splitlines())} lines, "
+      f"{len(present)} options ({len(DEFAULTS)} defaults in code)")

File diff suppressed because it is too large
+ 41 - 0
docs/pisg-doc.html


+ 347 - 1
docs/pisg-doc.xml

@@ -5,7 +5,7 @@
 
 <book id="pisg-documentation">
   <bookinfo>
-    <title>pisg 0.73 documentation</title>
+    <title>pisg 1.0.a documentation</title>
     <subtitle>How to install and configure pisg</subtitle>
   </bookinfo>
   <toc></toc>
@@ -1088,6 +1088,102 @@
     </refsect1>
     </refentry>
 
+    <refentry id="ChannelIndex">
+
+    <refmeta> <refentrytitle>ChannelIndex</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>ChannelIndex</refname>
+    <refpurpose>name of the channel list file for a landing page</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set ChannelIndex="channels.json">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Each time pisg writes a channel page, it also updates a small JSON file, next to
+    that page, listing the channels found in the same directory: the channel name as
+    written in the <xref linkend="Channel" /> setting, the page file name, the network, when
+    it was updated, and the number of days, nicks and lines. A landing page can read this
+    file to offer a drop-down list of channels (see <filename>site/index.html</filename>), so
+    nothing has to scan or scrape the HTML pages. Each run only changes the entry of its
+    own channel, and entries whose page no longer exists are removed. Set it to "none"
+    to switch this off.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> channels.json </para>
+    </refsect1>
+    </refentry>
+
+    <refentry id="HomeLink">
+
+    <refmeta> <refentrytitle>HomeLink</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>HomeLink</refname>
+    <refpurpose>adds a button at the top of every stats page that leads back to your landing page</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set HomeLink="index.html">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Shows an "All channels" button above the title of each stats page (translated with the
+    page language). Give the page it should lead to: a file name next to the stats page, such as
+    <filename>index.html</filename> (the landing page in <filename>site/</filename>), or a full
+    http(s) address. Leave it empty, or "none", for no button. Only the newer colour schemes
+    draw it as a button; a scheme set to "none" does not show it.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> empty (no button) </para>
+    </refsect1>
+    </refentry>
+
+    <refentry id="ShowNavBar">
+
+    <refmeta> <refentrytitle>ShowNavBar</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>ShowNavBar</refname>
+    <refpurpose>enable/disable the section bar at the top of the page</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set ShowNavBar="1">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Adds a slim bar at the top of the stats page that stays in view while scrolling. It shows which
+    section is being read, and a "Sections" button opens a list of every section, laid out in columns so
+    nothing has to be scrolled sideways, whatever the number of sections or the language. It also works
+    without JavaScript. The bar is left out when <xref linkend="ColorScheme" /> is "none",
+    since the page is then meant to be embedded in another one.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> 1 (enabled) </para>
+    </refsect1>
+    </refentry>
+
   </chapter>
   <chapter id="reference-stats">
    <title>Options for various statistics features</title>
@@ -2356,6 +2452,210 @@
     </refsect1>
     </refentry>
 
+    <refentry id="ShowOverview">
+
+    <refmeta> <refentrytitle>ShowOverview</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>ShowOverview</refname>
+    <refpurpose>enable/disable the channel overview</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set ShowOverview="1">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Shows a row of key numbers at the top of the page: lines, words, nicks, days covered, lines
+    per day, the busiest and quietest hour, questions, links shared, the top talker and the
+    number of joins and kicks.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> 1 (enabled) </para>
+    </refsect1>
+    </refentry>
+
+    <refentry id="ShowRelations">
+
+    <refmeta> <refentrytitle>ShowRelations</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>ShowRelations</refname>
+    <refpurpose>enable/disable the relation map and its tables</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set ShowRelations="1">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Shows who talks to whom: an interactive map (click a nick or a line for details), a table of
+    the closest pairs, and the "social roles" (who is talked to the most, the best listener, ...).
+    Two people are connected when one starts a line with the other's nick ("Bob: hi"), mentions
+    them, or answers right after them. A line addressed to someone counts three times as much as
+    a mention or a quick reply. Nicks that are joined by <xref linkend="NickTracking" /> or a user alias
+    count as one person. Bots are left out: mark a bot with <![CDATA[<user nick="Bot" sex="b">]]> to keep it in the
+    other statistics but off the map, or with ignore="y" to remove it altogether. Nicks that are also
+    ordinary words (and the channel name) are not counted as mentions.
+    See also <xref linkend="RelationNicks" /> and <xref linkend="RelationMinWeight" />.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> 1 (enabled) </para>
+    </refsect1>
+    </refentry>
+
+    <refentry id="RelationNicks">
+
+    <refmeta> <refentrytitle>RelationNicks</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>RelationNicks</refname>
+    <refpurpose>number of nicks on the relation map</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set RelationNicks="30">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    The map shows this many of the most active people (bots excluded). More than about 40 makes
+    the picture crowded.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> 30 </para>
+    </refsect1>
+    </refentry>
+
+    <refentry id="RelationMinWeight">
+
+    <refmeta> <refentrytitle>RelationMinWeight</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>RelationMinWeight</refname>
+    <refpurpose>minimum connection strength shown as a line on the relation map</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set RelationMinWeight="3">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Connections weaker than this are not drawn, which keeps the map readable. A line addressed to
+    someone is worth 3, a mention 1 and a quick reply 1. Raise it for a busy channel, lower it for a quiet one.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> 3 </para>
+    </refsect1>
+    </refentry>
+
+    <refentry id="ShowTimePersonalities">
+
+    <refmeta> <refentrytitle>ShowTimePersonalities</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>ShowTimePersonalities</refname>
+    <refpurpose>enable/disable the time personalities section</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set ShowTimePersonalities="1">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Shows who chats mostly at night (0-5h), in the morning (6-11h), in the afternoon (12-17h) and
+    in the evening (18-23h), for the people with enough lines. The hours follow <xref linkend="TimeOffset" />.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> 1 (enabled) </para>
+    </refsect1>
+    </refentry>
+
+    <refentry id="ShowConcentration">
+
+    <refmeta> <refentrytitle>ShowConcentration</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>ShowConcentration</refname>
+    <refpurpose>enable/disable the "who carries the channel" section</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set ShowConcentration="1">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Shows how much of the channel the busiest people write: the share of all lines written by the
+    top 1, 3, 5, 10 and 20 nicks, and how many nicks wrote half of everything.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> 1 (enabled) </para>
+    </refsect1>
+    </refentry>
+
+    <refentry id="ShowSignatureWords">
+
+    <refmeta> <refentrytitle>ShowSignatureWords</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>ShowSignatureWords</refname>
+    <refpurpose>enable/disable the signature words section</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <set ShowSignatureWords="1">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    Shows, for each of the regular talkers, the word they use a lot and that hardly anyone else uses.
+    Short words, nicks and links are ignored (see <xref linkend="WordLength" /> and <xref linkend="IgnoreWords" />).
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> 1 (enabled) </para>
+    </refsect1>
+    </refentry>
+
     <!-- *** IGNOREWORDS *** -->
     <refentry id="IgnoreWords">
 
@@ -2480,6 +2780,52 @@
     </refsect1>
     </refentry>
 
+    <refentry id="BadUrls">
+
+    <refmeta> <refentrytitle>BadUrls</refentrytitle> </refmeta>
+
+    <refnamediv>
+    <refname>BadUrls</refname>
+    <refpurpose>words that keep a URL out of the URL statistics</refpurpose>
+    </refnamediv>
+
+    <refsynopsisdiv><programlisting>
+      <![CDATA[
+        <channel="#channel">
+         Logfile = "channel.log"
+         Format = "xchat"
+         BadUrls = "imagetwist imgur.com postimg.cc/*"
+        </channel>
+
+        <set BadUrls="imagetwist imgur.com postimg.cc/*">
+      ]]>
+    </programlisting></refsynopsisdiv>
+
+    <refsect1>
+    <title>Description</title>
+    <para>
+    BadUrls is a list of words, separated by spaces, much like <xref linkend="FoulWords" />.
+    A URL that contains any of the words is not counted, so it never shows up in
+    "Most referenced URLs" (see <xref linkend="ShowMru" />). Use it to keep spam and
+    unwanted image hosts out of the statistics.
+    A word matches anywhere in the URL, whatever the case, so "imgur.com" also
+    removes "https://i.imgur.com/abc.png". Two wildcards are understood: * stands for
+    any run of characters and ? for exactly one character. So "postimg.cc/*" removes
+    every link to postimg.cc (including "i.postimg.cc/x.png") but not
+    "example.org/postimg.cc", where nothing follows the name, and "*.example.org/*"
+    removes links on any subdomain of example.org but not on example.org itself.
+    Apart from the wildcards the words are plain text, not regular expressions, so
+    characters such as . + ( ) [ ] | mean exactly themselves. A lone * would remove every
+    URL. To hide a single exact address instead, use the
+    <link linkend="ignoring-links">link ignore</link> setting.
+    </para>
+    </refsect1>
+    <refsect1>
+    <title>Default</title>
+    <para> unset (no URLs are removed) </para>
+    </refsect1>
+    </refentry>
+
     <!-- *** MinQuote *** -->
     <refentry id="MinQuote">
 

+ 231 - 0
docs/xml2html.py

@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+"""Convert docs/pisg-doc.xml (DocBook subset) into a single self-contained HTML page.
+
+Usage: python3 docs/xml2html.py [input.xml] [output.html]
+Defaults: docs/pisg-doc.xml -> docs/pisg-doc.html
+"""
+import html
+import re
+import sys
+import textwrap
+import urllib.parse
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+here = Path(__file__).resolve().parent
+src = Path(sys.argv[1]) if len(sys.argv) > 1 else here / "pisg-doc.xml"
+dst = Path(sys.argv[2]) if len(sys.argv) > 2 else here / "pisg-doc.html"
+
+raw = src.read_text(encoding="utf-8", errors="replace")
+# The file starts with a comment before the XML declaration and references a
+# DTD we don't have; drop both so a plain XML parser accepts it.
+raw = re.sub(r"^\s*<!--.*?-->\s*", "", raw, count=1, flags=re.S)
+raw = re.sub(r"<\?xml[^>]*\?>", "", raw, count=1)
+raw = re.sub(r"<!DOCTYPE.*?>", "", raw, count=1, flags=re.S)
+root = ET.fromstring(raw)
+
+esc = html.escape
+
+# id -> display text, used to resolve <xref linkend=...>
+labels = {}
+for el in root.iter():
+    i = el.get("id")
+    if not i:
+        continue
+    if el.tag == "refentry":
+        labels[i] = el.findtext("refnamediv/refname") or i
+    else:
+        labels[i] = (el.findtext("title") or i).strip()
+
+chapter_no = 0
+
+
+def inline(el):
+    """Render an element's mixed content (text + children + tails)."""
+    out = [esc(el.text or "")]
+    for ch in el:
+        out.append(node(ch))
+        out.append(esc(ch.tail or ""))
+    return "".join(out)
+
+
+def node(el):
+    global chapter_no
+    t = el.tag
+    if t == "para":
+        return "<p>" + inline(el).strip() + "</p>\n"
+    if t in ("programlisting", "screen"):
+        text = "".join(el.itertext()).strip("\n")
+        text = re.sub(r"^\s*\n", "", text)
+        return "<pre>" + esc(textwrap.dedent(text).rstrip()) + "</pre>\n"
+    if t == "itemizedlist":
+        return "<ul>\n" + "".join(node(c) for c in el) + "</ul>\n"
+    if t == "listitem":
+        body = "".join(node(c) for c in el)
+        return "<li>" + body + "</li>\n"
+    if t == "xref":
+        i = el.get("linkend", "")
+        return f'<a href="#{esc(i)}">{esc(labels.get(i, i))}</a>'
+    if t == "link":
+        i = el.get("linkend")
+        if i:
+            return f'<a href="#{esc(i)}">{inline(el)}</a>'
+        return inline(el)
+    if t == "ulink":
+        return f'<a href="{esc(el.get("url", ""))}">{inline(el).strip()}</a>'
+    if t in ("command", "filename", "userinput", "prompt"):
+        return "<code>" + inline(el) + "</code>"
+    if t == "emphasis":
+        return "<em>" + inline(el) + "</em>"
+    if t == "chapter":
+        chapter_no += 1
+        title = el.findtext("title") or ""
+        body = "".join(node(c) for c in el if c.tag != "title")
+        return (f'<section class="chapter" id="{esc(el.get("id", ""))}">'
+                f"<h2>{chapter_no}. {esc(title.strip())}</h2>\n{body}</section>\n")
+    if t == "sect1":
+        title = el.findtext("title") or ""
+        body = "".join(node(c) for c in el if c.tag != "title")
+        return (f'<section id="{esc(el.get("id", ""))}">'
+                f"<h3>{esc(title.strip())}</h3>\n{body}</section>\n")
+    if t == "refentry":
+        name = el.findtext("refnamediv/refname") or ""
+        purpose = el.findtext("refnamediv/refpurpose") or ""
+        parts = [f'<section class="option" id="{esc(el.get("id", ""))}">',
+                 f"<h3>{esc(name.strip())}"
+                 f' <span class="purpose">{esc(" ".join(purpose.split()))}</span></h3>\n']
+        syn = el.find("refsynopsisdiv")
+        if syn is not None:
+            parts.append("".join(node(c) for c in syn))
+        for rs in el.findall("refsect1"):
+            parts.append(node(rs))
+        parts.append("</section>\n")
+        return "".join(parts)
+    if t == "refsect1":
+        title = el.findtext("title") or ""
+        body = "".join(node(c) for c in el if c.tag != "title")
+        return f"<h4>{esc(title.strip())}</h4>\n{body}"
+    if t in ("toc", "title", "subtitle", "bookinfo"):
+        return ""
+    # Unknown element: keep its text so nothing silently disappears.
+    return inline(el)
+
+
+def toc():
+    rows = []
+    n = 0
+    for ch in root.findall("chapter"):
+        n += 1
+        rows.append(f'<li><a href="#{esc(ch.get("id", ""))}">{n}. {esc(ch.findtext("title").strip())}</a>')
+        subs = [(s.get("id"), (s.findtext("title") or "").strip()) for s in ch.findall("sect1")]
+        subs += [(r.get("id"), (r.findtext("refnamediv/refname") or "").strip()) for r in ch.findall("refentry")]
+        if subs:
+            if len(subs) > 12:  # long option lists: compact inline index
+                rows.append('<div class="opts">' + " ".join(
+                    f'<a href="#{esc(i)}">{esc(s)}</a>' for i, s in subs) + "</div>")
+            else:
+                rows.append("<ul>" + "".join(
+                    f'<li><a href="#{esc(i)}">{esc(s)}</a></li>' for i, s in subs) + "</ul>")
+        rows.append("</li>")
+    rows.append(example_toc)
+    return "<ul>" + "".join(rows) + "</ul>"
+
+
+body = "".join(node(c) for c in root if c.tag == "chapter")   # numbers the chapters first
+
+# Final chapter: the complete example configuration, straight from pisg.cfg.example
+# (regenerate that with docs/gen-example-config.py). Skipped if the file is absent.
+example_file = here.parent / "pisg.cfg.example"
+example_html = ""
+example_toc = ""
+if example_file.exists():
+    cfg_text = example_file.read_text(encoding="utf-8")
+    chapter_no += 1
+    download = "data:text/plain;charset=utf-8," + urllib.parse.quote(cfg_text)
+    example_toc = (f'<li><a href="#example-config">{chapter_no}. Complete example configuration</a></li>')
+    example_html = f"""<section class="chapter" id="example-config">
+<h2>{chapter_no}. Complete example configuration</h2>
+<p>A ready-to-use <code>pisg.cfg</code> for a channel called <code>#example</code>. It lists every
+option pisg understands, with what it does, so you can copy it and change only the lines marked
+<code>EDIT</code>: the log file, its format, the network name, the output file and your name.
+Options are at pisg's own defaults unless the comment says <em>recommended</em>.</p>
+<ol>
+<li>Copy the file below (or download it) and save it as <code>pisg.cfg</code> next to <code>pisg</code>.</li>
+<li>Change the <code>EDIT</code> values in the <code>&lt;channel&gt;</code> block.</li>
+<li>Run <code>./pisg</code>.</li>
+</ol>
+<div class="codewrap">
+<div class="codebar"><span>pisg.cfg</span>
+<a class="btn" href="{download}" download="pisg.cfg">Download</a>
+<button class="btn copy" type="button" data-target="cfg-example">Copy</button></div>
+<pre id="cfg-example" class="cfg">{esc(cfg_text)}</pre>
+</div>
+</section>
+"""
+
+title = root.findtext("bookinfo/title") or "pisg documentation"
+subtitle = root.findtext("bookinfo/subtitle") or ""
+body += example_html
+
+page = f"""<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{esc(title)}</title>
+<style>
+:root {{ --bg:#fff; --fg:#1d2329; --muted:#5b6672; --line:#d9dee3; --code:#f3f5f7; --link:#0b6b3a; }}
+@media (prefers-color-scheme: dark) {{
+  :root {{ --bg:#14181c; --fg:#e3e8ec; --muted:#98a4af; --line:#2a3138; --code:#1d2329; --link:#5fd08e; }}
+}}
+body {{ margin:0; background:var(--bg); color:var(--fg);
+  font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; }}
+main {{ max-width:60rem; margin:0 auto; padding:2rem 1rem 4rem; }}
+a {{ color:var(--link); }}
+h1 {{ margin-bottom:.2rem; }} .sub {{ color:var(--muted); margin-top:0; }}
+h2 {{ margin-top:3rem; padding-bottom:.3rem; border-bottom:2px solid var(--line); }}
+h3 {{ margin-top:2rem; }} h4 {{ margin:1rem 0 .2rem; color:var(--muted);
+  text-transform:uppercase; font-size:.8rem; letter-spacing:.06em; }}
+.purpose {{ font-weight:400; font-size:.85rem; color:var(--muted); margin-left:.5rem; }}
+section.option {{ border-top:1px solid var(--line); padding-top:.5rem; }}
+pre {{ background:var(--code); border:1px solid var(--line); border-radius:6px;
+  padding:.75rem 1rem; overflow-x:auto; font-size:.85rem; }}
+code {{ background:var(--code); padding:.1em .3em; border-radius:4px; font-size:.9em; }}
+nav {{ border:1px solid var(--line); border-radius:8px; padding:.5rem 1.2rem; margin:1.5rem 0; }}
+/* example config: code block with copy/download bar */
+.codewrap {{ border:1px solid var(--line); border-radius:8px; overflow:hidden; margin:1rem 0; }}
+.codebar {{ display:flex; align-items:center; gap:.5rem; padding:.4rem .75rem; background:var(--code);
+  border-bottom:1px solid var(--line); font-size:.85rem; color:var(--muted); }}
+.codebar span {{ flex:1; font-family:ui-monospace,Menlo,Consolas,monospace; }}
+.btn {{ font:inherit; font-size:.8rem; padding:.25rem .7rem; border:1px solid var(--line); border-radius:6px;
+  background:var(--bg); color:var(--fg); cursor:pointer; text-decoration:none; }}
+.btn:hover {{ border-color:var(--link); color:var(--link); }}
+pre.cfg {{ margin:0; border:0; border-radius:0; max-height:36rem; overflow:auto; font-size:.8rem; line-height:1.45; }}
+nav .opts a {{ display:inline-block; margin:.1rem .6rem .1rem 0; font-size:.85rem; }}
+</style>
+</head>
+<body>
+<main>
+<h1>{esc(title)}</h1>
+<p class="sub">{esc(subtitle)}</p>
+<nav aria-label="Contents">{toc()}</nav>
+{body}</main>
+<script>
+document.querySelectorAll('.copy').forEach(function (b) {{
+  b.addEventListener('click', function () {{
+    var pre = document.getElementById(b.dataset.target), text = pre.textContent;
+    function done(ok) {{ var old = b.textContent; b.textContent = ok ? 'Copied!' : 'Press Ctrl+C';
+      setTimeout(function () {{ b.textContent = old; }}, 1800); }}
+    function fallback() {{ var r = document.createRange(); r.selectNodeContents(pre);
+      var s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
+      var ok = false; try {{ ok = document.execCommand('copy'); }} catch (e) {{}} done(ok); }}
+    if (navigator.clipboard && window.isSecureContext) {{ navigator.clipboard.writeText(text).then(function () {{ done(true); }}, fallback); }}
+    else {{ fallback(); }}
+  }});
+}});
+</script>
+</body>
+</html>
+"""
+dst.write_text(page, encoding="utf-8")
+print(f"wrote {dst} ({len(page)//1024} KiB, {chapter_no} chapters)")

+ 150 - 0
lang.txt

@@ -210,6 +210,81 @@ pisghomepage = "Go to the pisg homepage"
 authorhomepage = "Go to the authors homepage"
 newwindow = "Open in new window:"
 mailto = "Mail to"
+
+# Navigation bar, overview, relations and the other newer sections
+nav_label = "Sections"
+home_label = "All channels"
+overviewtopic = "Channel overview"
+ov_lines = "Lines"
+ov_lines_sub = "chat lines"
+ov_words = "Words"
+ov_nicks = "Nicks"
+ov_days = "Days covered"
+ov_lpd = "Lines per day"
+ov_busy = "Busiest hour"
+ov_quiet = "Quietest hour"
+ov_pct_sub = "[:pct]% of all lines"
+ov_questions = "Questions"
+ov_pct_lines_sub = "[:pct]% of lines"
+ov_links = "Links shared"
+ov_links_sub = "[:distinct] different"
+ov_top = "Top talker"
+ov_joins = "Joins"
+ov_kicks = "Kicks"
+relationstopic = "Who talks to whom"
+rel_intro = "The most active people, joined by a line when they talk to each other: a line that starts with the other person's nick, a mention, or a quick reply. Thicker lines mean more talk. Bots are left out. Click a nick or a line for details."
+rel_nojs = "The interactive map needs JavaScript. The tables below show the same information."
+rel_pick = "Click a nick or a line to see details."
+rel_lines = "lines"
+rel_words = "words"
+rel_active = "Most active in the"
+rel_partners = "Talks most with"
+rel_between = "[:a] and [:b]"
+rel_toward = "[:a] to [:b]"
+rel_strength = "Strength"
+rel_direct = "addressed directly"
+rel_mentions = "mentions"
+rel_replies = "quick replies"
+rel_nolinks = "No links to show"
+rel_time0 = "Night (0-5h)"
+rel_time1 = "Morning (6-11h)"
+rel_time2 = "Afternoon (12-17h)"
+rel_time3 = "Evening (18-23h)"
+pairstopic = "Closest pairs"
+pair = "Pair"
+pair_detail = "How they talk"
+pair_line = "[:a] to [:b]: [:ad] direct, [:am] mentions; back: [:bd] direct, [:bm] mentions; [:replies] quick replies"
+rolestopic = "Social roles"
+role_talkedto = "Most talked to"
+role_talkedto_d = "Others address or mention them the most"
+role_talkedto_v = "[:n] points"
+role_outgoing = "Most outgoing"
+role_outgoing_d = "Aims the largest share of their lines at someone"
+role_outgoing_v = "[:pct]% of their lines"
+role_connector = "Connector"
+role_connector_d = "Has close contact with the most people"
+role_connector_v = "[:n] close contacts"
+role_listener = "Best listener"
+role_listener_d = "Most often answers right after someone else"
+role_listener_v = "[:n] quick replies"
+role_lonewolf = "Lone wolf"
+role_lonewolf_d = "Talks a lot, with the fewest close contacts"
+role_lonewolf_v = "[:lines] lines, [:n] close contacts"
+persontopic = "Time personalities"
+per_night = "Night owl"
+per_morning = "Early bird"
+per_afternoon = "Afternoon regular"
+per_evening = "Evening regular"
+per_range = "Chats between [:from]h and [:to]h"
+concentrationtopic = "Who carries the channel"
+conc_half = "Half of all lines come from just the top [:n]."
+conc_top = "Top [:n]"
+signaturetopic = "Signature words"
+sig_intro = "The word each regular uses a lot and hardly anyone else does."
+sig_word = "Word"
+sig_uses = "Uses"
+sig_share = "Share of all uses"
+ov_hero = "[:lines] lines from [:nicks] nicks over [:days] days"
 </lang>
 
 ### German
@@ -717,6 +792,81 @@ pisghomepage = "Aller sur le site de pisg"
 authorhomepage = "Aller sur le site de l'auteur"
 newwindow = "Ouvrir dans une nouvelle fenêtre :"
 mailto = "Envoyer un mail"
+
+# Barre de sections, aperçu, relations et autres sections plus récentes
+nav_label = "Sections"
+home_label = "Toutes les chaînes"
+overviewtopic = "Aperçu du canal"
+ov_lines = "Lignes"
+ov_lines_sub = "lignes de discussion"
+ov_words = "Mots"
+ov_nicks = "Nicks"
+ov_days = "Jours couverts"
+ov_lpd = "Lignes par jour"
+ov_busy = "Heure de pointe"
+ov_quiet = "Heure creuse"
+ov_pct_sub = "[:pct]% du total"
+ov_questions = "Questions"
+ov_pct_lines_sub = "[:pct]% des lignes"
+ov_links = "Liens partagés"
+ov_links_sub = "[:distinct] différents"
+ov_top = "Le plus bavard"
+ov_joins = "Arrivées"
+ov_kicks = "Expulsions"
+relationstopic = "Qui parle à qui"
+rel_intro = "Les personnes les plus actives, reliées par une ligne quand elles se parlent : une ligne qui commence par le nick de l'autre, une mention, ou une réponse rapide. Plus la ligne est épaisse, plus elles se parlent. Les bots sont exclus. Cliquez sur un nick ou sur une ligne pour voir les détails."
+rel_nojs = "La carte interactive nécessite JavaScript. Les tableaux ci-dessous montrent les mêmes informations."
+rel_pick = "Cliquez sur un nick ou sur une ligne pour voir les détails."
+rel_lines = "lignes"
+rel_words = "mots"
+rel_active = "Surtout actif :"
+rel_partners = "Parle surtout avec"
+rel_between = "[:a] et [:b]"
+rel_toward = "[:a] vers [:b]"
+rel_strength = "Force"
+rel_direct = "lignes adressées"
+rel_mentions = "mentions"
+rel_replies = "réponses rapides"
+rel_nolinks = "Aucun lien à afficher"
+rel_time0 = "la nuit (0-5 h)"
+rel_time1 = "le matin (6-11 h)"
+rel_time2 = "l'après-midi (12-17 h)"
+rel_time3 = "le soir (18-23 h)"
+pairstopic = "Les duos les plus proches"
+pair = "Duo"
+pair_detail = "Comment ils se parlent"
+pair_line = "[:a] vers [:b] : [:ad] adressées, [:am] mentions ; en retour : [:bd] adressées, [:bm] mentions ; [:replies] réponses rapides"
+rolestopic = "Rôles sociaux"
+role_talkedto = "Le plus sollicité"
+role_talkedto_d = "C'est à cette personne que les autres s'adressent ou font référence le plus"
+role_talkedto_v = "[:n] points"
+role_outgoing = "Le plus sociable"
+role_outgoing_d = "Adresse la plus grande part de ses lignes à quelqu'un"
+role_outgoing_v = "[:pct]% de ses lignes"
+role_connector = "Le connecteur"
+role_connector_d = "Est en contact étroit avec le plus de monde"
+role_connector_v = "[:n] contacts étroits"
+role_listener = "Le meilleur auditeur"
+role_listener_d = "Répond le plus souvent juste après quelqu'un d'autre"
+role_listener_v = "[:n] réponses rapides"
+role_lonewolf = "Le loup solitaire"
+role_lonewolf_d = "Parle beaucoup, avec le moins de contacts étroits"
+role_lonewolf_v = "[:lines] lignes, [:n] contacts étroits"
+persontopic = "Personnalités horaires"
+per_night = "Oiseau de nuit"
+per_morning = "Lève-tôt"
+per_afternoon = "Habitué de l'après-midi"
+per_evening = "Habitué du soir"
+per_range = "Discute entre [:from] h et [:to] h"
+concentrationtopic = "Qui porte le canal"
+conc_half = "La moitié de toutes les lignes vient du top [:n] seulement."
+conc_top = "Top [:n]"
+signaturetopic = "Mots signature"
+sig_intro = "Le mot que chaque habitué utilise beaucoup et que presque personne d'autre n'utilise."
+sig_word = "Mot"
+sig_uses = "Utilisations"
+sig_share = "Part de toutes les utilisations"
+ov_hero = "[:lines] lignes de [:nicks] nicks sur [:days] jours"
 </lang>
 
 ### Spanish

+ 247 - 0
layout/amoled.css

@@ -0,0 +1,247 @@
+/* pisg theme: amoled - Pure black, always dark.
+   Generated by layout/build-themes.py, edit the palette there. */
+:root {
+  color-scheme: dark;
+  --bg: #000000;
+  --surface: #0a0b0d;
+  --fg: #e8ebef;
+  --muted: #98a2ae;
+  --line: #1c2027;
+  --line2: #2c323b;
+  --hover: #101216;
+  --mark: #3987e5;
+  --t0: #9085e9;
+  --t1: #c98500;
+  --t2: #199e70;
+  --t3: #d95926;
+  --male: #7aa7ea;
+  --female: #e58ab5;
+  --shadow: 0 18px 32px -14px rgba(0,0,0,.9);
+}
+
+/* pisg output is table markup, so these rules restyle the existing classes (headtext, hicell,
+   tdtop, rankc ...). The generator wraps each section in <div class="card" id="...">. */
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+
+body {
+  margin: 0;
+  padding: 32px 24px 96px;
+  background: var(--bg);
+  color: var(--fg);
+  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  font-size: 15px;
+  line-height: 1.55;
+}
+body > div { max-width: 1040px; margin: 0 auto; text-align: left; }   /* pisg centres it with align=center */
+
+/* Fixed 925/929px table widths are attributes, so plain CSS overrides them. */
+table { border-collapse: separate; border-spacing: 0; max-width: 100%; }
+table[width] { width: 100%; }
+table[width="520"] { width: auto; margin: 10px 0 0; }
+td { color: var(--fg); font-family: inherit; font-size: 14px; text-align: left; padding: 9px 10px; vertical-align: middle; overflow-wrap: anywhere; }   /* long URLs and nicks wrap instead of forcing a scrollbar */
+
+a, a:link, a:visited { color: inherit; font-weight: 600; text-decoration: underline; text-decoration-color: var(--line2); text-underline-offset: 3px; }
+a:hover { text-decoration-color: currentColor; }
+a:focus-visible, summary:focus-visible, button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
+a.background, a.background:link, a.background:visited { color: var(--muted); font-weight: 500; }
+
+/* ---- Masthead: reads like an IRC header, "#channel @ network" ---- */
+.title, #pagetitle1 {
+  margin: 4px 0 8px;
+  font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+  font-size: clamp(24px, 4.6vw, 40px);
+  line-height: 1.15;
+  font-weight: 700;
+  letter-spacing: -0.035em;
+  color: var(--fg);
+  overflow-wrap: anywhere;
+}
+.subtitle { margin: 0 0 18px; color: var(--muted); font-size: 14px; }
+.subtitle b { color: var(--fg); font-weight: 600; }
+
+/* ---- Sections: a rule and space, no boxes ---- */
+.card { margin: 56px 0 0; padding: 20px 0 0; border-top: 1px solid var(--line2); overflow-x: auto; text-align: left; scroll-margin-top: 64px; }
+.card > br { display: none; }
+.pisg-nav + .card { margin-top: 28px; border-top: 0; padding-top: 0; }
+#overview .headlinebg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }   /* the sentence below is its headline; the menu still links here */
+.card > table:first-of-type { margin-bottom: 8px; }
+.headlinebg { background: none; padding: 0; }
+.headtext { margin: 0; padding: 0; border: 0; background: none; color: var(--fg);
+            font-size: 20px; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; text-align: left; }
+.intro { margin: 2px 0 16px; max-width: 68ch; color: var(--muted); }
+
+/* ---- Wide screens: a menu down the left, the page fills the rest ---- */
+body { container-type: inline-size; }
+.nav-side { display: none; }
+@media (min-width: 1000px) {
+  body { padding-left: 272px; padding-right: 28px; }
+  body > div { max-width: 1720px; }
+  .pisg-nav { box-sizing: border-box; position: fixed; top: 0; left: 0; bottom: 0; width: 232px; margin: 0; padding: 26px 12px 24px 24px;
+              border: 0; border-right: 1px solid var(--line2); background: var(--bg); overflow-y: auto; z-index: 30; }
+  .nav-menu { display: none; }
+  .nav-side { display: block; }
+  .nav-brand, .nav-brand:link, .nav-brand:visited { display: block; margin: 0 0 18px; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 18px; font-weight: 700;
+              letter-spacing: -0.02em; text-decoration: none; overflow-wrap: anywhere; }
+  .nav-side-list { margin: 0; padding: 0; list-style: none; }
+  .nav-side-list a, .nav-side-list a:link, .nav-side-list a:visited { display: block; margin-left: -10px; padding: 6px 10px; border-left: 2px solid transparent;
+              color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; }
+  .nav-side-list a:hover { color: var(--fg); background: var(--hover); }
+  .nav-side-list a.active { color: var(--fg); font-weight: 650; border-left-color: var(--mark); }
+  .card { scroll-margin-top: 24px; }
+  .card td:not(.headtext):not(.headlinebg) { padding-left: 8px; padding-right: 8px; }   /* the 10-column nick table just fits beside the menu */
+  .pisg-nav + .card { margin-top: 20px; }
+}
+/* Two short sections share a row when the page is wide enough (measured on the page, so it also holds inside a frame). */
+@container (min-width: 1080px) {
+  body > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 56px; grid-auto-flow: dense; }
+  body > div > * { grid-column: 1 / -1; }
+  body > div > .card.half { grid-column: auto; }
+}
+
+/* Way back to the landing page (option HomeLink) */
+.homebar { margin: 0 0 14px; text-align: left; }
+.homebtn, .homebtn:link, .homebtn:visited { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--line2); border-radius: 6px;
+  background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 600; line-height: 1.3; text-decoration: none; }
+.homebtn:hover { background: var(--hover); border-color: var(--fg); }
+
+/* Column titles */
+.tdtop { background: none; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em;
+         text-transform: uppercase; border-bottom: 1px solid var(--line2); padding: 8px 10px; }
+.tdtop { white-space: nowrap; }   /* column titles stay on one line; the table scrolls instead */
+.tdtop b { font-weight: 600; }
+
+/* Rows: one hairline each */
+.hicell { background: none; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 10px; }
+.hicell10 { background: none; font-size: 12px; border-bottom: 1px solid var(--line); }
+tr:last-child > .hicell { border-bottom: 0; }
+/* pisg writes an inline colour gradient on the top-nicks rows (HiCell/HiCell2); the theme owns colour. */
+td[style*="background-color"] { background: transparent !important; border-bottom: 1px solid var(--line); }
+tr:hover > td[style*="background-color"], tr:hover > .rankc, tr:hover > .hirankc { background: var(--hover) !important; }
+
+.rankc, .hirankc, .rankc10 { background: transparent; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px;
+                              font-weight: 500; text-align: center; border-bottom: 1px solid var(--line); }
+.hirankc { color: var(--fg); font-weight: 700; }
+.rankc, .hirankc, .rankc10 { white-space: nowrap; }
+.rankc10 { text-align: left; }
+/* nicks are set in monospace, as in an IRC client */
+.rankc + td, .hirankc + td { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.small, .asmall { color: var(--muted); font-family: inherit; font-size: 12px; }
+.asmall { text-align: center; }
+
+/* ---- Bar charts (marks: thin, 4px rounded data end, 2px gap between segments) ---- */
+table:not([width]) { width: 100%; table-layout: fixed; margin: 6px 0; }
+td.asmall { padding: 0 2px; line-height: 1.3; vertical-align: bottom; }   /* bars stand on one baseline */
+table:not([width]) tr:first-child > td.asmall { border-bottom: 1px solid var(--line2); }
+td.asmall br { display: none; }
+td.rankc10center, td.hirankc10center { background: none; border: 0; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+                                         font-size: 11px; text-align: center; padding: 6px 0 0; min-width: 0; letter-spacing: -0.02em; }   /* no side padding: 24 narrow columns must not poke out of the table */
+td.hirankc10center { color: var(--fg); font-weight: 700; }
+/* a number over every bar is noise: the peak keeps its label, the rest are in the tooltip */
+td.asmall .v { display: none; }
+td.asmall .v.pk { display: block; color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.rankc10center.skip { visibility: hidden; }
+
+img[id$="-v"] { display: block; width: min(64%, 22px); margin: 0 auto; border-radius: 0; }
+img[id$="-v"]:first-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-v"]:not(:first-of-type) { margin-top: 2px; }
+img[id$="-v"]:only-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-h"] { height: 8px; vertical-align: middle; margin: 0 1px 0 0; border-radius: 0; }
+td[nowrap] img[id$="-h"] { background: var(--mark); margin-right: 0; }   /* lines: one value, one colour; the "when" column keeps the four */
+img[id$="-h"]:first-of-type { border-radius: 4px 0 0 4px; }
+img[id$="-h"]:last-of-type { margin-right: 0; border-radius: 0 4px 4px 0; }
+img[id$="-h"]:only-of-type { border-radius: 4px; }
+td.asmall img[id$="-h"] { margin-right: 6px; }
+table[width="520"] img[id$="-h"] { width: 14px; height: 8px; border-radius: 2px; }   /* legend: a colour key, not a bar */
+table[width="520"] td.asmall { text-align: left; padding: 0 14px 0 0; white-space: nowrap; }
+/* the colour key wraps instead of forcing a sideways scrollbar on a narrow screen */
+table[width="520"] { display: flex; flex-wrap: wrap; gap: 4px 0; width: auto; max-width: 100%; }
+table[width="520"] tbody, table[width="520"] tr { display: contents; }
+img[id="blue-h"],   img[id="blue-v"]   { background: var(--t0); }
+img[id="green-h"],  img[id="green-v"]  { background: var(--t1); }
+img[id="yellow-h"], img[id="yellow-v"] { background: var(--t2); }
+img[id="red-h"],    img[id="red-v"]    { background: var(--t3); }
+
+td[rowspan] img { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; display: block; margin: 0 auto; }
+td[rowspan] img.defpic { width: 26px; height: 26px; opacity: .3; }   /* the shared default picture: a quiet placeholder */
+
+/* ranked tables: one left edge, and no stray rule under the last row */
+tr:first-child > td:not([class]):first-child { border-bottom: 1px solid var(--line2); }
+tr:last-child > .rankc, tr:last-child > .hirankc, tr:last-child > td[style*="background-color"] { border-bottom: 0; }
+.rankc, .hirankc { width: 44px; }
+td[style*="background-color"] { white-space: nowrap; }          /* "3 days ago" stays on one line ... */
+td[style*="background-color"].quote { white-space: normal; }    /* ... only the quote may wrap */
+.male, .male a { color: var(--male); }
+.female, .female a { color: var(--female); }
+.bot, .bot a { color: var(--muted); font-style: italic; }
+
+/* single-series bars */
+.bar { display: inline-block; vertical-align: middle; width: 140px; max-width: 100%; height: 8px; border-radius: 4px; background: var(--line); overflow: hidden; }
+.bar span { display: block; height: 100%; border-radius: 4px; background: var(--mark); opacity: 1; }
+.sigword { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+
+/* "These didn't make it to the top:" is a bare <b><i> under the table; give it the same voice as a column title */
+.card > b { display: block; margin: 26px 0 4px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.card > b > i { font-style: normal; }
+
+#totallines { margin-top: 56px; padding-top: 14px; border-top: 1px solid var(--line2); color: var(--muted); font-size: 12px; }
+#footer { margin-top: 2px; color: var(--muted); }
+#footer .small { font-size: 12px; }
+::selection { background: var(--fg); color: var(--bg); }
+
+/* ---- Section menu: one slim bar, one panel ---- */
+.pisg-nav { background: var(--bg); border-bottom-color: var(--line2); margin-bottom: 0; }
+.nav-current { color: var(--fg); font-weight: 650; }
+.nav-toggle { color: var(--muted); opacity: 1; }
+.nav-list { background: var(--surface); border-color: var(--line2); box-shadow: var(--shadow); }
+.nav-list a, .nav-list a:link, .nav-list a:visited { color: var(--fg); font-weight: 500; text-decoration: none; }
+.nav-list a:hover { background: var(--hover); }
+.nav-list a.active { font-weight: 700; }
+
+/* ---- Overview: a sentence and a list of facts, not a wall of tiles ---- */
+.hero { margin: 2px 0 22px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 650; letter-spacing: -0.02em; text-wrap: balance; }
+.hero b { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 700; letter-spacing: -0.05em; }
+.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 56px; margin: 0; }
+.fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 11px 0; border-bottom: 1px solid var(--line); }
+.fact dt { color: var(--muted); }
+.fact dd { margin: 0; text-align: right; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; font-variant-numeric: tabular-nums; }
+.fact dd small { display: block; color: var(--muted); font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 12px; font-weight: 400; }
+
+/* ---- Roles, personalities: columns split by a rule, no boxes ---- */
+.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 28px 32px; margin: 8px 0 0; }
+.minicard { display: flex; flex-direction: column; gap: 3px; padding: 12px 0 0; border: 0; border-top: 1px solid var(--line2); border-radius: 0; background: none; text-align: left; }
+.mc-title { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.mc-name { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 19px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; }
+.mc-desc { color: var(--muted); font-size: 13px; }
+.mc-value { margin-top: 6px; font-size: 13px; font-weight: 600; }
+.mc-list { margin: 8px 0 0; padding: 0; list-style: none; }
+.mc-list li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 0; border-top: 1px solid var(--line); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 13px; }
+.mc-list span { color: var(--muted); }
+.minicard.t0 { border-top: 3px solid var(--t0); } .minicard.t1 { border-top: 3px solid var(--t1); }
+.minicard.t2 { border-top: 3px solid var(--t2); } .minicard.t3 { border-top: 3px solid var(--t3); }
+
+/* ---- Relation map: the one lifted surface ---- */
+.relmap-canvas svg { box-sizing: border-box; background: var(--surface); border: 1px solid var(--line2); border-radius: 8px; }   /* width:100% must include the border */
+.relmap-info { background: none; border: 0; border-top: 1px solid var(--line2); border-radius: 0; padding: 12px 0 0; }
+.relmap-legend { color: var(--muted); }
+.ri-title { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; color: var(--fg); }
+.ri-sub, .ri-num { color: var(--muted); opacity: 1; }
+.ri-list li { border-top-color: var(--line); }
+.ri-link { color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; text-decoration-color: var(--line2); }
+.lg0 i, .rm-node.t0 circle { background: var(--t0); fill: var(--t0); } .lg1 i, .rm-node.t1 circle { background: var(--t1); fill: var(--t1); }
+.lg2 i, .rm-node.t2 circle { background: var(--t2); fill: var(--t2); } .lg3 i, .rm-node.t3 circle { background: var(--t3); fill: var(--t3); }
+.rm-line { stroke: var(--muted); }
+.rm-node circle { stroke: var(--surface); }
+.rm-node text { fill: var(--fg); stroke: var(--surface); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
+.rm-edge.sel .rm-line { stroke: var(--fg); }
+.rm-node:focus-visible circle, .rm-node.sel circle { stroke: var(--fg); }
+
+@media (max-width: 720px) {
+  body { padding: 20px 16px 72px; font-size: 14px; }
+  .hr.rankc10center:nth-child(even) { visibility: hidden; }     /* every second hour label on a phone */
+  td { padding: 7px 6px; font-size: 13px; }
+  .bar { width: 64px; }
+  .card { margin-top: 44px; }
+  .facts { grid-template-columns: 1fr; }
+  td[rowspan] img { width: 30px; height: 30px; }
+}

+ 313 - 0
layout/build-themes.py

@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+"""Generate the modern pisg themes (layout/<name>.css) from one shared stylesheet.
+
+pisg inlines exactly one CSS file into the page, so every theme must be self-contained. The
+shared rules live here once and each theme is just a palette.
+
+Design: ink on cool paper, and colour reserved for data. The interface is monochrome; the four
+time-of-day colours (night violet, morning yellow, afternoon aqua, evening orange) are the only
+hues on the page besides one series colour. They were checked with the dataviz palette validator
+in light, dark and on pure black (colour-blind separation, contrast, lightness band), so they
+can be trusted. Nicks and figures are set in monospace, like an IRC client. Sections are
+separated by a hairline and space, not boxed.
+
+Usage: python3 layout/build-themes.py      (rewrites the generated .css files)
+Add a theme: add an entry to THEMES below and re-run, then use <set ColorScheme="yourtheme">.
+The old themes (default, darkred, ocean, ...) are untouched.
+"""
+from pathlib import Path
+
+out_dir = Path(__file__).resolve().parent
+
+# Palette keys
+#  bg page | surface panels (map canvas, menu) | fg text | muted secondary text
+#  line hairlines | line2 stronger rules | hover row hover | mark single-series colour
+#  t0..t3 hours 0-5 / 6-11 / 12-17 / 18-23 | male / female nick colours | shadow menu shadow
+LIGHT = dict(bg="#f6f7f9", surface="#ffffff", fg="#171b21", muted="#59626d", line="#e0e3e8",
+             line2="#c6ccd4", hover="#eceff3", mark="#2a78d6",
+             t0="#4a3aa7", t1="#eda100", t2="#1baf7a", t3="#eb6834",
+             male="#2a5fb0", female="#b03a72",
+             shadow="0 18px 32px -14px rgba(20,24,30,.28)")
+DARK = dict(bg="#0f1318", surface="#171c23", fg="#e8ebef", muted="#98a2ae", line="#252c35",
+            line2="#38414c", hover="#1b2129", mark="#3987e5",
+            t0="#9085e9", t1="#c98500", t2="#199e70", t3="#d95926",
+            male="#7aa7ea", female="#e58ab5",
+            shadow="0 18px 32px -14px rgba(0,0,0,.6)")
+
+SANS = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
+MONO = 'ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace'
+
+THEMES = {
+    # Follows the visitor's OS setting: light by day, dark by night.
+    "modern": dict(desc="Ink on paper, colour only for data; follows the visitor's light/dark setting",
+                   scheme="light dark", light=LIGHT, dark=DARK, sans=SANS, mono=MONO),
+    "midnight": dict(desc="Blue-black, always dark", scheme="dark", light=DARK, dark=None,
+                     sans=SANS, mono=MONO),
+    "amoled": dict(desc="Pure black, always dark", scheme="dark", dark=None,
+                   light=dict(DARK, bg="#000000", surface="#0a0b0d", line="#1c2027", line2="#2c323b",
+                              hover="#101216", shadow="0 18px 32px -14px rgba(0,0,0,.9)"),
+                   sans=SANS, mono=MONO),
+    "terminal": dict(desc="Everything in monospace, green on black, like a console",
+                     scheme="dark", dark=None,
+                     light=dict(DARK, bg="#060a07", surface="#0b120d", fg="#b9f3c4", muted="#6ea77a",
+                                line="#17301c", line2="#24492b", hover="#0f1a12", mark="#3ddc84",
+                                male="#7fd6ff", female="#ff9ad0"),
+                     sans=MONO, mono=MONO),
+    # Red and white, for #canada.
+    "canada": dict(desc="Warm paper with a maple-red series colour; dark variant follows the visitor's setting",
+                   scheme="light dark", sans=SANS, mono=MONO,
+                   light=dict(LIGHT, bg="#faf6f5", line="#e8dcdb", line2="#d3c2c1", hover="#f3eae9", mark="#c8102e"),
+                   dark=dict(DARK, bg="#150d0e", surface="#1e1416", line="#33232a", line2="#4a3239", hover="#231719", mark="#ff5a6d")),
+}
+
+KEYS = ["bg", "surface", "fg", "muted", "line", "line2", "hover", "mark",
+        "t0", "t1", "t2", "t3", "male", "female", "shadow"]
+
+
+def vars_block(p, indent="  "):
+    return "\n".join(f"{indent}--{k}: {p[k]};" for k in KEYS)
+
+
+BASE = r"""
+/* pisg output is table markup, so these rules restyle the existing classes (headtext, hicell,
+   tdtop, rankc ...). The generator wraps each section in <div class="card" id="...">. */
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+
+body {
+  margin: 0;
+  padding: 32px 24px 96px;
+  background: var(--bg);
+  color: var(--fg);
+  font-family: __SANS__;
+  font-size: 15px;
+  line-height: 1.55;
+}
+body > div { max-width: 1040px; margin: 0 auto; text-align: left; }   /* pisg centres it with align=center */
+
+/* Fixed 925/929px table widths are attributes, so plain CSS overrides them. */
+table { border-collapse: separate; border-spacing: 0; max-width: 100%; }
+table[width] { width: 100%; }
+table[width="520"] { width: auto; margin: 10px 0 0; }
+td { color: var(--fg); font-family: inherit; font-size: 14px; text-align: left; padding: 9px 10px; vertical-align: middle; overflow-wrap: anywhere; }   /* long URLs and nicks wrap instead of forcing a scrollbar */
+
+a, a:link, a:visited { color: inherit; font-weight: 600; text-decoration: underline; text-decoration-color: var(--line2); text-underline-offset: 3px; }
+a:hover { text-decoration-color: currentColor; }
+a:focus-visible, summary:focus-visible, button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
+a.background, a.background:link, a.background:visited { color: var(--muted); font-weight: 500; }
+
+/* ---- Masthead: reads like an IRC header, "#channel @ network" ---- */
+.title, #pagetitle1 {
+  margin: 4px 0 8px;
+  font-family: __MONO__;
+  font-size: clamp(24px, 4.6vw, 40px);
+  line-height: 1.15;
+  font-weight: 700;
+  letter-spacing: -0.035em;
+  color: var(--fg);
+  overflow-wrap: anywhere;
+}
+.subtitle { margin: 0 0 18px; color: var(--muted); font-size: 14px; }
+.subtitle b { color: var(--fg); font-weight: 600; }
+
+/* ---- Sections: a rule and space, no boxes ---- */
+.card { margin: 56px 0 0; padding: 20px 0 0; border-top: 1px solid var(--line2); overflow-x: auto; text-align: left; scroll-margin-top: 64px; }
+.card > br { display: none; }
+.pisg-nav + .card { margin-top: 28px; border-top: 0; padding-top: 0; }
+#overview .headlinebg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }   /* the sentence below is its headline; the menu still links here */
+.card > table:first-of-type { margin-bottom: 8px; }
+.headlinebg { background: none; padding: 0; }
+.headtext { margin: 0; padding: 0; border: 0; background: none; color: var(--fg);
+            font-size: 20px; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; text-align: left; }
+.intro { margin: 2px 0 16px; max-width: 68ch; color: var(--muted); }
+
+/* ---- Wide screens: a menu down the left, the page fills the rest ---- */
+body { container-type: inline-size; }
+.nav-side { display: none; }
+@media (min-width: 1000px) {
+  body { padding-left: 272px; padding-right: 28px; }
+  body > div { max-width: 1720px; }
+  .pisg-nav { box-sizing: border-box; position: fixed; top: 0; left: 0; bottom: 0; width: 232px; margin: 0; padding: 26px 12px 24px 24px;
+              border: 0; border-right: 1px solid var(--line2); background: var(--bg); overflow-y: auto; z-index: 30; }
+  .nav-menu { display: none; }
+  .nav-side { display: block; }
+  .nav-brand, .nav-brand:link, .nav-brand:visited { display: block; margin: 0 0 18px; font-family: __MONO__; font-size: 18px; font-weight: 700;
+              letter-spacing: -0.02em; text-decoration: none; overflow-wrap: anywhere; }
+  .nav-side-list { margin: 0; padding: 0; list-style: none; }
+  .nav-side-list a, .nav-side-list a:link, .nav-side-list a:visited { display: block; margin-left: -10px; padding: 6px 10px; border-left: 2px solid transparent;
+              color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; }
+  .nav-side-list a:hover { color: var(--fg); background: var(--hover); }
+  .nav-side-list a.active { color: var(--fg); font-weight: 650; border-left-color: var(--mark); }
+  .card { scroll-margin-top: 24px; }
+  .card td:not(.headtext):not(.headlinebg) { padding-left: 8px; padding-right: 8px; }   /* the 10-column nick table just fits beside the menu */
+  .pisg-nav + .card { margin-top: 20px; }
+}
+/* Two short sections share a row when the page is wide enough (measured on the page, so it also holds inside a frame). */
+@container (min-width: 1080px) {
+  body > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 56px; grid-auto-flow: dense; }
+  body > div > * { grid-column: 1 / -1; }
+  body > div > .card.half { grid-column: auto; }
+}
+
+/* Way back to the landing page (option HomeLink) */
+.homebar { margin: 0 0 14px; text-align: left; }
+.homebtn, .homebtn:link, .homebtn:visited { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--line2); border-radius: 6px;
+  background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 600; line-height: 1.3; text-decoration: none; }
+.homebtn:hover { background: var(--hover); border-color: var(--fg); }
+
+/* Column titles */
+.tdtop { background: none; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em;
+         text-transform: uppercase; border-bottom: 1px solid var(--line2); padding: 8px 10px; }
+.tdtop { white-space: nowrap; }   /* column titles stay on one line; the table scrolls instead */
+.tdtop b { font-weight: 600; }
+
+/* Rows: one hairline each */
+.hicell { background: none; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 10px; }
+.hicell10 { background: none; font-size: 12px; border-bottom: 1px solid var(--line); }
+tr:last-child > .hicell { border-bottom: 0; }
+/* pisg writes an inline colour gradient on the top-nicks rows (HiCell/HiCell2); the theme owns colour. */
+td[style*="background-color"] { background: transparent !important; border-bottom: 1px solid var(--line); }
+tr:hover > td[style*="background-color"], tr:hover > .rankc, tr:hover > .hirankc { background: var(--hover) !important; }
+
+.rankc, .hirankc, .rankc10 { background: transparent; color: var(--muted); font-family: __MONO__; font-size: 12px;
+                              font-weight: 500; text-align: center; border-bottom: 1px solid var(--line); }
+.hirankc { color: var(--fg); font-weight: 700; }
+.rankc, .hirankc, .rankc10 { white-space: nowrap; }
+.rankc10 { text-align: left; }
+/* nicks are set in monospace, as in an IRC client */
+.rankc + td, .hirankc + td { font-family: __MONO__; font-weight: 600; }
+.small, .asmall { color: var(--muted); font-family: inherit; font-size: 12px; }
+.asmall { text-align: center; }
+
+/* ---- Bar charts (marks: thin, 4px rounded data end, 2px gap between segments) ---- */
+table:not([width]) { width: 100%; table-layout: fixed; margin: 6px 0; }
+td.asmall { padding: 0 2px; line-height: 1.3; vertical-align: bottom; }   /* bars stand on one baseline */
+table:not([width]) tr:first-child > td.asmall { border-bottom: 1px solid var(--line2); }
+td.asmall br { display: none; }
+td.rankc10center, td.hirankc10center { background: none; border: 0; color: var(--muted); font-family: __MONO__;
+                                         font-size: 11px; text-align: center; padding: 6px 0 0; min-width: 0; letter-spacing: -0.02em; }   /* no side padding: 24 narrow columns must not poke out of the table */
+td.hirankc10center { color: var(--fg); font-weight: 700; }
+/* a number over every bar is noise: the peak keeps its label, the rest are in the tooltip */
+td.asmall .v { display: none; }
+td.asmall .v.pk { display: block; color: var(--fg); font-family: __MONO__; font-weight: 600; }
+.rankc10center.skip { visibility: hidden; }
+
+img[id$="-v"] { display: block; width: min(64%, 22px); margin: 0 auto; border-radius: 0; }
+img[id$="-v"]:first-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-v"]:not(:first-of-type) { margin-top: 2px; }
+img[id$="-v"]:only-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-h"] { height: 8px; vertical-align: middle; margin: 0 1px 0 0; border-radius: 0; }
+td[nowrap] img[id$="-h"] { background: var(--mark); margin-right: 0; }   /* lines: one value, one colour; the "when" column keeps the four */
+img[id$="-h"]:first-of-type { border-radius: 4px 0 0 4px; }
+img[id$="-h"]:last-of-type { margin-right: 0; border-radius: 0 4px 4px 0; }
+img[id$="-h"]:only-of-type { border-radius: 4px; }
+td.asmall img[id$="-h"] { margin-right: 6px; }
+table[width="520"] img[id$="-h"] { width: 14px; height: 8px; border-radius: 2px; }   /* legend: a colour key, not a bar */
+table[width="520"] td.asmall { text-align: left; padding: 0 14px 0 0; white-space: nowrap; }
+/* the colour key wraps instead of forcing a sideways scrollbar on a narrow screen */
+table[width="520"] { display: flex; flex-wrap: wrap; gap: 4px 0; width: auto; max-width: 100%; }
+table[width="520"] tbody, table[width="520"] tr { display: contents; }
+img[id="blue-h"],   img[id="blue-v"]   { background: var(--t0); }
+img[id="green-h"],  img[id="green-v"]  { background: var(--t1); }
+img[id="yellow-h"], img[id="yellow-v"] { background: var(--t2); }
+img[id="red-h"],    img[id="red-v"]    { background: var(--t3); }
+
+td[rowspan] img { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; display: block; margin: 0 auto; }
+td[rowspan] img.defpic { width: 26px; height: 26px; opacity: .3; }   /* the shared default picture: a quiet placeholder */
+
+/* ranked tables: one left edge, and no stray rule under the last row */
+tr:first-child > td:not([class]):first-child { border-bottom: 1px solid var(--line2); }
+tr:last-child > .rankc, tr:last-child > .hirankc, tr:last-child > td[style*="background-color"] { border-bottom: 0; }
+.rankc, .hirankc { width: 44px; }
+td[style*="background-color"] { white-space: nowrap; }          /* "3 days ago" stays on one line ... */
+td[style*="background-color"].quote { white-space: normal; }    /* ... only the quote may wrap */
+.male, .male a { color: var(--male); }
+.female, .female a { color: var(--female); }
+.bot, .bot a { color: var(--muted); font-style: italic; }
+
+/* single-series bars */
+.bar { display: inline-block; vertical-align: middle; width: 140px; max-width: 100%; height: 8px; border-radius: 4px; background: var(--line); overflow: hidden; }
+.bar span { display: block; height: 100%; border-radius: 4px; background: var(--mark); opacity: 1; }
+.sigword { font-family: __MONO__; font-weight: 600; }
+
+/* "These didn't make it to the top:" is a bare <b><i> under the table; give it the same voice as a column title */
+.card > b { display: block; margin: 26px 0 4px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.card > b > i { font-style: normal; }
+
+#totallines { margin-top: 56px; padding-top: 14px; border-top: 1px solid var(--line2); color: var(--muted); font-size: 12px; }
+#footer { margin-top: 2px; color: var(--muted); }
+#footer .small { font-size: 12px; }
+::selection { background: var(--fg); color: var(--bg); }
+
+/* ---- Section menu: one slim bar, one panel ---- */
+.pisg-nav { background: var(--bg); border-bottom-color: var(--line2); margin-bottom: 0; }
+.nav-current { color: var(--fg); font-weight: 650; }
+.nav-toggle { color: var(--muted); opacity: 1; }
+.nav-list { background: var(--surface); border-color: var(--line2); box-shadow: var(--shadow); }
+.nav-list a, .nav-list a:link, .nav-list a:visited { color: var(--fg); font-weight: 500; text-decoration: none; }
+.nav-list a:hover { background: var(--hover); }
+.nav-list a.active { font-weight: 700; }
+
+/* ---- Overview: a sentence and a list of facts, not a wall of tiles ---- */
+.hero { margin: 2px 0 22px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 650; letter-spacing: -0.02em; text-wrap: balance; }
+.hero b { font-family: __MONO__; font-weight: 700; letter-spacing: -0.05em; }
+.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 56px; margin: 0; }
+.fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 11px 0; border-bottom: 1px solid var(--line); }
+.fact dt { color: var(--muted); }
+.fact dd { margin: 0; text-align: right; font-family: __MONO__; font-weight: 600; font-variant-numeric: tabular-nums; }
+.fact dd small { display: block; color: var(--muted); font-family: __SANS__; font-size: 12px; font-weight: 400; }
+
+/* ---- Roles, personalities: columns split by a rule, no boxes ---- */
+.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 28px 32px; margin: 8px 0 0; }
+.minicard { display: flex; flex-direction: column; gap: 3px; padding: 12px 0 0; border: 0; border-top: 1px solid var(--line2); border-radius: 0; background: none; text-align: left; }
+.mc-title { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.mc-name { font-family: __MONO__; font-size: 19px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; }
+.mc-desc { color: var(--muted); font-size: 13px; }
+.mc-value { margin-top: 6px; font-size: 13px; font-weight: 600; }
+.mc-list { margin: 8px 0 0; padding: 0; list-style: none; }
+.mc-list li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 0; border-top: 1px solid var(--line); font-family: __MONO__; font-size: 13px; }
+.mc-list span { color: var(--muted); }
+.minicard.t0 { border-top: 3px solid var(--t0); } .minicard.t1 { border-top: 3px solid var(--t1); }
+.minicard.t2 { border-top: 3px solid var(--t2); } .minicard.t3 { border-top: 3px solid var(--t3); }
+
+/* ---- Relation map: the one lifted surface ---- */
+.relmap-canvas svg { box-sizing: border-box; background: var(--surface); border: 1px solid var(--line2); border-radius: 8px; }   /* width:100% must include the border */
+.relmap-info { background: none; border: 0; border-top: 1px solid var(--line2); border-radius: 0; padding: 12px 0 0; }
+.relmap-legend { color: var(--muted); }
+.ri-title { font-family: __MONO__; color: var(--fg); }
+.ri-sub, .ri-num { color: var(--muted); opacity: 1; }
+.ri-list li { border-top-color: var(--line); }
+.ri-link { color: var(--fg); font-family: __MONO__; text-decoration-color: var(--line2); }
+.lg0 i, .rm-node.t0 circle { background: var(--t0); fill: var(--t0); } .lg1 i, .rm-node.t1 circle { background: var(--t1); fill: var(--t1); }
+.lg2 i, .rm-node.t2 circle { background: var(--t2); fill: var(--t2); } .lg3 i, .rm-node.t3 circle { background: var(--t3); fill: var(--t3); }
+.rm-line { stroke: var(--muted); }
+.rm-node circle { stroke: var(--surface); }
+.rm-node text { fill: var(--fg); stroke: var(--surface); font-family: __MONO__; font-size: 12px; }
+.rm-edge.sel .rm-line { stroke: var(--fg); }
+.rm-node:focus-visible circle, .rm-node.sel circle { stroke: var(--fg); }
+
+@media (max-width: 720px) {
+  body { padding: 20px 16px 72px; font-size: 14px; }
+  .hr.rankc10center:nth-child(even) { visibility: hidden; }     /* every second hour label on a phone */
+  td { padding: 7px 6px; font-size: 13px; }
+  .bar { width: 64px; }
+  .card { margin-top: 44px; }
+  .facts { grid-template-columns: 1fr; }
+  td[rowspan] img { width: 30px; height: 30px; }
+}
+"""
+
+
+def theme_css(name, t):
+    head = f"/* pisg theme: {name} - {t['desc']}.\n   Generated by layout/build-themes.py, edit the palette there. */\n"
+    root = f":root {{\n  color-scheme: {t['scheme']};\n{vars_block(t['light'])}\n}}\n"
+    if t["dark"]:
+        root += ("@media (prefers-color-scheme: dark) {\n  :root {\n"
+                 + vars_block(t["dark"], "    ") + "\n  }\n}\n")
+    return head + root + BASE.replace("__SANS__", t["sans"]).replace("__MONO__", t["mono"])
+
+
+for name, t in THEMES.items():
+    path = out_dir / f"{name}.css"
+    path.write_text(theme_css(name, t), encoding="utf-8")
+    print(f"wrote {path.name} ({path.stat().st_size} bytes) - {t['desc']}")

+ 266 - 0
layout/canada.css

@@ -0,0 +1,266 @@
+/* pisg theme: canada - Warm paper with a maple-red series colour; dark variant follows the visitor's setting.
+   Generated by layout/build-themes.py, edit the palette there. */
+:root {
+  color-scheme: light dark;
+  --bg: #faf6f5;
+  --surface: #ffffff;
+  --fg: #171b21;
+  --muted: #59626d;
+  --line: #e8dcdb;
+  --line2: #d3c2c1;
+  --hover: #f3eae9;
+  --mark: #c8102e;
+  --t0: #4a3aa7;
+  --t1: #eda100;
+  --t2: #1baf7a;
+  --t3: #eb6834;
+  --male: #2a5fb0;
+  --female: #b03a72;
+  --shadow: 0 18px 32px -14px rgba(20,24,30,.28);
+}
+@media (prefers-color-scheme: dark) {
+  :root {
+    --bg: #150d0e;
+    --surface: #1e1416;
+    --fg: #e8ebef;
+    --muted: #98a2ae;
+    --line: #33232a;
+    --line2: #4a3239;
+    --hover: #231719;
+    --mark: #ff5a6d;
+    --t0: #9085e9;
+    --t1: #c98500;
+    --t2: #199e70;
+    --t3: #d95926;
+    --male: #7aa7ea;
+    --female: #e58ab5;
+    --shadow: 0 18px 32px -14px rgba(0,0,0,.6);
+  }
+}
+
+/* pisg output is table markup, so these rules restyle the existing classes (headtext, hicell,
+   tdtop, rankc ...). The generator wraps each section in <div class="card" id="...">. */
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+
+body {
+  margin: 0;
+  padding: 32px 24px 96px;
+  background: var(--bg);
+  color: var(--fg);
+  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  font-size: 15px;
+  line-height: 1.55;
+}
+body > div { max-width: 1040px; margin: 0 auto; text-align: left; }   /* pisg centres it with align=center */
+
+/* Fixed 925/929px table widths are attributes, so plain CSS overrides them. */
+table { border-collapse: separate; border-spacing: 0; max-width: 100%; }
+table[width] { width: 100%; }
+table[width="520"] { width: auto; margin: 10px 0 0; }
+td { color: var(--fg); font-family: inherit; font-size: 14px; text-align: left; padding: 9px 10px; vertical-align: middle; overflow-wrap: anywhere; }   /* long URLs and nicks wrap instead of forcing a scrollbar */
+
+a, a:link, a:visited { color: inherit; font-weight: 600; text-decoration: underline; text-decoration-color: var(--line2); text-underline-offset: 3px; }
+a:hover { text-decoration-color: currentColor; }
+a:focus-visible, summary:focus-visible, button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
+a.background, a.background:link, a.background:visited { color: var(--muted); font-weight: 500; }
+
+/* ---- Masthead: reads like an IRC header, "#channel @ network" ---- */
+.title, #pagetitle1 {
+  margin: 4px 0 8px;
+  font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+  font-size: clamp(24px, 4.6vw, 40px);
+  line-height: 1.15;
+  font-weight: 700;
+  letter-spacing: -0.035em;
+  color: var(--fg);
+  overflow-wrap: anywhere;
+}
+.subtitle { margin: 0 0 18px; color: var(--muted); font-size: 14px; }
+.subtitle b { color: var(--fg); font-weight: 600; }
+
+/* ---- Sections: a rule and space, no boxes ---- */
+.card { margin: 56px 0 0; padding: 20px 0 0; border-top: 1px solid var(--line2); overflow-x: auto; text-align: left; scroll-margin-top: 64px; }
+.card > br { display: none; }
+.pisg-nav + .card { margin-top: 28px; border-top: 0; padding-top: 0; }
+#overview .headlinebg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }   /* the sentence below is its headline; the menu still links here */
+.card > table:first-of-type { margin-bottom: 8px; }
+.headlinebg { background: none; padding: 0; }
+.headtext { margin: 0; padding: 0; border: 0; background: none; color: var(--fg);
+            font-size: 20px; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; text-align: left; }
+.intro { margin: 2px 0 16px; max-width: 68ch; color: var(--muted); }
+
+/* ---- Wide screens: a menu down the left, the page fills the rest ---- */
+body { container-type: inline-size; }
+.nav-side { display: none; }
+@media (min-width: 1000px) {
+  body { padding-left: 272px; padding-right: 28px; }
+  body > div { max-width: 1720px; }
+  .pisg-nav { box-sizing: border-box; position: fixed; top: 0; left: 0; bottom: 0; width: 232px; margin: 0; padding: 26px 12px 24px 24px;
+              border: 0; border-right: 1px solid var(--line2); background: var(--bg); overflow-y: auto; z-index: 30; }
+  .nav-menu { display: none; }
+  .nav-side { display: block; }
+  .nav-brand, .nav-brand:link, .nav-brand:visited { display: block; margin: 0 0 18px; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 18px; font-weight: 700;
+              letter-spacing: -0.02em; text-decoration: none; overflow-wrap: anywhere; }
+  .nav-side-list { margin: 0; padding: 0; list-style: none; }
+  .nav-side-list a, .nav-side-list a:link, .nav-side-list a:visited { display: block; margin-left: -10px; padding: 6px 10px; border-left: 2px solid transparent;
+              color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; }
+  .nav-side-list a:hover { color: var(--fg); background: var(--hover); }
+  .nav-side-list a.active { color: var(--fg); font-weight: 650; border-left-color: var(--mark); }
+  .card { scroll-margin-top: 24px; }
+  .card td:not(.headtext):not(.headlinebg) { padding-left: 8px; padding-right: 8px; }   /* the 10-column nick table just fits beside the menu */
+  .pisg-nav + .card { margin-top: 20px; }
+}
+/* Two short sections share a row when the page is wide enough (measured on the page, so it also holds inside a frame). */
+@container (min-width: 1080px) {
+  body > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 56px; grid-auto-flow: dense; }
+  body > div > * { grid-column: 1 / -1; }
+  body > div > .card.half { grid-column: auto; }
+}
+
+/* Way back to the landing page (option HomeLink) */
+.homebar { margin: 0 0 14px; text-align: left; }
+.homebtn, .homebtn:link, .homebtn:visited { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--line2); border-radius: 6px;
+  background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 600; line-height: 1.3; text-decoration: none; }
+.homebtn:hover { background: var(--hover); border-color: var(--fg); }
+
+/* Column titles */
+.tdtop { background: none; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em;
+         text-transform: uppercase; border-bottom: 1px solid var(--line2); padding: 8px 10px; }
+.tdtop { white-space: nowrap; }   /* column titles stay on one line; the table scrolls instead */
+.tdtop b { font-weight: 600; }
+
+/* Rows: one hairline each */
+.hicell { background: none; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 10px; }
+.hicell10 { background: none; font-size: 12px; border-bottom: 1px solid var(--line); }
+tr:last-child > .hicell { border-bottom: 0; }
+/* pisg writes an inline colour gradient on the top-nicks rows (HiCell/HiCell2); the theme owns colour. */
+td[style*="background-color"] { background: transparent !important; border-bottom: 1px solid var(--line); }
+tr:hover > td[style*="background-color"], tr:hover > .rankc, tr:hover > .hirankc { background: var(--hover) !important; }
+
+.rankc, .hirankc, .rankc10 { background: transparent; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px;
+                              font-weight: 500; text-align: center; border-bottom: 1px solid var(--line); }
+.hirankc { color: var(--fg); font-weight: 700; }
+.rankc, .hirankc, .rankc10 { white-space: nowrap; }
+.rankc10 { text-align: left; }
+/* nicks are set in monospace, as in an IRC client */
+.rankc + td, .hirankc + td { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.small, .asmall { color: var(--muted); font-family: inherit; font-size: 12px; }
+.asmall { text-align: center; }
+
+/* ---- Bar charts (marks: thin, 4px rounded data end, 2px gap between segments) ---- */
+table:not([width]) { width: 100%; table-layout: fixed; margin: 6px 0; }
+td.asmall { padding: 0 2px; line-height: 1.3; vertical-align: bottom; }   /* bars stand on one baseline */
+table:not([width]) tr:first-child > td.asmall { border-bottom: 1px solid var(--line2); }
+td.asmall br { display: none; }
+td.rankc10center, td.hirankc10center { background: none; border: 0; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+                                         font-size: 11px; text-align: center; padding: 6px 0 0; min-width: 0; letter-spacing: -0.02em; }   /* no side padding: 24 narrow columns must not poke out of the table */
+td.hirankc10center { color: var(--fg); font-weight: 700; }
+/* a number over every bar is noise: the peak keeps its label, the rest are in the tooltip */
+td.asmall .v { display: none; }
+td.asmall .v.pk { display: block; color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.rankc10center.skip { visibility: hidden; }
+
+img[id$="-v"] { display: block; width: min(64%, 22px); margin: 0 auto; border-radius: 0; }
+img[id$="-v"]:first-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-v"]:not(:first-of-type) { margin-top: 2px; }
+img[id$="-v"]:only-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-h"] { height: 8px; vertical-align: middle; margin: 0 1px 0 0; border-radius: 0; }
+td[nowrap] img[id$="-h"] { background: var(--mark); margin-right: 0; }   /* lines: one value, one colour; the "when" column keeps the four */
+img[id$="-h"]:first-of-type { border-radius: 4px 0 0 4px; }
+img[id$="-h"]:last-of-type { margin-right: 0; border-radius: 0 4px 4px 0; }
+img[id$="-h"]:only-of-type { border-radius: 4px; }
+td.asmall img[id$="-h"] { margin-right: 6px; }
+table[width="520"] img[id$="-h"] { width: 14px; height: 8px; border-radius: 2px; }   /* legend: a colour key, not a bar */
+table[width="520"] td.asmall { text-align: left; padding: 0 14px 0 0; white-space: nowrap; }
+/* the colour key wraps instead of forcing a sideways scrollbar on a narrow screen */
+table[width="520"] { display: flex; flex-wrap: wrap; gap: 4px 0; width: auto; max-width: 100%; }
+table[width="520"] tbody, table[width="520"] tr { display: contents; }
+img[id="blue-h"],   img[id="blue-v"]   { background: var(--t0); }
+img[id="green-h"],  img[id="green-v"]  { background: var(--t1); }
+img[id="yellow-h"], img[id="yellow-v"] { background: var(--t2); }
+img[id="red-h"],    img[id="red-v"]    { background: var(--t3); }
+
+td[rowspan] img { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; display: block; margin: 0 auto; }
+td[rowspan] img.defpic { width: 26px; height: 26px; opacity: .3; }   /* the shared default picture: a quiet placeholder */
+
+/* ranked tables: one left edge, and no stray rule under the last row */
+tr:first-child > td:not([class]):first-child { border-bottom: 1px solid var(--line2); }
+tr:last-child > .rankc, tr:last-child > .hirankc, tr:last-child > td[style*="background-color"] { border-bottom: 0; }
+.rankc, .hirankc { width: 44px; }
+td[style*="background-color"] { white-space: nowrap; }          /* "3 days ago" stays on one line ... */
+td[style*="background-color"].quote { white-space: normal; }    /* ... only the quote may wrap */
+.male, .male a { color: var(--male); }
+.female, .female a { color: var(--female); }
+.bot, .bot a { color: var(--muted); font-style: italic; }
+
+/* single-series bars */
+.bar { display: inline-block; vertical-align: middle; width: 140px; max-width: 100%; height: 8px; border-radius: 4px; background: var(--line); overflow: hidden; }
+.bar span { display: block; height: 100%; border-radius: 4px; background: var(--mark); opacity: 1; }
+.sigword { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+
+/* "These didn't make it to the top:" is a bare <b><i> under the table; give it the same voice as a column title */
+.card > b { display: block; margin: 26px 0 4px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.card > b > i { font-style: normal; }
+
+#totallines { margin-top: 56px; padding-top: 14px; border-top: 1px solid var(--line2); color: var(--muted); font-size: 12px; }
+#footer { margin-top: 2px; color: var(--muted); }
+#footer .small { font-size: 12px; }
+::selection { background: var(--fg); color: var(--bg); }
+
+/* ---- Section menu: one slim bar, one panel ---- */
+.pisg-nav { background: var(--bg); border-bottom-color: var(--line2); margin-bottom: 0; }
+.nav-current { color: var(--fg); font-weight: 650; }
+.nav-toggle { color: var(--muted); opacity: 1; }
+.nav-list { background: var(--surface); border-color: var(--line2); box-shadow: var(--shadow); }
+.nav-list a, .nav-list a:link, .nav-list a:visited { color: var(--fg); font-weight: 500; text-decoration: none; }
+.nav-list a:hover { background: var(--hover); }
+.nav-list a.active { font-weight: 700; }
+
+/* ---- Overview: a sentence and a list of facts, not a wall of tiles ---- */
+.hero { margin: 2px 0 22px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 650; letter-spacing: -0.02em; text-wrap: balance; }
+.hero b { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 700; letter-spacing: -0.05em; }
+.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 56px; margin: 0; }
+.fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 11px 0; border-bottom: 1px solid var(--line); }
+.fact dt { color: var(--muted); }
+.fact dd { margin: 0; text-align: right; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; font-variant-numeric: tabular-nums; }
+.fact dd small { display: block; color: var(--muted); font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 12px; font-weight: 400; }
+
+/* ---- Roles, personalities: columns split by a rule, no boxes ---- */
+.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 28px 32px; margin: 8px 0 0; }
+.minicard { display: flex; flex-direction: column; gap: 3px; padding: 12px 0 0; border: 0; border-top: 1px solid var(--line2); border-radius: 0; background: none; text-align: left; }
+.mc-title { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.mc-name { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 19px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; }
+.mc-desc { color: var(--muted); font-size: 13px; }
+.mc-value { margin-top: 6px; font-size: 13px; font-weight: 600; }
+.mc-list { margin: 8px 0 0; padding: 0; list-style: none; }
+.mc-list li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 0; border-top: 1px solid var(--line); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 13px; }
+.mc-list span { color: var(--muted); }
+.minicard.t0 { border-top: 3px solid var(--t0); } .minicard.t1 { border-top: 3px solid var(--t1); }
+.minicard.t2 { border-top: 3px solid var(--t2); } .minicard.t3 { border-top: 3px solid var(--t3); }
+
+/* ---- Relation map: the one lifted surface ---- */
+.relmap-canvas svg { box-sizing: border-box; background: var(--surface); border: 1px solid var(--line2); border-radius: 8px; }   /* width:100% must include the border */
+.relmap-info { background: none; border: 0; border-top: 1px solid var(--line2); border-radius: 0; padding: 12px 0 0; }
+.relmap-legend { color: var(--muted); }
+.ri-title { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; color: var(--fg); }
+.ri-sub, .ri-num { color: var(--muted); opacity: 1; }
+.ri-list li { border-top-color: var(--line); }
+.ri-link { color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; text-decoration-color: var(--line2); }
+.lg0 i, .rm-node.t0 circle { background: var(--t0); fill: var(--t0); } .lg1 i, .rm-node.t1 circle { background: var(--t1); fill: var(--t1); }
+.lg2 i, .rm-node.t2 circle { background: var(--t2); fill: var(--t2); } .lg3 i, .rm-node.t3 circle { background: var(--t3); fill: var(--t3); }
+.rm-line { stroke: var(--muted); }
+.rm-node circle { stroke: var(--surface); }
+.rm-node text { fill: var(--fg); stroke: var(--surface); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
+.rm-edge.sel .rm-line { stroke: var(--fg); }
+.rm-node:focus-visible circle, .rm-node.sel circle { stroke: var(--fg); }
+
+@media (max-width: 720px) {
+  body { padding: 20px 16px 72px; font-size: 14px; }
+  .hr.rankc10center:nth-child(even) { visibility: hidden; }     /* every second hour label on a phone */
+  td { padding: 7px 6px; font-size: 13px; }
+  .bar { width: 64px; }
+  .card { margin-top: 44px; }
+  .facts { grid-template-columns: 1fr; }
+  td[rowspan] img { width: 30px; height: 30px; }
+}

+ 102 - 0
layout/insights-base.css

@@ -0,0 +1,102 @@
+/* Base styles for the newer sections (navigation bar, overview, relation map, roles ...).
+   pisg puts this in front of the theme, so any theme can restyle these classes, and
+   the old themes still show them properly. Colours come from the theme's variables
+   when it has them (modern, midnight ...), otherwise from the page's own text colour. */
+
+html { scroll-behavior: smooth; }
+.card { scroll-margin-top: 64px; }
+
+.pisg-nav {
+  position: sticky; top: 0; z-index: 30; margin: 0 0 8px; text-align: left;
+  background: var(--bg, #fff); border-bottom: 1px solid rgba(128, 128, 128, .35);
+}
+.nav-menu > summary {
+  display: flex; align-items: center; justify-content: space-between; gap: 16px;
+  padding: 10px 2px; cursor: pointer; list-style: none; user-select: none;
+}
+.nav-menu > summary::-webkit-details-marker { display: none; }
+.nav-current { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.nav-toggle { flex: 0 0 auto; font-size: 13px; opacity: .8; }
+.nav-toggle::after { content: ""; display: inline-block; width: 6px; height: 6px; margin: 0 2px 2px 8px;
+  border: solid currentColor; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); transition: transform .15s; }
+.nav-menu[open] .nav-toggle::after { transform: rotate(-135deg); margin-bottom: -2px; }
+.nav-list {
+  position: absolute; left: 0; right: 0; top: 100%; z-index: 40; margin: 0; padding: 8px;
+  list-style: none; max-height: 70vh; overflow-y: auto;
+  display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0 12px;
+  background: var(--bg, #fff); border: 1px solid rgba(128, 128, 128, .35); border-top: 0;
+  box-shadow: 0 18px 30px -12px rgba(0, 0, 0, .28);
+}
+.nav-list a { display: block; padding: 8px 10px; border-radius: 6px; text-decoration: none; }
+.nav-list a:hover { background: rgba(128, 128, 128, .18); text-decoration: none; }
+.nav-list a.active { font-weight: 700; }
+.nav-list a.active::before { content: "\2022"; margin-right: 8px; }
+.nav-menu > summary:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
+
+.intro { margin: 4px 0 12px; text-align: left; opacity: .85; }
+
+.hero { margin: 2px 0 18px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 600; text-wrap: balance; }
+.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 0 48px; margin: 0; }
+.fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid rgba(128, 128, 128, .3); }
+.fact dt { opacity: .75; }
+.fact dd { margin: 0; text-align: right; font-weight: 600; }
+.fact dd small { display: block; font-weight: 400; opacity: .7; }
+
+.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; margin: 6px 0; }
+.minicard {
+  display: flex; flex-direction: column; gap: 2px; text-align: left;
+  padding: 12px 14px; border: 1px solid rgba(128, 128, 128, .35); border-radius: 12px;
+}
+.mc-title { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; opacity: .7; }
+.mc-name { font-size: 20px; font-weight: 700; line-height: 1.3; word-break: break-word; }
+.mc-desc { font-size: 12px; opacity: .7; }
+.mc-value { margin-top: 6px; font-size: 13px; font-weight: 600; }
+.mc-list { margin: 8px 0 0; padding: 0; list-style: none; }
+.mc-list li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; border-top: 1px solid rgba(128, 128, 128, .25); }
+.mc-list span { opacity: .75; }
+
+.bar { display: inline-block; vertical-align: middle; width: 130px; max-width: 100%; height: 8px; border-radius: 4px; background: rgba(128, 128, 128, .28); overflow: hidden; }
+.bar span { display: block; height: 100%; border-radius: 4px; background: var(--accent, currentColor); opacity: .85; }
+.sigword { font-weight: 600; }
+
+/* ---- relation map ---- */
+.relmap { display: grid; grid-template-columns: minmax(0, 3fr) minmax(230px, 1fr); gap: 14px; align-items: start; text-align: left; }
+@media (max-width: 820px) { .relmap { grid-template-columns: 1fr; } }
+.relmap-canvas svg { display: block; width: 100%; height: auto; border: 1px solid rgba(128, 128, 128, .3); border-radius: 12px; }
+.relmap-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; font-size: 12px; }
+.relmap-legend .lg i { display: inline-block; width: 10px; height: 10px; margin-right: 5px; border-radius: 50%; vertical-align: middle; }
+.lg0 i { background: var(--t0, #4a3aa7); } .lg1 i { background: var(--t1, #eda100); }
+.lg2 i { background: var(--t2, #1baf7a); } .lg3 i { background: var(--t3, #eb6834); }
+.relmap-info { padding: 12px 14px; min-height: 7em; border: 1px solid rgba(128, 128, 128, .35); border-radius: 12px; font-size: 13px; }
+.ri-title { margin: 0 0 4px; font-size: 17px; }
+.ri-sub { margin: 10px 0 4px; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; opacity: .7; }
+.ri-meta, .ri-row { margin: 2px 0; opacity: .9; }
+.ri-list { margin: 0; padding: 0; list-style: none; }
+.ri-list li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; border-top: 1px solid rgba(128, 128, 128, .25); }
+.ri-num { opacity: .7; }
+.ri-link { padding: 0; border: 0; background: none; color: inherit; font: inherit; font-weight: 600; text-decoration: underline; cursor: pointer; }
+
+.rm-line { stroke: currentColor; stroke-opacity: .32; stroke-linecap: round; }
+.rm-hit { stroke: transparent; cursor: pointer; }
+.rm-edge:hover .rm-line, .rm-edge.hl .rm-line { stroke-opacity: .8; }
+.rm-edge.sel .rm-line { stroke: var(--accent, currentColor); stroke-opacity: 1; }
+.rm-node { cursor: pointer; outline: none; }
+.rm-node circle { stroke: rgba(255, 255, 255, .55); stroke-width: 2; }
+.rm-node text { font-size: 12px; fill: currentColor; paint-order: stroke; stroke: var(--card, var(--bg, transparent)); stroke-width: 3px; stroke-linejoin: round; pointer-events: none; }
+.rm-node.t0 circle { fill: var(--t0, #4a3aa7); } .rm-node.t1 circle { fill: var(--t1, #eda100); }
+.rm-node.t2 circle { fill: var(--t2, #1baf7a); } .rm-node.t3 circle { fill: var(--t3, #eb6834); }
+.rm-node:focus-visible circle, .rm-node.sel circle { stroke: var(--accent, currentColor); stroke-width: 4; }
+svg.dim .rm-node:not(.hl) { opacity: .28; }
+svg.dim .rm-edge:not(.hl) { opacity: .1; }
+
+/* labels that found no free space appear on hover, focus, selection, or as a neighbour of one */
+.rm-lbl.off { opacity: 0; }
+.rm-node:hover .rm-lbl.off, .rm-node:focus .rm-lbl.off, .rm-node.hl .rm-lbl.off, .rm-node.sel .rm-lbl.off { opacity: 1; }
+.minicard.t0 { border-top: 3px solid var(--t0, #4a3aa7); } .minicard.t1 { border-top: 3px solid var(--t1, #eda100); }
+.minicard.t2 { border-top: 3px solid var(--t2, #1baf7a); } .minicard.t3 { border-top: 3px solid var(--t3, #eb6834); }
+td.asmall .v { display: inline; }
+
+/* The left-hand menu only exists in themes that lay it out (see build-themes.py). */
+.nav-side { display: none; }
+
+.homebar { margin: 0 0 10px; text-align: left; }

+ 247 - 0
layout/midnight.css

@@ -0,0 +1,247 @@
+/* pisg theme: midnight - Blue-black, always dark.
+   Generated by layout/build-themes.py, edit the palette there. */
+:root {
+  color-scheme: dark;
+  --bg: #0f1318;
+  --surface: #171c23;
+  --fg: #e8ebef;
+  --muted: #98a2ae;
+  --line: #252c35;
+  --line2: #38414c;
+  --hover: #1b2129;
+  --mark: #3987e5;
+  --t0: #9085e9;
+  --t1: #c98500;
+  --t2: #199e70;
+  --t3: #d95926;
+  --male: #7aa7ea;
+  --female: #e58ab5;
+  --shadow: 0 18px 32px -14px rgba(0,0,0,.6);
+}
+
+/* pisg output is table markup, so these rules restyle the existing classes (headtext, hicell,
+   tdtop, rankc ...). The generator wraps each section in <div class="card" id="...">. */
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+
+body {
+  margin: 0;
+  padding: 32px 24px 96px;
+  background: var(--bg);
+  color: var(--fg);
+  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  font-size: 15px;
+  line-height: 1.55;
+}
+body > div { max-width: 1040px; margin: 0 auto; text-align: left; }   /* pisg centres it with align=center */
+
+/* Fixed 925/929px table widths are attributes, so plain CSS overrides them. */
+table { border-collapse: separate; border-spacing: 0; max-width: 100%; }
+table[width] { width: 100%; }
+table[width="520"] { width: auto; margin: 10px 0 0; }
+td { color: var(--fg); font-family: inherit; font-size: 14px; text-align: left; padding: 9px 10px; vertical-align: middle; overflow-wrap: anywhere; }   /* long URLs and nicks wrap instead of forcing a scrollbar */
+
+a, a:link, a:visited { color: inherit; font-weight: 600; text-decoration: underline; text-decoration-color: var(--line2); text-underline-offset: 3px; }
+a:hover { text-decoration-color: currentColor; }
+a:focus-visible, summary:focus-visible, button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
+a.background, a.background:link, a.background:visited { color: var(--muted); font-weight: 500; }
+
+/* ---- Masthead: reads like an IRC header, "#channel @ network" ---- */
+.title, #pagetitle1 {
+  margin: 4px 0 8px;
+  font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+  font-size: clamp(24px, 4.6vw, 40px);
+  line-height: 1.15;
+  font-weight: 700;
+  letter-spacing: -0.035em;
+  color: var(--fg);
+  overflow-wrap: anywhere;
+}
+.subtitle { margin: 0 0 18px; color: var(--muted); font-size: 14px; }
+.subtitle b { color: var(--fg); font-weight: 600; }
+
+/* ---- Sections: a rule and space, no boxes ---- */
+.card { margin: 56px 0 0; padding: 20px 0 0; border-top: 1px solid var(--line2); overflow-x: auto; text-align: left; scroll-margin-top: 64px; }
+.card > br { display: none; }
+.pisg-nav + .card { margin-top: 28px; border-top: 0; padding-top: 0; }
+#overview .headlinebg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }   /* the sentence below is its headline; the menu still links here */
+.card > table:first-of-type { margin-bottom: 8px; }
+.headlinebg { background: none; padding: 0; }
+.headtext { margin: 0; padding: 0; border: 0; background: none; color: var(--fg);
+            font-size: 20px; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; text-align: left; }
+.intro { margin: 2px 0 16px; max-width: 68ch; color: var(--muted); }
+
+/* ---- Wide screens: a menu down the left, the page fills the rest ---- */
+body { container-type: inline-size; }
+.nav-side { display: none; }
+@media (min-width: 1000px) {
+  body { padding-left: 272px; padding-right: 28px; }
+  body > div { max-width: 1720px; }
+  .pisg-nav { box-sizing: border-box; position: fixed; top: 0; left: 0; bottom: 0; width: 232px; margin: 0; padding: 26px 12px 24px 24px;
+              border: 0; border-right: 1px solid var(--line2); background: var(--bg); overflow-y: auto; z-index: 30; }
+  .nav-menu { display: none; }
+  .nav-side { display: block; }
+  .nav-brand, .nav-brand:link, .nav-brand:visited { display: block; margin: 0 0 18px; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 18px; font-weight: 700;
+              letter-spacing: -0.02em; text-decoration: none; overflow-wrap: anywhere; }
+  .nav-side-list { margin: 0; padding: 0; list-style: none; }
+  .nav-side-list a, .nav-side-list a:link, .nav-side-list a:visited { display: block; margin-left: -10px; padding: 6px 10px; border-left: 2px solid transparent;
+              color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; }
+  .nav-side-list a:hover { color: var(--fg); background: var(--hover); }
+  .nav-side-list a.active { color: var(--fg); font-weight: 650; border-left-color: var(--mark); }
+  .card { scroll-margin-top: 24px; }
+  .card td:not(.headtext):not(.headlinebg) { padding-left: 8px; padding-right: 8px; }   /* the 10-column nick table just fits beside the menu */
+  .pisg-nav + .card { margin-top: 20px; }
+}
+/* Two short sections share a row when the page is wide enough (measured on the page, so it also holds inside a frame). */
+@container (min-width: 1080px) {
+  body > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 56px; grid-auto-flow: dense; }
+  body > div > * { grid-column: 1 / -1; }
+  body > div > .card.half { grid-column: auto; }
+}
+
+/* Way back to the landing page (option HomeLink) */
+.homebar { margin: 0 0 14px; text-align: left; }
+.homebtn, .homebtn:link, .homebtn:visited { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--line2); border-radius: 6px;
+  background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 600; line-height: 1.3; text-decoration: none; }
+.homebtn:hover { background: var(--hover); border-color: var(--fg); }
+
+/* Column titles */
+.tdtop { background: none; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em;
+         text-transform: uppercase; border-bottom: 1px solid var(--line2); padding: 8px 10px; }
+.tdtop { white-space: nowrap; }   /* column titles stay on one line; the table scrolls instead */
+.tdtop b { font-weight: 600; }
+
+/* Rows: one hairline each */
+.hicell { background: none; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 10px; }
+.hicell10 { background: none; font-size: 12px; border-bottom: 1px solid var(--line); }
+tr:last-child > .hicell { border-bottom: 0; }
+/* pisg writes an inline colour gradient on the top-nicks rows (HiCell/HiCell2); the theme owns colour. */
+td[style*="background-color"] { background: transparent !important; border-bottom: 1px solid var(--line); }
+tr:hover > td[style*="background-color"], tr:hover > .rankc, tr:hover > .hirankc { background: var(--hover) !important; }
+
+.rankc, .hirankc, .rankc10 { background: transparent; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px;
+                              font-weight: 500; text-align: center; border-bottom: 1px solid var(--line); }
+.hirankc { color: var(--fg); font-weight: 700; }
+.rankc, .hirankc, .rankc10 { white-space: nowrap; }
+.rankc10 { text-align: left; }
+/* nicks are set in monospace, as in an IRC client */
+.rankc + td, .hirankc + td { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.small, .asmall { color: var(--muted); font-family: inherit; font-size: 12px; }
+.asmall { text-align: center; }
+
+/* ---- Bar charts (marks: thin, 4px rounded data end, 2px gap between segments) ---- */
+table:not([width]) { width: 100%; table-layout: fixed; margin: 6px 0; }
+td.asmall { padding: 0 2px; line-height: 1.3; vertical-align: bottom; }   /* bars stand on one baseline */
+table:not([width]) tr:first-child > td.asmall { border-bottom: 1px solid var(--line2); }
+td.asmall br { display: none; }
+td.rankc10center, td.hirankc10center { background: none; border: 0; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+                                         font-size: 11px; text-align: center; padding: 6px 0 0; min-width: 0; letter-spacing: -0.02em; }   /* no side padding: 24 narrow columns must not poke out of the table */
+td.hirankc10center { color: var(--fg); font-weight: 700; }
+/* a number over every bar is noise: the peak keeps its label, the rest are in the tooltip */
+td.asmall .v { display: none; }
+td.asmall .v.pk { display: block; color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.rankc10center.skip { visibility: hidden; }
+
+img[id$="-v"] { display: block; width: min(64%, 22px); margin: 0 auto; border-radius: 0; }
+img[id$="-v"]:first-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-v"]:not(:first-of-type) { margin-top: 2px; }
+img[id$="-v"]:only-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-h"] { height: 8px; vertical-align: middle; margin: 0 1px 0 0; border-radius: 0; }
+td[nowrap] img[id$="-h"] { background: var(--mark); margin-right: 0; }   /* lines: one value, one colour; the "when" column keeps the four */
+img[id$="-h"]:first-of-type { border-radius: 4px 0 0 4px; }
+img[id$="-h"]:last-of-type { margin-right: 0; border-radius: 0 4px 4px 0; }
+img[id$="-h"]:only-of-type { border-radius: 4px; }
+td.asmall img[id$="-h"] { margin-right: 6px; }
+table[width="520"] img[id$="-h"] { width: 14px; height: 8px; border-radius: 2px; }   /* legend: a colour key, not a bar */
+table[width="520"] td.asmall { text-align: left; padding: 0 14px 0 0; white-space: nowrap; }
+/* the colour key wraps instead of forcing a sideways scrollbar on a narrow screen */
+table[width="520"] { display: flex; flex-wrap: wrap; gap: 4px 0; width: auto; max-width: 100%; }
+table[width="520"] tbody, table[width="520"] tr { display: contents; }
+img[id="blue-h"],   img[id="blue-v"]   { background: var(--t0); }
+img[id="green-h"],  img[id="green-v"]  { background: var(--t1); }
+img[id="yellow-h"], img[id="yellow-v"] { background: var(--t2); }
+img[id="red-h"],    img[id="red-v"]    { background: var(--t3); }
+
+td[rowspan] img { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; display: block; margin: 0 auto; }
+td[rowspan] img.defpic { width: 26px; height: 26px; opacity: .3; }   /* the shared default picture: a quiet placeholder */
+
+/* ranked tables: one left edge, and no stray rule under the last row */
+tr:first-child > td:not([class]):first-child { border-bottom: 1px solid var(--line2); }
+tr:last-child > .rankc, tr:last-child > .hirankc, tr:last-child > td[style*="background-color"] { border-bottom: 0; }
+.rankc, .hirankc { width: 44px; }
+td[style*="background-color"] { white-space: nowrap; }          /* "3 days ago" stays on one line ... */
+td[style*="background-color"].quote { white-space: normal; }    /* ... only the quote may wrap */
+.male, .male a { color: var(--male); }
+.female, .female a { color: var(--female); }
+.bot, .bot a { color: var(--muted); font-style: italic; }
+
+/* single-series bars */
+.bar { display: inline-block; vertical-align: middle; width: 140px; max-width: 100%; height: 8px; border-radius: 4px; background: var(--line); overflow: hidden; }
+.bar span { display: block; height: 100%; border-radius: 4px; background: var(--mark); opacity: 1; }
+.sigword { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+
+/* "These didn't make it to the top:" is a bare <b><i> under the table; give it the same voice as a column title */
+.card > b { display: block; margin: 26px 0 4px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.card > b > i { font-style: normal; }
+
+#totallines { margin-top: 56px; padding-top: 14px; border-top: 1px solid var(--line2); color: var(--muted); font-size: 12px; }
+#footer { margin-top: 2px; color: var(--muted); }
+#footer .small { font-size: 12px; }
+::selection { background: var(--fg); color: var(--bg); }
+
+/* ---- Section menu: one slim bar, one panel ---- */
+.pisg-nav { background: var(--bg); border-bottom-color: var(--line2); margin-bottom: 0; }
+.nav-current { color: var(--fg); font-weight: 650; }
+.nav-toggle { color: var(--muted); opacity: 1; }
+.nav-list { background: var(--surface); border-color: var(--line2); box-shadow: var(--shadow); }
+.nav-list a, .nav-list a:link, .nav-list a:visited { color: var(--fg); font-weight: 500; text-decoration: none; }
+.nav-list a:hover { background: var(--hover); }
+.nav-list a.active { font-weight: 700; }
+
+/* ---- Overview: a sentence and a list of facts, not a wall of tiles ---- */
+.hero { margin: 2px 0 22px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 650; letter-spacing: -0.02em; text-wrap: balance; }
+.hero b { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 700; letter-spacing: -0.05em; }
+.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 56px; margin: 0; }
+.fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 11px 0; border-bottom: 1px solid var(--line); }
+.fact dt { color: var(--muted); }
+.fact dd { margin: 0; text-align: right; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; font-variant-numeric: tabular-nums; }
+.fact dd small { display: block; color: var(--muted); font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 12px; font-weight: 400; }
+
+/* ---- Roles, personalities: columns split by a rule, no boxes ---- */
+.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 28px 32px; margin: 8px 0 0; }
+.minicard { display: flex; flex-direction: column; gap: 3px; padding: 12px 0 0; border: 0; border-top: 1px solid var(--line2); border-radius: 0; background: none; text-align: left; }
+.mc-title { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.mc-name { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 19px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; }
+.mc-desc { color: var(--muted); font-size: 13px; }
+.mc-value { margin-top: 6px; font-size: 13px; font-weight: 600; }
+.mc-list { margin: 8px 0 0; padding: 0; list-style: none; }
+.mc-list li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 0; border-top: 1px solid var(--line); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 13px; }
+.mc-list span { color: var(--muted); }
+.minicard.t0 { border-top: 3px solid var(--t0); } .minicard.t1 { border-top: 3px solid var(--t1); }
+.minicard.t2 { border-top: 3px solid var(--t2); } .minicard.t3 { border-top: 3px solid var(--t3); }
+
+/* ---- Relation map: the one lifted surface ---- */
+.relmap-canvas svg { box-sizing: border-box; background: var(--surface); border: 1px solid var(--line2); border-radius: 8px; }   /* width:100% must include the border */
+.relmap-info { background: none; border: 0; border-top: 1px solid var(--line2); border-radius: 0; padding: 12px 0 0; }
+.relmap-legend { color: var(--muted); }
+.ri-title { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; color: var(--fg); }
+.ri-sub, .ri-num { color: var(--muted); opacity: 1; }
+.ri-list li { border-top-color: var(--line); }
+.ri-link { color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; text-decoration-color: var(--line2); }
+.lg0 i, .rm-node.t0 circle { background: var(--t0); fill: var(--t0); } .lg1 i, .rm-node.t1 circle { background: var(--t1); fill: var(--t1); }
+.lg2 i, .rm-node.t2 circle { background: var(--t2); fill: var(--t2); } .lg3 i, .rm-node.t3 circle { background: var(--t3); fill: var(--t3); }
+.rm-line { stroke: var(--muted); }
+.rm-node circle { stroke: var(--surface); }
+.rm-node text { fill: var(--fg); stroke: var(--surface); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
+.rm-edge.sel .rm-line { stroke: var(--fg); }
+.rm-node:focus-visible circle, .rm-node.sel circle { stroke: var(--fg); }
+
+@media (max-width: 720px) {
+  body { padding: 20px 16px 72px; font-size: 14px; }
+  .hr.rankc10center:nth-child(even) { visibility: hidden; }     /* every second hour label on a phone */
+  td { padding: 7px 6px; font-size: 13px; }
+  .bar { width: 64px; }
+  .card { margin-top: 44px; }
+  .facts { grid-template-columns: 1fr; }
+  td[rowspan] img { width: 30px; height: 30px; }
+}

+ 266 - 0
layout/modern.css

@@ -0,0 +1,266 @@
+/* pisg theme: modern - Ink on paper, colour only for data; follows the visitor's light/dark setting.
+   Generated by layout/build-themes.py, edit the palette there. */
+:root {
+  color-scheme: light dark;
+  --bg: #f6f7f9;
+  --surface: #ffffff;
+  --fg: #171b21;
+  --muted: #59626d;
+  --line: #e0e3e8;
+  --line2: #c6ccd4;
+  --hover: #eceff3;
+  --mark: #2a78d6;
+  --t0: #4a3aa7;
+  --t1: #eda100;
+  --t2: #1baf7a;
+  --t3: #eb6834;
+  --male: #2a5fb0;
+  --female: #b03a72;
+  --shadow: 0 18px 32px -14px rgba(20,24,30,.28);
+}
+@media (prefers-color-scheme: dark) {
+  :root {
+    --bg: #0f1318;
+    --surface: #171c23;
+    --fg: #e8ebef;
+    --muted: #98a2ae;
+    --line: #252c35;
+    --line2: #38414c;
+    --hover: #1b2129;
+    --mark: #3987e5;
+    --t0: #9085e9;
+    --t1: #c98500;
+    --t2: #199e70;
+    --t3: #d95926;
+    --male: #7aa7ea;
+    --female: #e58ab5;
+    --shadow: 0 18px 32px -14px rgba(0,0,0,.6);
+  }
+}
+
+/* pisg output is table markup, so these rules restyle the existing classes (headtext, hicell,
+   tdtop, rankc ...). The generator wraps each section in <div class="card" id="...">. */
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+
+body {
+  margin: 0;
+  padding: 32px 24px 96px;
+  background: var(--bg);
+  color: var(--fg);
+  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  font-size: 15px;
+  line-height: 1.55;
+}
+body > div { max-width: 1040px; margin: 0 auto; text-align: left; }   /* pisg centres it with align=center */
+
+/* Fixed 925/929px table widths are attributes, so plain CSS overrides them. */
+table { border-collapse: separate; border-spacing: 0; max-width: 100%; }
+table[width] { width: 100%; }
+table[width="520"] { width: auto; margin: 10px 0 0; }
+td { color: var(--fg); font-family: inherit; font-size: 14px; text-align: left; padding: 9px 10px; vertical-align: middle; overflow-wrap: anywhere; }   /* long URLs and nicks wrap instead of forcing a scrollbar */
+
+a, a:link, a:visited { color: inherit; font-weight: 600; text-decoration: underline; text-decoration-color: var(--line2); text-underline-offset: 3px; }
+a:hover { text-decoration-color: currentColor; }
+a:focus-visible, summary:focus-visible, button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
+a.background, a.background:link, a.background:visited { color: var(--muted); font-weight: 500; }
+
+/* ---- Masthead: reads like an IRC header, "#channel @ network" ---- */
+.title, #pagetitle1 {
+  margin: 4px 0 8px;
+  font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+  font-size: clamp(24px, 4.6vw, 40px);
+  line-height: 1.15;
+  font-weight: 700;
+  letter-spacing: -0.035em;
+  color: var(--fg);
+  overflow-wrap: anywhere;
+}
+.subtitle { margin: 0 0 18px; color: var(--muted); font-size: 14px; }
+.subtitle b { color: var(--fg); font-weight: 600; }
+
+/* ---- Sections: a rule and space, no boxes ---- */
+.card { margin: 56px 0 0; padding: 20px 0 0; border-top: 1px solid var(--line2); overflow-x: auto; text-align: left; scroll-margin-top: 64px; }
+.card > br { display: none; }
+.pisg-nav + .card { margin-top: 28px; border-top: 0; padding-top: 0; }
+#overview .headlinebg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }   /* the sentence below is its headline; the menu still links here */
+.card > table:first-of-type { margin-bottom: 8px; }
+.headlinebg { background: none; padding: 0; }
+.headtext { margin: 0; padding: 0; border: 0; background: none; color: var(--fg);
+            font-size: 20px; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; text-align: left; }
+.intro { margin: 2px 0 16px; max-width: 68ch; color: var(--muted); }
+
+/* ---- Wide screens: a menu down the left, the page fills the rest ---- */
+body { container-type: inline-size; }
+.nav-side { display: none; }
+@media (min-width: 1000px) {
+  body { padding-left: 272px; padding-right: 28px; }
+  body > div { max-width: 1720px; }
+  .pisg-nav { box-sizing: border-box; position: fixed; top: 0; left: 0; bottom: 0; width: 232px; margin: 0; padding: 26px 12px 24px 24px;
+              border: 0; border-right: 1px solid var(--line2); background: var(--bg); overflow-y: auto; z-index: 30; }
+  .nav-menu { display: none; }
+  .nav-side { display: block; }
+  .nav-brand, .nav-brand:link, .nav-brand:visited { display: block; margin: 0 0 18px; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 18px; font-weight: 700;
+              letter-spacing: -0.02em; text-decoration: none; overflow-wrap: anywhere; }
+  .nav-side-list { margin: 0; padding: 0; list-style: none; }
+  .nav-side-list a, .nav-side-list a:link, .nav-side-list a:visited { display: block; margin-left: -10px; padding: 6px 10px; border-left: 2px solid transparent;
+              color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; }
+  .nav-side-list a:hover { color: var(--fg); background: var(--hover); }
+  .nav-side-list a.active { color: var(--fg); font-weight: 650; border-left-color: var(--mark); }
+  .card { scroll-margin-top: 24px; }
+  .card td:not(.headtext):not(.headlinebg) { padding-left: 8px; padding-right: 8px; }   /* the 10-column nick table just fits beside the menu */
+  .pisg-nav + .card { margin-top: 20px; }
+}
+/* Two short sections share a row when the page is wide enough (measured on the page, so it also holds inside a frame). */
+@container (min-width: 1080px) {
+  body > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 56px; grid-auto-flow: dense; }
+  body > div > * { grid-column: 1 / -1; }
+  body > div > .card.half { grid-column: auto; }
+}
+
+/* Way back to the landing page (option HomeLink) */
+.homebar { margin: 0 0 14px; text-align: left; }
+.homebtn, .homebtn:link, .homebtn:visited { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--line2); border-radius: 6px;
+  background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 600; line-height: 1.3; text-decoration: none; }
+.homebtn:hover { background: var(--hover); border-color: var(--fg); }
+
+/* Column titles */
+.tdtop { background: none; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em;
+         text-transform: uppercase; border-bottom: 1px solid var(--line2); padding: 8px 10px; }
+.tdtop { white-space: nowrap; }   /* column titles stay on one line; the table scrolls instead */
+.tdtop b { font-weight: 600; }
+
+/* Rows: one hairline each */
+.hicell { background: none; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 10px; }
+.hicell10 { background: none; font-size: 12px; border-bottom: 1px solid var(--line); }
+tr:last-child > .hicell { border-bottom: 0; }
+/* pisg writes an inline colour gradient on the top-nicks rows (HiCell/HiCell2); the theme owns colour. */
+td[style*="background-color"] { background: transparent !important; border-bottom: 1px solid var(--line); }
+tr:hover > td[style*="background-color"], tr:hover > .rankc, tr:hover > .hirankc { background: var(--hover) !important; }
+
+.rankc, .hirankc, .rankc10 { background: transparent; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px;
+                              font-weight: 500; text-align: center; border-bottom: 1px solid var(--line); }
+.hirankc { color: var(--fg); font-weight: 700; }
+.rankc, .hirankc, .rankc10 { white-space: nowrap; }
+.rankc10 { text-align: left; }
+/* nicks are set in monospace, as in an IRC client */
+.rankc + td, .hirankc + td { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.small, .asmall { color: var(--muted); font-family: inherit; font-size: 12px; }
+.asmall { text-align: center; }
+
+/* ---- Bar charts (marks: thin, 4px rounded data end, 2px gap between segments) ---- */
+table:not([width]) { width: 100%; table-layout: fixed; margin: 6px 0; }
+td.asmall { padding: 0 2px; line-height: 1.3; vertical-align: bottom; }   /* bars stand on one baseline */
+table:not([width]) tr:first-child > td.asmall { border-bottom: 1px solid var(--line2); }
+td.asmall br { display: none; }
+td.rankc10center, td.hirankc10center { background: none; border: 0; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+                                         font-size: 11px; text-align: center; padding: 6px 0 0; min-width: 0; letter-spacing: -0.02em; }   /* no side padding: 24 narrow columns must not poke out of the table */
+td.hirankc10center { color: var(--fg); font-weight: 700; }
+/* a number over every bar is noise: the peak keeps its label, the rest are in the tooltip */
+td.asmall .v { display: none; }
+td.asmall .v.pk { display: block; color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.rankc10center.skip { visibility: hidden; }
+
+img[id$="-v"] { display: block; width: min(64%, 22px); margin: 0 auto; border-radius: 0; }
+img[id$="-v"]:first-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-v"]:not(:first-of-type) { margin-top: 2px; }
+img[id$="-v"]:only-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-h"] { height: 8px; vertical-align: middle; margin: 0 1px 0 0; border-radius: 0; }
+td[nowrap] img[id$="-h"] { background: var(--mark); margin-right: 0; }   /* lines: one value, one colour; the "when" column keeps the four */
+img[id$="-h"]:first-of-type { border-radius: 4px 0 0 4px; }
+img[id$="-h"]:last-of-type { margin-right: 0; border-radius: 0 4px 4px 0; }
+img[id$="-h"]:only-of-type { border-radius: 4px; }
+td.asmall img[id$="-h"] { margin-right: 6px; }
+table[width="520"] img[id$="-h"] { width: 14px; height: 8px; border-radius: 2px; }   /* legend: a colour key, not a bar */
+table[width="520"] td.asmall { text-align: left; padding: 0 14px 0 0; white-space: nowrap; }
+/* the colour key wraps instead of forcing a sideways scrollbar on a narrow screen */
+table[width="520"] { display: flex; flex-wrap: wrap; gap: 4px 0; width: auto; max-width: 100%; }
+table[width="520"] tbody, table[width="520"] tr { display: contents; }
+img[id="blue-h"],   img[id="blue-v"]   { background: var(--t0); }
+img[id="green-h"],  img[id="green-v"]  { background: var(--t1); }
+img[id="yellow-h"], img[id="yellow-v"] { background: var(--t2); }
+img[id="red-h"],    img[id="red-v"]    { background: var(--t3); }
+
+td[rowspan] img { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; display: block; margin: 0 auto; }
+td[rowspan] img.defpic { width: 26px; height: 26px; opacity: .3; }   /* the shared default picture: a quiet placeholder */
+
+/* ranked tables: one left edge, and no stray rule under the last row */
+tr:first-child > td:not([class]):first-child { border-bottom: 1px solid var(--line2); }
+tr:last-child > .rankc, tr:last-child > .hirankc, tr:last-child > td[style*="background-color"] { border-bottom: 0; }
+.rankc, .hirankc { width: 44px; }
+td[style*="background-color"] { white-space: nowrap; }          /* "3 days ago" stays on one line ... */
+td[style*="background-color"].quote { white-space: normal; }    /* ... only the quote may wrap */
+.male, .male a { color: var(--male); }
+.female, .female a { color: var(--female); }
+.bot, .bot a { color: var(--muted); font-style: italic; }
+
+/* single-series bars */
+.bar { display: inline-block; vertical-align: middle; width: 140px; max-width: 100%; height: 8px; border-radius: 4px; background: var(--line); overflow: hidden; }
+.bar span { display: block; height: 100%; border-radius: 4px; background: var(--mark); opacity: 1; }
+.sigword { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+
+/* "These didn't make it to the top:" is a bare <b><i> under the table; give it the same voice as a column title */
+.card > b { display: block; margin: 26px 0 4px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.card > b > i { font-style: normal; }
+
+#totallines { margin-top: 56px; padding-top: 14px; border-top: 1px solid var(--line2); color: var(--muted); font-size: 12px; }
+#footer { margin-top: 2px; color: var(--muted); }
+#footer .small { font-size: 12px; }
+::selection { background: var(--fg); color: var(--bg); }
+
+/* ---- Section menu: one slim bar, one panel ---- */
+.pisg-nav { background: var(--bg); border-bottom-color: var(--line2); margin-bottom: 0; }
+.nav-current { color: var(--fg); font-weight: 650; }
+.nav-toggle { color: var(--muted); opacity: 1; }
+.nav-list { background: var(--surface); border-color: var(--line2); box-shadow: var(--shadow); }
+.nav-list a, .nav-list a:link, .nav-list a:visited { color: var(--fg); font-weight: 500; text-decoration: none; }
+.nav-list a:hover { background: var(--hover); }
+.nav-list a.active { font-weight: 700; }
+
+/* ---- Overview: a sentence and a list of facts, not a wall of tiles ---- */
+.hero { margin: 2px 0 22px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 650; letter-spacing: -0.02em; text-wrap: balance; }
+.hero b { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 700; letter-spacing: -0.05em; }
+.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 56px; margin: 0; }
+.fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 11px 0; border-bottom: 1px solid var(--line); }
+.fact dt { color: var(--muted); }
+.fact dd { margin: 0; text-align: right; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; font-variant-numeric: tabular-nums; }
+.fact dd small { display: block; color: var(--muted); font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 12px; font-weight: 400; }
+
+/* ---- Roles, personalities: columns split by a rule, no boxes ---- */
+.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 28px 32px; margin: 8px 0 0; }
+.minicard { display: flex; flex-direction: column; gap: 3px; padding: 12px 0 0; border: 0; border-top: 1px solid var(--line2); border-radius: 0; background: none; text-align: left; }
+.mc-title { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.mc-name { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 19px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; }
+.mc-desc { color: var(--muted); font-size: 13px; }
+.mc-value { margin-top: 6px; font-size: 13px; font-weight: 600; }
+.mc-list { margin: 8px 0 0; padding: 0; list-style: none; }
+.mc-list li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 0; border-top: 1px solid var(--line); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 13px; }
+.mc-list span { color: var(--muted); }
+.minicard.t0 { border-top: 3px solid var(--t0); } .minicard.t1 { border-top: 3px solid var(--t1); }
+.minicard.t2 { border-top: 3px solid var(--t2); } .minicard.t3 { border-top: 3px solid var(--t3); }
+
+/* ---- Relation map: the one lifted surface ---- */
+.relmap-canvas svg { box-sizing: border-box; background: var(--surface); border: 1px solid var(--line2); border-radius: 8px; }   /* width:100% must include the border */
+.relmap-info { background: none; border: 0; border-top: 1px solid var(--line2); border-radius: 0; padding: 12px 0 0; }
+.relmap-legend { color: var(--muted); }
+.ri-title { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; color: var(--fg); }
+.ri-sub, .ri-num { color: var(--muted); opacity: 1; }
+.ri-list li { border-top-color: var(--line); }
+.ri-link { color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; text-decoration-color: var(--line2); }
+.lg0 i, .rm-node.t0 circle { background: var(--t0); fill: var(--t0); } .lg1 i, .rm-node.t1 circle { background: var(--t1); fill: var(--t1); }
+.lg2 i, .rm-node.t2 circle { background: var(--t2); fill: var(--t2); } .lg3 i, .rm-node.t3 circle { background: var(--t3); fill: var(--t3); }
+.rm-line { stroke: var(--muted); }
+.rm-node circle { stroke: var(--surface); }
+.rm-node text { fill: var(--fg); stroke: var(--surface); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
+.rm-edge.sel .rm-line { stroke: var(--fg); }
+.rm-node:focus-visible circle, .rm-node.sel circle { stroke: var(--fg); }
+
+@media (max-width: 720px) {
+  body { padding: 20px 16px 72px; font-size: 14px; }
+  .hr.rankc10center:nth-child(even) { visibility: hidden; }     /* every second hour label on a phone */
+  td { padding: 7px 6px; font-size: 13px; }
+  .bar { width: 64px; }
+  .card { margin-top: 44px; }
+  .facts { grid-template-columns: 1fr; }
+  td[rowspan] img { width: 30px; height: 30px; }
+}

+ 50 - 0
layout/pagenav.js

@@ -0,0 +1,50 @@
+/* Section bar: shows which section you are reading, and closes the section list after a choice.
+ * pisg writes <nav id="pisg-nav"><details>...</details></nav>. Without this script the list still
+ * opens and every link still works; this only adds the "you are here" label. */
+(function () {
+  "use strict";
+  var nav = document.getElementById("pisg-nav");
+  if (!nav) return;
+  var menu = nav.querySelector("details");
+  var current = document.getElementById("nav-current");
+  // Both lists (the dropdown and the menu down the left) carry the same links: one target per section.
+  var links = Array.prototype.slice.call(nav.querySelectorAll(".nav-list a"));
+  var all = Array.prototype.slice.call(nav.querySelectorAll(".nav-list a, .nav-side-list a"));
+  var targets = links.map(function (a) { return document.getElementById(a.getAttribute("href").slice(1)); });
+  var active = -1, ticking = false;
+
+  function setActive(i) {
+    if (i === active) return;
+    active = i;
+    all.forEach(function (a) {
+      var on = links[i] && a.getAttribute("href") === links[i].getAttribute("href");
+      a.classList.toggle("active", !!on);
+      if (on) a.setAttribute("aria-current", "true"); else a.removeAttribute("aria-current");
+    });
+    if (current && links[i]) current.textContent = links[i].textContent;
+  }
+
+  function update() {
+    ticking = false;
+    var side = window.getComputedStyle(nav).position === "fixed";           // the menu down the left takes no height
+    var line = (side ? 0 : nav.offsetHeight) + 24, i, hit = 0;              // just under the sticky bar
+    for (i = 0; i < targets.length; i++) {
+      if (targets[i] && targets[i].getBoundingClientRect().top <= line) hit = i;
+    }
+    setActive(hit);
+  }
+  function onScroll() { if (!ticking) { ticking = true; (window.requestAnimationFrame || setTimeout)(update); } }
+
+  function close() { if (menu && menu.open) menu.open = false; }
+  window.addEventListener("scroll", onScroll, { passive: true });
+  window.addEventListener("resize", onScroll);
+  links.forEach(function (a, i) { a.addEventListener("click", function () { setActive(i); close(); }); });
+  Array.prototype.forEach.call(nav.querySelectorAll(".nav-side-list a"), function (a) {
+    a.addEventListener("click", function () { setActive(links.map(function (l) { return l.getAttribute("href"); }).indexOf(a.getAttribute("href"))); });
+  });
+  document.addEventListener("keydown", function (e) {
+    if (e.key === "Escape" && menu && menu.open) { menu.open = false; menu.querySelector("summary").focus(); }
+  });
+  document.addEventListener("click", function (e) { if (menu && menu.open && !nav.contains(e.target)) close(); });
+  update();
+})();

+ 317 - 0
layout/relmap.js

@@ -0,0 +1,317 @@
+/* Relation map: who talks to whom.
+ *
+ * pisg writes the data as JSON in <script id="relmap-data"> and this script draws it as
+ * an SVG into <svg id="relmap-svg">, with details in <div id="relmap-info">.
+ * No libraries. The layout is a small force simulation with a fixed starting point, so
+ * the same data always gives the same picture.
+ *
+ * data = { nodes: [{id, lines, words, hours:[4], partners:[[nick, strength]...]}],
+ *          edges: [{a, b, w, ab:[direct, mentions, replies], ba:[...]}],   // a, b = node index
+ *          i18n:  { text key: text } }
+ */
+(function () {
+  "use strict";
+
+  var dataEl = document.getElementById("relmap-data");
+  var svg = document.getElementById("relmap-svg");
+  var info = document.getElementById("relmap-info");
+  if (!dataEl || !svg || !info) return;
+
+  var data;
+  try { data = JSON.parse(dataEl.textContent); } catch (e) { return; }
+  var nodes = data.nodes || [], edges = data.edges || [], T = data.i18n || {};
+  if (nodes.length < 2 || !edges.length) return;
+
+  var NS = "http://www.w3.org/2000/svg";
+  var W = 960, H = 620, PAD = 40;
+
+  function fmt(s, vars) {
+    return String(s || "").replace(/\[:(\w+)\]/g, function (m, k) { return vars && vars[k] !== undefined ? vars[k] : m; });
+  }
+  function num(n) { return Number(n || 0).toLocaleString(); }
+  function el(name, attrs, text) {
+    var e = document.createElementNS(NS, name);
+    for (var k in attrs) if (Object.prototype.hasOwnProperty.call(attrs, k)) e.setAttribute(k, attrs[k]);
+    if (text !== undefined) e.textContent = text;
+    return e;
+  }
+  function html(tag, cls, text) {
+    var e = document.createElement(tag);
+    if (cls) e.className = cls;
+    if (text !== undefined) e.textContent = text;
+    return e;
+  }
+
+  // ---- sizes and colours -------------------------------------------------------------
+  var maxLines = 1, maxW = 1;
+  nodes.forEach(function (n) { if (n.lines > maxLines) maxLines = n.lines; });
+  edges.forEach(function (e) { if (e.w > maxW) maxW = e.w; });
+  nodes.forEach(function (n) {
+    n.r = 7 + 20 * Math.sqrt(n.lines / maxLines);
+    var best = 0;
+    for (var i = 1; i < 4; i++) if ((n.hours[i] || 0) > (n.hours[best] || 0)) best = i;
+    n.t = best;                                   // 0 night, 1 morning, 2 afternoon, 3 evening
+  });
+
+  // ---- layout (Fruchterman-Reingold, fixed start, so it is repeatable) -----------------
+  function layout() {
+    var n = nodes.length, i, j, k;
+    var pos = nodes.map(function (nd, idx) {
+      var a = (2 * Math.PI * idx) / n;
+      return { x: W / 2 + 0.36 * W * Math.cos(a), y: H / 2 + 0.36 * H * Math.sin(a), dx: 0, dy: 0 };
+    });
+    var area = (W - 2 * PAD) * (H - 2 * PAD);
+    var kk = 0.75 * Math.sqrt(area / n);
+    var steps = 320;
+    for (var s = 0; s < steps; s++) {
+      var temp = (W / 9) * (1 - s / steps) + 0.5;
+      for (i = 0; i < n; i++) { pos[i].dx = 0; pos[i].dy = 0; }
+      for (i = 0; i < n; i++) {                   // everyone pushes everyone away
+        for (j = i + 1; j < n; j++) {
+          var dx = pos[i].x - pos[j].x, dy = pos[i].y - pos[j].y;
+          var d = Math.sqrt(dx * dx + dy * dy) || 0.01;
+          var minGap = nodes[i].r + nodes[j].r + 14;
+          var f = (kk * kk) / d + (d < minGap ? (minGap - d) * 2 : 0);
+          pos[i].dx += (dx / d) * f; pos[i].dy += (dy / d) * f;
+          pos[j].dx -= (dx / d) * f; pos[j].dy -= (dy / d) * f;
+        }
+      }
+      for (k = 0; k < edges.length; k++) {        // linked people pull together, harder when they talk more
+        var e = edges[k], a = pos[e.a], b = pos[e.b];
+        var ex = a.x - b.x, ey = a.y - b.y;
+        var ed = Math.sqrt(ex * ex + ey * ey) || 0.01;
+        var pull = ((ed * ed) / kk) * (0.15 + 0.85 * Math.sqrt(e.w / maxW));
+        a.dx -= (ex / ed) * pull; a.dy -= (ey / ed) * pull;
+        b.dx += (ex / ed) * pull; b.dy += (ey / ed) * pull;
+      }
+      for (i = 0; i < n; i++) {                   // a little gravity keeps loners on the page
+        pos[i].dx -= (pos[i].x - W / 2) * 0.05; pos[i].dy -= (pos[i].y - H / 2) * 0.05;
+        var len = Math.sqrt(pos[i].dx * pos[i].dx + pos[i].dy * pos[i].dy) || 0.01;
+        var move = Math.min(len, temp);
+        pos[i].x += (pos[i].dx / len) * move; pos[i].y += (pos[i].dy / len) * move;
+      }
+    }
+    // Not to scale, on purpose. Fitting the drawing to its farthest nodes squeezes the busy middle
+    // into a blob, so keep only each node's direction from the centre and re-space them by rank:
+    // the nearest goes near the middle, the farthest to the edge, evenly in between.
+    var cx = median(pos.map(function (q) { return q.x; })), cy = median(pos.map(function (q) { return q.y; }));
+    var sx = Math.max(median(pos.map(function (q) { return Math.abs(q.x - cx); })), 1);
+    var sy = Math.max(median(pos.map(function (q) { return Math.abs(q.y - cy); })), 1);
+    var polar = pos.map(function (q, idx) {
+      var nx = (q.x - cx) / sx, ny = (q.y - cy) / sy;
+      return { i: idx, a: Math.atan2(ny, nx), d: Math.sqrt(nx * nx + ny * ny) };
+    });
+    polar.sort(function (p, q) { return p.d - q.d || p.i - q.i; });
+    var RX = W / 2 - PAD - 34, RY = H / 2 - PAD - 26;
+    polar.forEach(function (p, rank) {
+      var t = Math.pow((rank + 0.6) / (n + 0.2), 0.62);           // <1 keeps more room near the middle
+      nodes[p.i].x = W / 2 + Math.cos(p.a) * RX * t;
+      nodes[p.i].y = H / 2 + Math.sin(p.a) * RY * t;
+    });
+    // stretch the result to fill the picture, so no band of it stays empty
+    var x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
+    nodes.forEach(function (nd) { x0 = Math.min(x0, nd.x); x1 = Math.max(x1, nd.x); y0 = Math.min(y0, nd.y); y1 = Math.max(y1, nd.y); });
+    nodes.forEach(function (nd) {
+      nd.x = (PAD + 30) + ((nd.x - x0) / ((x1 - x0) || 1)) * (W - 2 * (PAD + 30));
+      nd.y = (PAD + 8) + ((nd.y - y0) / ((y1 - y0) || 1)) * (H - 2 * PAD - 44);
+    });
+    separate();
+  }
+
+  function median(a) { return pct(a, 0.5); }
+  function pct(a, p) {
+    var b = a.slice().sort(function (x, y) { return x - y; });
+    return b[Math.min(b.length - 1, Math.floor(p * b.length))];
+  }
+  // Push apart circles that still overlap, keeping every node (and room for its label) in the picture.
+  function separate() {
+    var n = nodes.length;
+    for (var pass = 0; pass < 200; pass++) {
+      var moved = false;
+      for (var i = 0; i < n; i++) {
+        for (var j = i + 1; j < n; j++) {
+          var dx = nodes[i].x - nodes[j].x, dy = nodes[i].y - nodes[j].y;
+          var d = Math.sqrt(dx * dx + dy * dy);
+          var gap = nodes[i].r + nodes[j].r + 10;
+          if (d < gap) {
+            if (d < 0.01) { dx = 1; dy = 0; d = 1; }
+            var push = (gap - d) / 2 + 0.05;
+            nodes[i].x += (dx / d) * push; nodes[i].y += (dy / d) * push;
+            nodes[j].x -= (dx / d) * push; nodes[j].y -= (dy / d) * push;
+            moved = true;
+          }
+        }
+      }
+      for (i = 0; i < n; i++) {
+        nodes[i].x = Math.max(nodes[i].r + 8, Math.min(W - nodes[i].r - 8, nodes[i].x));
+        nodes[i].y = Math.max(nodes[i].r + 8, Math.min(H - nodes[i].r - 22, nodes[i].y));
+      }
+      if (!moved) break;
+    }
+  }
+  layout();
+
+  // Labels: busiest nodes first, each tried below, above, right, then left of its circle, and kept
+  // only where it touches no other label and no circle. The rest show on hover or when selected.
+  function placeLabels() {
+    var boxes = [], order = nodes.map(function (_, i) { return i; })
+      .sort(function (a, b) { return nodes[b].lines - nodes[a].lines; });
+    function hits(b) {
+      for (var k = 0; k < boxes.length; k++) {
+        var o = boxes[k];
+        if (b.x < o.x + o.w && b.x + b.w > o.x && b.y < o.y + o.h && b.y + b.h > o.y) return true;
+      }
+      for (k = 0; k < nodes.length; k++) {
+        var c = nodes[k];
+        if (c.x + c.r + 3 > b.x && c.x - c.r - 3 < b.x + b.w && c.y + c.r + 3 > b.y && c.y - c.r - 3 < b.y + b.h) return true;
+      }
+      return false;
+    }
+    order.forEach(function (i) {
+      var n = nodes[i], w = shorten(n.id).length * 6.7 + 4, h = 14;
+      var tries = [
+        { a: "middle", x: n.x, y: n.y + n.r + 12, bx: n.x - w / 2, by: n.y + n.r + 1 },
+        { a: "middle", x: n.x, y: n.y - n.r - 4, bx: n.x - w / 2, by: n.y - n.r - 15 },
+        { a: "start", x: n.x + n.r + 4, y: n.y + 4, bx: n.x + n.r + 3, by: n.y - 7 },
+        { a: "end", x: n.x - n.r - 4, y: n.y + 4, bx: n.x - n.r - w - 3, by: n.y - 7 }
+      ];
+      n.label = null;
+      for (var t = 0; t < tries.length; t++) {
+        var b = { x: tries[t].bx, y: tries[t].by, w: w, h: h };
+        if (b.x < 2 || b.x + b.w > W - 2 || b.y < 2 || b.y + b.h > H - 2) continue;
+        if (!hits(b)) { n.label = tries[t]; boxes.push(b); break; }
+      }
+      if (!n.label) n.label = { a: "middle", x: n.x, y: n.y + n.r + 12, hidden: true };
+    });
+  }
+  function shorten(s) { return s.length > 15 ? s.slice(0, 14) + "\u2026" : s; }
+  placeLabels();
+
+  // ---- drawing --------------------------------------------------------------------------
+  var edgeLayer = el("g", { "class": "rm-edges" }), nodeLayer = el("g", { "class": "rm-nodes" });
+  svg.appendChild(edgeLayer);
+  svg.appendChild(nodeLayer);
+
+  var nodeEls = [], edgeEls = [], neighbours = nodes.map(function () { return {}; });
+
+  edges.forEach(function (e, idx) {
+    var a = nodes[e.a], b = nodes[e.b];
+    neighbours[e.a][e.b] = idx; neighbours[e.b][e.a] = idx;
+    var width = 1 + 6 * Math.sqrt(e.w / maxW);
+    var g = el("g", { "class": "rm-edge" });
+    g.appendChild(el("line", { "class": "rm-hit", x1: a.x, y1: a.y, x2: b.x, y2: b.y, "stroke-width": Math.max(14, width + 8) }));
+    g.appendChild(el("line", { "class": "rm-line", x1: a.x, y1: a.y, x2: b.x, y2: b.y, "stroke-width": width.toFixed(2) }));
+    g.appendChild(el("title", {}, fmt(T.rel_between, { a: a.id, b: b.id }) + " - " + T.rel_strength + " " + num(e.w)));
+    g.addEventListener("click", function (ev) { ev.stopPropagation(); selectEdge(idx); });
+    edgeLayer.appendChild(g);
+    edgeEls.push(g);
+  });
+
+
+  nodes.forEach(function (n, idx) {
+    var g = el("g", { "class": "rm-node t" + n.t, tabindex: "0", role: "button",
+                      "aria-label": n.id + ", " + num(n.lines) + " " + T.rel_lines });
+    g.appendChild(el("circle", { cx: n.x, cy: n.y, r: n.r.toFixed(1) }));
+    g.appendChild(el("text", { x: n.label.x.toFixed(1), y: n.label.y.toFixed(1), "text-anchor": n.label.a,
+                               "class": n.label.hidden ? "rm-lbl off" : "rm-lbl" }, shorten(n.id)));
+    g.appendChild(el("title", {}, n.id + " - " + num(n.lines) + " " + T.rel_lines));
+    g.addEventListener("click", function (ev) { ev.stopPropagation(); selectNode(idx); });
+    g.addEventListener("keydown", function (ev) {
+      if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); selectNode(idx); }
+    });
+    g.addEventListener("mouseenter", function () { focusNode(idx); });
+    g.addEventListener("mouseleave", function () { if (selected === null) clearFocus(); else focusSelection(); });
+    nodeLayer.appendChild(g);
+    nodeEls.push(g);
+  });
+
+  // ---- highlighting ------------------------------------------------------------------------
+  var selected = null;                             // {type: "node"|"edge", i}
+
+  function setClasses(nodeSet, edgeSet, selNode, selEdge) {
+    var dim = !!(nodeSet || edgeSet);
+    svg.classList.toggle("dim", dim);
+    nodeEls.forEach(function (g, i) {
+      g.classList.toggle("hl", !!(nodeSet && nodeSet[i]));
+      g.classList.toggle("sel", selNode === i);
+    });
+    edgeEls.forEach(function (g, i) {
+      g.classList.toggle("hl", !!(edgeSet && edgeSet[i]));
+      g.classList.toggle("sel", selEdge === i);
+    });
+  }
+  function focusNode(i) {
+    var ns = {}, es = {};
+    ns[i] = true;
+    for (var j in neighbours[i]) { ns[j] = true; es[neighbours[i][j]] = true; }
+    setClasses(ns, es, selected && selected.type === "node" ? selected.i : null, null);
+  }
+  function clearFocus() { setClasses(null, null, null, null); }
+  function focusSelection() {
+    if (!selected) return clearFocus();
+    if (selected.type === "node") focusNode(selected.i);
+    else {
+      var e = edges[selected.i], ns = {}, es = {};
+      ns[e.a] = true; ns[e.b] = true; es[selected.i] = true;
+      setClasses(ns, es, null, selected.i);
+    }
+  }
+
+  // ---- details panel --------------------------------------------------------------------------
+  var BUCKETS = ["rel_time0", "rel_time1", "rel_time2", "rel_time3"];
+
+  function reset() {
+    selected = null;
+    clearFocus();
+    info.textContent = T.rel_pick || "";
+  }
+
+  function line(label, parts) { var p = html("p", "ri-row"); p.appendChild(html("b", "", label + " ")); p.appendChild(document.createTextNode(parts)); return p; }
+
+  function selectNode(i) {
+    selected = { type: "node", i: i };
+    focusSelection();
+    var n = nodes[i];
+    info.textContent = "";
+    info.appendChild(html("h4", "ri-title", n.id));
+    info.appendChild(html("p", "ri-meta", num(n.lines) + " " + T.rel_lines + " · " + num(n.words) + " " + T.rel_words));
+    info.appendChild(html("p", "ri-meta", T.rel_active + " " + (T[BUCKETS[n.t]] || "")));
+    if (n.partners && n.partners.length) {
+      info.appendChild(html("h5", "ri-sub", T.rel_partners));
+      var ul = html("ul", "ri-list");
+      n.partners.forEach(function (p) {
+        var li = html("li", ""), idx = -1;
+        nodes.forEach(function (o, k) { if (o.id === p[0]) idx = k; });
+        if (idx >= 0) {
+          var b = html("button", "ri-link", p[0]);
+          b.type = "button";
+          b.addEventListener("click", function () { selectNode(idx); });
+          li.appendChild(b);
+        } else li.appendChild(document.createTextNode(p[0]));
+        li.appendChild(html("span", "ri-num", num(p[1])));
+        ul.appendChild(li);
+      });
+      info.appendChild(ul);
+    }
+  }
+
+  function selectEdge(i) {
+    selected = { type: "edge", i: i };
+    focusSelection();
+    var e = edges[i], a = nodes[e.a].id, b = nodes[e.b].id;
+    info.textContent = "";
+    info.appendChild(html("h4", "ri-title", fmt(T.rel_between, { a: a, b: b })));
+    info.appendChild(html("p", "ri-meta", T.rel_strength + ": " + num(e.w)));
+    function dir(from, to, v) {
+      return line(fmt(T.rel_toward, { a: from, b: to }) + ":",
+                  num(v[0]) + " " + T.rel_direct + ", " + num(v[1]) + " " + T.rel_mentions);
+    }
+    info.appendChild(dir(a, b, e.ab));
+    info.appendChild(dir(b, a, e.ba));
+    info.appendChild(line(T.rel_replies + ":", num(e.ab[2] + e.ba[2])));
+  }
+
+  svg.addEventListener("click", reset);
+  document.addEventListener("keydown", function (ev) { if (ev.key === "Escape" && selected) reset(); });
+  info.textContent = T.rel_pick || "";
+})();

+ 247 - 0
layout/terminal.css

@@ -0,0 +1,247 @@
+/* pisg theme: terminal - Everything in monospace, green on black, like a console.
+   Generated by layout/build-themes.py, edit the palette there. */
+:root {
+  color-scheme: dark;
+  --bg: #060a07;
+  --surface: #0b120d;
+  --fg: #b9f3c4;
+  --muted: #6ea77a;
+  --line: #17301c;
+  --line2: #24492b;
+  --hover: #0f1a12;
+  --mark: #3ddc84;
+  --t0: #9085e9;
+  --t1: #c98500;
+  --t2: #199e70;
+  --t3: #d95926;
+  --male: #7fd6ff;
+  --female: #ff9ad0;
+  --shadow: 0 18px 32px -14px rgba(0,0,0,.6);
+}
+
+/* pisg output is table markup, so these rules restyle the existing classes (headtext, hicell,
+   tdtop, rankc ...). The generator wraps each section in <div class="card" id="...">. */
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+
+body {
+  margin: 0;
+  padding: 32px 24px 96px;
+  background: var(--bg);
+  color: var(--fg);
+  font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+  font-size: 15px;
+  line-height: 1.55;
+}
+body > div { max-width: 1040px; margin: 0 auto; text-align: left; }   /* pisg centres it with align=center */
+
+/* Fixed 925/929px table widths are attributes, so plain CSS overrides them. */
+table { border-collapse: separate; border-spacing: 0; max-width: 100%; }
+table[width] { width: 100%; }
+table[width="520"] { width: auto; margin: 10px 0 0; }
+td { color: var(--fg); font-family: inherit; font-size: 14px; text-align: left; padding: 9px 10px; vertical-align: middle; overflow-wrap: anywhere; }   /* long URLs and nicks wrap instead of forcing a scrollbar */
+
+a, a:link, a:visited { color: inherit; font-weight: 600; text-decoration: underline; text-decoration-color: var(--line2); text-underline-offset: 3px; }
+a:hover { text-decoration-color: currentColor; }
+a:focus-visible, summary:focus-visible, button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
+a.background, a.background:link, a.background:visited { color: var(--muted); font-weight: 500; }
+
+/* ---- Masthead: reads like an IRC header, "#channel @ network" ---- */
+.title, #pagetitle1 {
+  margin: 4px 0 8px;
+  font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+  font-size: clamp(24px, 4.6vw, 40px);
+  line-height: 1.15;
+  font-weight: 700;
+  letter-spacing: -0.035em;
+  color: var(--fg);
+  overflow-wrap: anywhere;
+}
+.subtitle { margin: 0 0 18px; color: var(--muted); font-size: 14px; }
+.subtitle b { color: var(--fg); font-weight: 600; }
+
+/* ---- Sections: a rule and space, no boxes ---- */
+.card { margin: 56px 0 0; padding: 20px 0 0; border-top: 1px solid var(--line2); overflow-x: auto; text-align: left; scroll-margin-top: 64px; }
+.card > br { display: none; }
+.pisg-nav + .card { margin-top: 28px; border-top: 0; padding-top: 0; }
+#overview .headlinebg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }   /* the sentence below is its headline; the menu still links here */
+.card > table:first-of-type { margin-bottom: 8px; }
+.headlinebg { background: none; padding: 0; }
+.headtext { margin: 0; padding: 0; border: 0; background: none; color: var(--fg);
+            font-size: 20px; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; text-align: left; }
+.intro { margin: 2px 0 16px; max-width: 68ch; color: var(--muted); }
+
+/* ---- Wide screens: a menu down the left, the page fills the rest ---- */
+body { container-type: inline-size; }
+.nav-side { display: none; }
+@media (min-width: 1000px) {
+  body { padding-left: 272px; padding-right: 28px; }
+  body > div { max-width: 1720px; }
+  .pisg-nav { box-sizing: border-box; position: fixed; top: 0; left: 0; bottom: 0; width: 232px; margin: 0; padding: 26px 12px 24px 24px;
+              border: 0; border-right: 1px solid var(--line2); background: var(--bg); overflow-y: auto; z-index: 30; }
+  .nav-menu { display: none; }
+  .nav-side { display: block; }
+  .nav-brand, .nav-brand:link, .nav-brand:visited { display: block; margin: 0 0 18px; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 18px; font-weight: 700;
+              letter-spacing: -0.02em; text-decoration: none; overflow-wrap: anywhere; }
+  .nav-side-list { margin: 0; padding: 0; list-style: none; }
+  .nav-side-list a, .nav-side-list a:link, .nav-side-list a:visited { display: block; margin-left: -10px; padding: 6px 10px; border-left: 2px solid transparent;
+              color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; }
+  .nav-side-list a:hover { color: var(--fg); background: var(--hover); }
+  .nav-side-list a.active { color: var(--fg); font-weight: 650; border-left-color: var(--mark); }
+  .card { scroll-margin-top: 24px; }
+  .card td:not(.headtext):not(.headlinebg) { padding-left: 8px; padding-right: 8px; }   /* the 10-column nick table just fits beside the menu */
+  .pisg-nav + .card { margin-top: 20px; }
+}
+/* Two short sections share a row when the page is wide enough (measured on the page, so it also holds inside a frame). */
+@container (min-width: 1080px) {
+  body > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 56px; grid-auto-flow: dense; }
+  body > div > * { grid-column: 1 / -1; }
+  body > div > .card.half { grid-column: auto; }
+}
+
+/* Way back to the landing page (option HomeLink) */
+.homebar { margin: 0 0 14px; text-align: left; }
+.homebtn, .homebtn:link, .homebtn:visited { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--line2); border-radius: 6px;
+  background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 600; line-height: 1.3; text-decoration: none; }
+.homebtn:hover { background: var(--hover); border-color: var(--fg); }
+
+/* Column titles */
+.tdtop { background: none; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em;
+         text-transform: uppercase; border-bottom: 1px solid var(--line2); padding: 8px 10px; }
+.tdtop { white-space: nowrap; }   /* column titles stay on one line; the table scrolls instead */
+.tdtop b { font-weight: 600; }
+
+/* Rows: one hairline each */
+.hicell { background: none; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 10px; }
+.hicell10 { background: none; font-size: 12px; border-bottom: 1px solid var(--line); }
+tr:last-child > .hicell { border-bottom: 0; }
+/* pisg writes an inline colour gradient on the top-nicks rows (HiCell/HiCell2); the theme owns colour. */
+td[style*="background-color"] { background: transparent !important; border-bottom: 1px solid var(--line); }
+tr:hover > td[style*="background-color"], tr:hover > .rankc, tr:hover > .hirankc { background: var(--hover) !important; }
+
+.rankc, .hirankc, .rankc10 { background: transparent; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px;
+                              font-weight: 500; text-align: center; border-bottom: 1px solid var(--line); }
+.hirankc { color: var(--fg); font-weight: 700; }
+.rankc, .hirankc, .rankc10 { white-space: nowrap; }
+.rankc10 { text-align: left; }
+/* nicks are set in monospace, as in an IRC client */
+.rankc + td, .hirankc + td { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.small, .asmall { color: var(--muted); font-family: inherit; font-size: 12px; }
+.asmall { text-align: center; }
+
+/* ---- Bar charts (marks: thin, 4px rounded data end, 2px gap between segments) ---- */
+table:not([width]) { width: 100%; table-layout: fixed; margin: 6px 0; }
+td.asmall { padding: 0 2px; line-height: 1.3; vertical-align: bottom; }   /* bars stand on one baseline */
+table:not([width]) tr:first-child > td.asmall { border-bottom: 1px solid var(--line2); }
+td.asmall br { display: none; }
+td.rankc10center, td.hirankc10center { background: none; border: 0; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
+                                         font-size: 11px; text-align: center; padding: 6px 0 0; min-width: 0; letter-spacing: -0.02em; }   /* no side padding: 24 narrow columns must not poke out of the table */
+td.hirankc10center { color: var(--fg); font-weight: 700; }
+/* a number over every bar is noise: the peak keeps its label, the rest are in the tooltip */
+td.asmall .v { display: none; }
+td.asmall .v.pk { display: block; color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+.rankc10center.skip { visibility: hidden; }
+
+img[id$="-v"] { display: block; width: min(64%, 22px); margin: 0 auto; border-radius: 0; }
+img[id$="-v"]:first-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-v"]:not(:first-of-type) { margin-top: 2px; }
+img[id$="-v"]:only-of-type { border-radius: 4px 4px 0 0; }
+img[id$="-h"] { height: 8px; vertical-align: middle; margin: 0 1px 0 0; border-radius: 0; }
+td[nowrap] img[id$="-h"] { background: var(--mark); margin-right: 0; }   /* lines: one value, one colour; the "when" column keeps the four */
+img[id$="-h"]:first-of-type { border-radius: 4px 0 0 4px; }
+img[id$="-h"]:last-of-type { margin-right: 0; border-radius: 0 4px 4px 0; }
+img[id$="-h"]:only-of-type { border-radius: 4px; }
+td.asmall img[id$="-h"] { margin-right: 6px; }
+table[width="520"] img[id$="-h"] { width: 14px; height: 8px; border-radius: 2px; }   /* legend: a colour key, not a bar */
+table[width="520"] td.asmall { text-align: left; padding: 0 14px 0 0; white-space: nowrap; }
+/* the colour key wraps instead of forcing a sideways scrollbar on a narrow screen */
+table[width="520"] { display: flex; flex-wrap: wrap; gap: 4px 0; width: auto; max-width: 100%; }
+table[width="520"] tbody, table[width="520"] tr { display: contents; }
+img[id="blue-h"],   img[id="blue-v"]   { background: var(--t0); }
+img[id="green-h"],  img[id="green-v"]  { background: var(--t1); }
+img[id="yellow-h"], img[id="yellow-v"] { background: var(--t2); }
+img[id="red-h"],    img[id="red-v"]    { background: var(--t3); }
+
+td[rowspan] img { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; display: block; margin: 0 auto; }
+td[rowspan] img.defpic { width: 26px; height: 26px; opacity: .3; }   /* the shared default picture: a quiet placeholder */
+
+/* ranked tables: one left edge, and no stray rule under the last row */
+tr:first-child > td:not([class]):first-child { border-bottom: 1px solid var(--line2); }
+tr:last-child > .rankc, tr:last-child > .hirankc, tr:last-child > td[style*="background-color"] { border-bottom: 0; }
+.rankc, .hirankc { width: 44px; }
+td[style*="background-color"] { white-space: nowrap; }          /* "3 days ago" stays on one line ... */
+td[style*="background-color"].quote { white-space: normal; }    /* ... only the quote may wrap */
+.male, .male a { color: var(--male); }
+.female, .female a { color: var(--female); }
+.bot, .bot a { color: var(--muted); font-style: italic; }
+
+/* single-series bars */
+.bar { display: inline-block; vertical-align: middle; width: 140px; max-width: 100%; height: 8px; border-radius: 4px; background: var(--line); overflow: hidden; }
+.bar span { display: block; height: 100%; border-radius: 4px; background: var(--mark); opacity: 1; }
+.sigword { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; }
+
+/* "These didn't make it to the top:" is a bare <b><i> under the table; give it the same voice as a column title */
+.card > b { display: block; margin: 26px 0 4px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.card > b > i { font-style: normal; }
+
+#totallines { margin-top: 56px; padding-top: 14px; border-top: 1px solid var(--line2); color: var(--muted); font-size: 12px; }
+#footer { margin-top: 2px; color: var(--muted); }
+#footer .small { font-size: 12px; }
+::selection { background: var(--fg); color: var(--bg); }
+
+/* ---- Section menu: one slim bar, one panel ---- */
+.pisg-nav { background: var(--bg); border-bottom-color: var(--line2); margin-bottom: 0; }
+.nav-current { color: var(--fg); font-weight: 650; }
+.nav-toggle { color: var(--muted); opacity: 1; }
+.nav-list { background: var(--surface); border-color: var(--line2); box-shadow: var(--shadow); }
+.nav-list a, .nav-list a:link, .nav-list a:visited { color: var(--fg); font-weight: 500; text-decoration: none; }
+.nav-list a:hover { background: var(--hover); }
+.nav-list a.active { font-weight: 700; }
+
+/* ---- Overview: a sentence and a list of facts, not a wall of tiles ---- */
+.hero { margin: 2px 0 22px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 650; letter-spacing: -0.02em; text-wrap: balance; }
+.hero b { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 700; letter-spacing: -0.05em; }
+.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 56px; margin: 0; }
+.fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 11px 0; border-bottom: 1px solid var(--line); }
+.fact dt { color: var(--muted); }
+.fact dd { margin: 0; text-align: right; font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-weight: 600; font-variant-numeric: tabular-nums; }
+.fact dd small { display: block; color: var(--muted); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; font-weight: 400; }
+
+/* ---- Roles, personalities: columns split by a rule, no boxes ---- */
+.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 28px 32px; margin: 8px 0 0; }
+.minicard { display: flex; flex-direction: column; gap: 3px; padding: 12px 0 0; border: 0; border-top: 1px solid var(--line2); border-radius: 0; background: none; text-align: left; }
+.mc-title { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.mc-name { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 19px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; }
+.mc-desc { color: var(--muted); font-size: 13px; }
+.mc-value { margin-top: 6px; font-size: 13px; font-weight: 600; }
+.mc-list { margin: 8px 0 0; padding: 0; list-style: none; }
+.mc-list li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 0; border-top: 1px solid var(--line); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 13px; }
+.mc-list span { color: var(--muted); }
+.minicard.t0 { border-top: 3px solid var(--t0); } .minicard.t1 { border-top: 3px solid var(--t1); }
+.minicard.t2 { border-top: 3px solid var(--t2); } .minicard.t3 { border-top: 3px solid var(--t3); }
+
+/* ---- Relation map: the one lifted surface ---- */
+.relmap-canvas svg { box-sizing: border-box; background: var(--surface); border: 1px solid var(--line2); border-radius: 8px; }   /* width:100% must include the border */
+.relmap-info { background: none; border: 0; border-top: 1px solid var(--line2); border-radius: 0; padding: 12px 0 0; }
+.relmap-legend { color: var(--muted); }
+.ri-title { font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; color: var(--fg); }
+.ri-sub, .ri-num { color: var(--muted); opacity: 1; }
+.ri-list li { border-top-color: var(--line); }
+.ri-link { color: var(--fg); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; text-decoration-color: var(--line2); }
+.lg0 i, .rm-node.t0 circle { background: var(--t0); fill: var(--t0); } .lg1 i, .rm-node.t1 circle { background: var(--t1); fill: var(--t1); }
+.lg2 i, .rm-node.t2 circle { background: var(--t2); fill: var(--t2); } .lg3 i, .rm-node.t3 circle { background: var(--t3); fill: var(--t3); }
+.rm-line { stroke: var(--muted); }
+.rm-node circle { stroke: var(--surface); }
+.rm-node text { fill: var(--fg); stroke: var(--surface); font-family: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
+.rm-edge.sel .rm-line { stroke: var(--fg); }
+.rm-node:focus-visible circle, .rm-node.sel circle { stroke: var(--fg); }
+
+@media (max-width: 720px) {
+  body { padding: 20px 16px 72px; font-size: 14px; }
+  .hr.rankc10center:nth-child(even) { visibility: hidden; }     /* every second hour label on a phone */
+  td { padding: 7px 6px; font-size: 13px; }
+  .bar { width: 64px; }
+  .card { margin-top: 44px; }
+  .facts { grid-template-columns: 1fr; }
+  td[rowspan] img { width: 30px; height: 30px; }
+}

+ 24 - 2
modules/Pisg.pm

@@ -115,6 +115,7 @@ sub get_default_config_settings
         logsuffix => '',
         silent => 0,
         cachedir => '',
+        channelindex => 'channels.json',
         userpics => 'y',
 
         # Colors / Layout
@@ -208,6 +209,16 @@ sub get_default_config_settings
         violentwords => 'slaps beats smacks',
         chartsregexp => '(?:is )?(?:np:|(?:now )?playing:? |listening to:? )(?:MPEG stream from)?\s*(.*)',
         ignorewords => '',
+        badurls => '',
+        shownavbar => 1,
+        homelink => '',
+        showoverview => 1,
+        showrelations => 1,
+        relationnicks => 30,
+        relationminweight => 3,
+        showtimepersonalities => 1,
+        showconcentration => 1,
+        showsignaturewords => 1,
         noignoredquotes => 0,
         tablewidth => 574,
         regexpaliases => 0,
@@ -218,7 +229,7 @@ sub get_default_config_settings
         modules_dir => '',         # set in get_cmdline_options
         cchannels => '',           # set in get_cmdline_options
 
-        version => "0.80-preview2"
+        version => "1.0.a"
     };
 
     # This enables us to use the search_path in other modules
@@ -478,6 +489,7 @@ sub do_channel
     } else {
         $self->init_pisg();        # Init some general things
 
+        $self->{aliases_stored} = 1;
         store_aliases();           # Save the aliases so we can restore them
                                    # later, we don't want to add the aliases
                                    # for this channel to the next channel
@@ -533,6 +545,7 @@ _END
         }
 
         restore_aliases();
+        $self->{aliases_stored} = 0;
 
         $self->{cfg}->{chan_done}{$self->{cfg}->{channel}} = 1;
     }
@@ -567,7 +580,16 @@ sub parse_channels
         foreach my $chan (keys %{ $channel }) { # import channel specific config
             $self->{cfg}->{$_} = $channel->{$chan}->{$_} foreach (keys %{ $channel->{$chan} });
         }
-        $self->do_channel();
+        # One channel that cannot be read (a missing log folder, a bad format) must not stop the others:
+        # say so, carry on, and let the exit status tell cron that something failed.
+        my $name = $self->{cfg}->{channel};
+        unless (eval { $self->do_channel(); 1 }) {
+            my $why = $@; $why =~ s/\s+$//;
+            print STDERR "Skipped channel $name: $why\n";
+            $self->{failed_channels}++;
+            restore_aliases() if $self->{aliases_stored};
+            $self->{aliases_stored} = 0;
+        }
         $origcfg{chan_done} = $self->{cfg}->{chan_done};
         %{ $self->{cfg} } = %origcfg;
     }

+ 186 - 28
modules/Pisg/HTMLGenerator.pm

@@ -6,6 +6,8 @@ package Pisg::HTMLGenerator;
 # found at the end of the file.
 
 use strict;
+use Encode ();
+use Pisg::Insights;
 $^W = 1;
 
 # test for Text::Iconv
@@ -33,7 +35,27 @@ sub new
     return $self;
 }
 
+# The page is written to a temporary file and moved into place only when it is complete,
+# so an error part-way through can never leave a half-written page where the good one was.
 sub create_output
+{
+    my ($self, $lang) = @_;
+    my $ok = eval { $self->_create_output_inner($lang); 1 };
+    my $err = $@;
+    my ($tmp, $final) = @{$self}{qw(outfile_tmp outfile_final)};
+    close(OUTPUT);
+    if (defined $tmp and -e $tmp) {
+        if ($ok) {
+            rename($tmp, $final) or do { unlink($tmp); die("$0: Unable to write $final: $!\n"); };
+        } else {
+            unlink($tmp);
+        }
+    }
+    delete @{$self}{qw(outfile_tmp outfile_final)};
+    die $err unless $ok;
+}
+
+sub _create_output_inner
 {
     # This subroutine calls all the subroutines which create their
     # individual stats. The name of the functions is somewhat saying - if
@@ -72,7 +94,9 @@ sub create_output
     print "Now generating HTML ($self->{cfg}->{lang}) in $fname...\n"
         unless ($self->{cfg}->{silent});
 
-    open (OUTPUT, "> $fname") or
+    $self->{outfile_final} = $fname;
+    $self->{outfile_tmp} = "$fname.tmp$$";
+    open (OUTPUT, "> $self->{outfile_tmp}") or
         die("$0: Unable to open outputfile($fname): $!\n");
 
     if ($self->{cfg}->{showlines}) {
@@ -100,10 +124,15 @@ sub create_output
         $self->{cfg}->{tablewidth} += $self->{cfg}->{userpics} * ($self->{cfg}->{picwidth} || 60);
     }
     $self->{cfg}->{headwidth} = $self->{cfg}->{tablewidth} - 4;
+    $self->{card_open} = 0;
+    $self->{nav} = [];
+    $self->{ins_script_relmap} = 0;
     $self->_htmlheader();
     $self->_pageheader()
         if ($self->{cfg}->{pagehead} ne 'none');
 
+    $self->_ins_safe('_insights_overview') if $self->{cfg}->{showoverview};
+
     if ($self->{cfg}->{dailyactivity}) {
         $self->_activedays();
     }
@@ -116,12 +145,18 @@ sub create_output
         $self->_activenicks();
     }
 
+    $self->_ins_safe('_insights_relations') if $self->{cfg}->{showrelations};
+
     if ($self->{cfg}->{showmostactivebyhour}) {
         $self->_mostactivebyhour();
     }
 
+    $self->_ins_safe('_insights_personalities') if $self->{cfg}->{showtimepersonalities};
+    $self->_ins_safe('_insights_concentration') if $self->{cfg}->{showconcentration};
+    $self->_ins_safe('_insights_signature') if $self->{cfg}->{showsignaturewords};
+
     if ($self->{cfg}->{showbignumbers}) {
-        $self->_headline($self->_template_text('bignumtopic'));
+        $self->_headline($self->_template_text('bignumtopic'), undef, undef, 'bignumtopic');
         _html("<table width=\"$self->{cfg}->{tablewidth}\">"); # Needed for sections
         $self->_questions();
         $self->_shoutpeople();
@@ -168,7 +203,7 @@ sub create_output
     }
 
     if ($self->{cfg}->{showbignumbers}) {
-        $self->_headline($self->_template_text('othernumtopic'));
+        $self->_headline($self->_template_text('othernumtopic'), undef, undef, 'othernumtopic');
         _html("<table width=\"$self->{cfg}->{tablewidth}\">"); # Needed for sections
         $self->_gotkicks();
         $self->_mostkicks();
@@ -183,7 +218,7 @@ sub create_output
     }
 
     if ($self->{cfg}->{showtopics}) {
-        $self->_headline($self->_template_text('latesttopic'));
+        $self->_headline($self->_template_text('latesttopic'), undef, undef, 'latesttopic');
         _html("<table width=\"$self->{cfg}->{tablewidth}\">"); # Needed for sections
 
         $self->_lasttopics();
@@ -191,6 +226,10 @@ sub create_output
         _html("</table>"); # Needed for sections
     }
 
+    _html('</div>') if $self->{card_open};
+    $self->{card_open} = 0;
+    $self->_insights_scripts();
+
     my %hash = ( lines => $self->{stats}->{parsedlines} );
     _html('<div id="totallines">' . $self->_template_text('totallines', %hash) . '</div>');
 
@@ -201,10 +240,100 @@ sub create_output
 
     close(OUTPUT);
 
+    $self->_insert_navbar();
+    $self->_update_channel_index();
+
     # restore tablewidth
     $self->{cfg}->{tablewidth} = $tablewidth_original;
 }
 
+# Keep a small JSON list of the channels found in the output directory, so a
+# landing page can offer a channel dropdown straight from pisg's own knowledge
+# (no scanning or scraping of the generated HTML). Each run updates only this
+# channel's entry, so running channels one at a time works too. Disable with
+# ChannelIndex="none".
+sub _update_channel_index
+{
+    my $self = shift;
+    my $name = $self->{cfg}->{channelindex};
+    return if !defined $name or $name eq '' or lc($name) eq 'none';
+    return unless eval { require JSON::PP; 1 };
+
+    my $out = $self->{outfile_final} || $self->{cfg}->{outputfile};
+    my ($dir, $file) = $out =~ m{^(.*)/([^/]+)$} ? ($1, $2) : ('.', $out);
+    $dir = '/' if $dir eq '';
+    my $path = "$dir/$name";
+    # No utf8 layer: names come from the config as bytes and go back out as bytes.
+    my $json = JSON::PP->new->canonical->pretty;
+
+    my @channels;
+    if (open(my $in, '<', $path)) {
+        local $/;
+        my $data = <$in>;
+        close($in);
+        my $old = eval { $json->decode($data) };
+        @channels = @{ $old->{channels} }
+            if ref $old eq 'HASH' and ref $old->{channels} eq 'ARRAY';
+    }
+    # Replace this channel's entry; forget pages that no longer exist.
+    @channels = grep { ref $_ eq 'HASH' and defined $_->{file}
+                       and $_->{file} ne $file and -e "$dir/$_->{file}" } @channels;
+
+    my @t = gmtime();
+    push @channels, {
+        name    => $self->{cfg}->{channel},
+        file    => $file,
+        network => $self->{cfg}->{network},
+        updated => sprintf("%04d-%02d-%02dT%02d:%02d:%02dZ", $t[5] + 1900, $t[4] + 1, @t[3, 2, 1, 0]),
+        days    => 0 + ($self->{stats}->{days} || 0),
+        nicks   => 0 + scalar(keys %{ $self->{stats}->{lines} || {} }),
+        lines   => 0 + ($self->{stats}->{parsedlines} || 0),
+        %{ $self->_channel_index_extras },
+    };
+    @channels = sort { lc($a->{name}) cmp lc($b->{name}) } @channels;
+
+    my $tmp = "$path.tmp$$";
+    if (open(my $fh, '>', $tmp)) {
+        print $fh $json->encode({ version => 1, channels => \@channels });
+        close($fh);
+        rename($tmp, $path) or unlink($tmp);
+    } else {
+        print STDERR "Warning: cannot write channel index $path: $!\n" unless $self->{cfg}->{silent};
+    }
+}
+
+# A few numbers per channel for the landing page, which shows the statistics of the statistics:
+# what the channel is busy with hour by hour, how the last weeks went, who talks most.
+sub _channel_index_extras
+{
+    my $self = shift;
+    my $st = $self->{stats};
+    my %x;
+    my $sum = sub { my $t = 0; $t += $_ for values %{ $_[0] || {} }; return $t; };
+    $x{words}     = 0 + $sum->($st->{words});
+    $x{questions} = 0 + $sum->($st->{questions});
+    $x{joins}     = 0 + $sum->($st->{joins});
+    $x{links}     = 0 + $sum->($st->{urlcounts});
+    $x{hours}     = [ map { 0 + ($st->{times}{ sprintf('%02d', $_) } || 0) } 0 .. 23 ];
+
+    # Lines on each of the last 30 days of the log (days are counted as pisg counts them everywhere).
+    my $dl = $st->{day_lines} || [];
+    my $last = $#$dl;
+    my $first = $last - 29; $first = 1 if $first < 1;
+    $x{recent} = [ map { 0 + ($dl->[$_] || 0) } $first .. $last ] if $last >= 1;
+
+    # Top talkers, bots left out. Names go out as valid UTF-8 whatever the log's charset was.
+    my @top;
+    my $humans = eval { $self->_ins_humans } || [];
+    for my $n (@$humans[0 .. ($#$humans > 4 ? 4 : $#$humans)]) {
+        next unless defined $n;
+        my $clean = Encode::encode('UTF-8', Encode::decode('UTF-8', $n, Encode::FB_DEFAULT | Encode::LEAVE_SRC));
+        push @top, { nick => $clean, lines => 0 + ($st->{lines}{$n} || 0) };
+    }
+    $x{top} = \@top;
+    return \%x;
+}
+
 sub _htmlheader
 {
     my $self = shift;
@@ -238,6 +367,13 @@ sub _htmlheader
         }
     }
 
+    # Styles for the newer sections, so any theme (even a 2003 one) shows them decently;
+    # a theme that styles them comes later and wins.
+    if ($self->{cfg}->{colorscheme} ne "none") {
+        my $base = $self->_ins_basecss;
+        $CSS = "<style type=\"text/css\" id=\"pisg-base\">\n$base</style>\n" . $CSS if length $base;
+    }
+
     my $title = $self->_template_text('pagetitle1', %hash);
     if($self->{cfg}->{colorscheme} ne "none") {
         _html( <<HTML );
@@ -245,6 +381,7 @@ sub _htmlheader
 <html xmlns="http://www.w3.org/1999/xhtml">
 <head>
 <meta http-equiv="Content-Type" content="text/html; charset=$self->{cfg}->{charset}" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
 <title>$title</title>
 $CSS
 </head>
@@ -252,9 +389,16 @@ $CSS
 <div align="center">
 HTML
     }
+    # Optional way back to the landing page (option HomeLink): a relative page or an http(s) address.
+    my $home = $self->{cfg}->{homelink};
+    if (defined $home and $home =~ m{^(?:[\w.\-/]+|https?://[^\s"'<>]+)$} and lc($home) ne 'none' and $self->{cfg}->{colorscheme} ne 'none') {
+        _html('<p class="homebar"><a class="homebtn" id="homebtn" href="' . $home . '"><span aria-hidden="true">&larr;</span> '
+              . $self->_template_text('home_label') . '</a></p>');
+    }
     _html('<h1 class="title" id="pagetitle1">' . $title . '</h1>');
     _html('<p class="subtitle"><span id="pagetitle2">' . $self->_template_text('pagetitle2', %hash) . ' ' . $self->get_time() . '</span><br />');
     _html('<span id="pagetitle3">' . $self->_template_text('pagetitle3', %hash) . '</span></p>');
+    $self->_nav_marker;
 
 }
 
@@ -354,7 +498,14 @@ HTML
 sub _headline
 {
     my $self = shift;
-    my ($title) = (@_);
+    my ($title, $id, $label, $key) = (@_);
+    # Wrap each section in a <div class="card"> so themes can box sections up.
+    # Themes that don't style .card render exactly as before. The id is what the
+    # navbar links to.
+    $id = $self->_nav_register($title, $id, $label);
+    _html('</div>') if $self->{card_open};
+    _html('<div class="card' . ($self->_card_is_half($key) ? ' half' : '') . '" id="' . $id . '">');
+    $self->{card_open} = 1;
     _html( <<HTML );
    <br />
    <table width="$self->{cfg}->{headwidth}" cellpadding="1" cellspacing="0" border="0">
@@ -422,21 +573,23 @@ sub _activedays
     my %hash = (
         n => $ndays
     );
-    $self->_headline($self->_template_text('dailyactivitytopic', %hash));
+    $self->_headline($self->_template_text('dailyactivitytopic', %hash), undef, undef, 'dailyactivitytopic');
 
     _html("<table border=\"0\"><tr>");
 
+    my $peak_done = 0;
     for (my $day = $days - $ndays + 1; $day <= $days ; $day++) {
         my $lines = $self->{stats}->{day_lines}[$day];
-        _html("<td align=\"center\" valign=\"bottom\" class=\"asmall\">$lines<br />");
+        my $pk = ($lines == $highest_value && !$peak_done++) ? ' pk' : '';   # only the busiest day keeps its number
+        _html("<td align=\"center\" valign=\"bottom\" class=\"asmall\"><span class=\"v$pk\">$lines</span><br />");
         for (my $time = 4; $time >= 0; $time--) {
             if (defined($self->{stats}->{day_times}[$day][$time])) {
-                my $size = int(($self->{stats}->{day_times}[$day][$time] / $highest_value) * 100);
+                my $size = int(($self->{stats}->{day_times}[$day][$time] / $highest_value) * 130);
 
                 my $image = "pic_v_".$time*6;
                 $image = $self->{cfg}->{$image};
                 _html("<img id=\"$image\" src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=
-\" width=\"15\" height=\"$size\" alt=\"$size\" title=\"$size\" /><br />") if $size;
+\" width=\"15\" height=\"$size\" alt=\"$self->{stats}->{day_times}[$day][$time]\" title=\"$self->{stats}->{day_times}[$day][$time]\" /><br />") if $size;
 
             }
         }
@@ -446,7 +599,8 @@ sub _activedays
     _html("</tr><tr>");
 
     for (my $day = $ndays - 1; $day >= 0 ; $day--) {
-        _html("<td class=\"rankc10center\" align=\"center\">$day</td>");
+        my $skip = ($day % 5 == 0) ? '' : ' skip';                   # label every 5th day only
+        _html("<td class=\"rankc10center$skip\" align=\"center\">$day</td>");
     }
 
     _html("</tr></table>");
@@ -463,22 +617,24 @@ sub _activetimes
 
     my (%output);
 
-    $self->_headline($self->_template_text('activetimestopic'));
+    $self->_headline($self->_template_text('activetimestopic'), undef, undef, 'activetimestopic');
 
     my @toptime = sort { $self->{stats}->{times}{$b} <=> $self->{stats}->{times}{$a} } keys %{ $self->{stats}->{times} };
 
     my $highest_value = $self->{stats}->{times}{$toptime[0]};
 
+    my $peak_done = 0;
     for my $hour (sort keys %{ $self->{stats}->{times} }) {
 
-        my $size = int(($self->{stats}->{times}{$hour} / $highest_value) * 100);
+        my $size = int(($self->{stats}->{times}{$hour} / $highest_value) * 110);
         my $percent = sprintf("%.1f", ($self->{stats}->{times}{$hour} / $self->{stats}->{parsedlines}) * 100);
         my $lines_per_hour = $self->{stats}->{times}{$hour};
+        my $pk = ($lines_per_hour == $highest_value && !$peak_done++) ? ' pk' : '';   # only the peak hour keeps its label
 
         my $image = "pic_v_".(int($hour/6)*6);
         $image = $self->{cfg}->{$image};
 
-        $output{$hour} = "<td align=\"center\" valign=\"bottom\" class=\"asmall\">$percent%<br /><img id=\"$image\" src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=
+        $output{$hour} = "<td align=\"center\" valign=\"bottom\" class=\"asmall\"><span class=\"v$pk\">$percent%</span><br /><img id=\"$image\" src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=
 \" width=\"15\" height=\"$size\" alt=\"$lines_per_hour\" title=\"$lines_per_hour\"/></td>" if $size;
     }
 
@@ -488,7 +644,7 @@ sub _activetimes
         $a = sprintf("%02d", $b);
 
         if (!defined($output{$a})) {
-            _html("<td align=\"center\" valign=\"bottom\" class=\"asmall\">0%</td>");
+            _html("<td align=\"center\" valign=\"bottom\" class=\"asmall\"><span class=\"v\">0%</span></td>");
         } else {
             _html($output{$a});
         }
@@ -502,7 +658,7 @@ sub _activetimes
     for ($b = 0; $b < 24; $b++) {
         # Highlight the top time
         my $class = $toptime[0] == $b ? 'hirankc10center' : 'rankc10center';
-        _html("<td class=\"$class\" align=\"center\">$b</td>");
+        _html("<td class=\"$class hr\" align=\"center\">$b</td>");
     }
 
     _html("</tr></table>");
@@ -517,7 +673,7 @@ sub _activenicks
     # The most active nicks (those who wrote most lines)
     my $self = shift;
 
-    $self->_headline($self->_template_text('activenickstopic'));
+    $self->_headline($self->_template_text('activenickstopic'), undef, undef, 'activenickstopic');
 
     my $output = "";
     $output .= "<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>";
@@ -652,8 +808,8 @@ sub _activenicks
         
         $output .= "<td $style>" . sprintf("%.1f", $w/$line) . "</td>"  if ($self->{cfg}->{showwpl});
         $output .= "<td $style>" . sprintf("%.1f", $ch/$line) . "</td>" if ($self->{cfg}->{showcpl});
-        $output .= "<td $style>$lastseen</td>"                          if ($self->{cfg}->{showlastseen});
-        $output .= "<td $style>\"$randomline\"</td>"                    if ($self->{cfg}->{showrandquote});
+        $output .= "<td $style class=\"lastseen\">$lastseen</td>"        if ($self->{cfg}->{showlastseen});
+        $output .= "<td $style class=\"quote\">\"$randomline\"</td>"      if ($self->{cfg}->{showrandquote});
 
         _html($output);
         undef $output;
@@ -1756,6 +1912,7 @@ sub _mostusedword
         # Skip people's nicks.
         next if is_nick($word, $self->{cfg}->{cachedir});
         next if (length($word) < $self->{cfg}->{wordlength});
+        next unless $self->_has_letters($word);          # ASCII art and punctuation are not words
         $usages{$word} = $self->{stats}->{wordcounts}{$word};
     }
 
@@ -1763,7 +1920,7 @@ sub _mostusedword
     my @popular = sort { $usages{$b} <=> $usages{$a} } keys %usages;
 
     if (@popular) {
-        $self->_headline($self->_template_text('mostwordstopic'));
+        $self->_headline($self->_template_text('mostwordstopic'), undef, undef, 'mostwordstopic');
 
         _html("<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>");
         _html("<td>&nbsp;</td><td class=\"tdtop\"><b>" . $self->_template_text('word') . "</b></td>");
@@ -1856,7 +2013,7 @@ sub _mostreferencednicks
 
     if (@popular) {
 
-        $self->_headline($self->_template_text('referencetopic'));
+        $self->_headline($self->_template_text('referencetopic'), undef, undef, 'referencetopic');
 
         _html("<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>");
         _html("<td>&nbsp;</td><td class=\"tdtop\"><b>" . $self->_template_text('nick') . "</b></td>");
@@ -1899,7 +2056,7 @@ sub _smileys
     my @popular = sort { $usages{$b} <=> $usages{$a} } keys %usages;
     return unless @popular;
 
-    $self->_headline($self->_template_text('smileytopic'));
+    $self->_headline($self->_template_text('smileytopic'), undef, undef, 'smileytopic');
 
     _html("<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>");
     _html("<td>&nbsp;</td><td class=\"tdtop\"><b>" . $self->_template_text('smiley') . "</b></td>");
@@ -1940,7 +2097,7 @@ sub _karma
     my @popular = sort { $karma{$b} <=> $karma{$a} } keys %karma;
     return unless @popular;
 
-    $self->_headline($self->_template_text('karmatopic'));
+    $self->_headline($self->_template_text('karmatopic'), undef, undef, 'karmatopic');
 
     _html("<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>");
     _html("<td>&nbsp;</td><td class=\"tdtop\"><b>" . $self->_template_text('nick') . "</b></td>");
@@ -2017,7 +2174,7 @@ sub _mosturls
 
     if (@sorturls) {
 
-        $self->_headline($self->_template_text('urlstopic'));
+        $self->_headline($self->_template_text('urlstopic'), undef, undef, 'urlstopic');
 
         _html("<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>");
         _html("<td>&nbsp;</td><td class=\"tdtop\"><b>" . $self->_template_text('url') . "</b></td>");
@@ -2056,7 +2213,7 @@ sub _charts
 
     if (@sortcharts) {
 
-        $self->_headline($self->_template_text('chartstopic'));
+        $self->_headline($self->_template_text('chartstopic'), undef, undef, 'chartstopic');
 
         _html("<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>");
         _html("<td>&nbsp;</td><td class=\"tdtop\"><b>" . $self->_template_text('song') . "</b></td>");
@@ -2241,7 +2398,8 @@ sub _user_pic
     my $width = $self->{cfg}->{picwidth} ? " width=\"$self->{cfg}->{picwidth}\"" : "";
     my $alt = $self->{users}->{userpics}{$nick} ? " alt=\"$nick\" title=\"$nick\"" : ' alt=""';
     my $border = $biguserpic ? ' border="0"' : '';
-    $output .= "<img src=\"$pic\"$width$height$alt$border />";
+    my $cls = $self->{users}->{userpics}{$nick} ? '' : ' class="defpic"';      # the shared default picture, so themes can tone it down
+    $output .= "<img src=\"$pic\"$cls$width$height$alt$border />";
 
     $output .= "</a>" if $biguserpic;
     _html("$output</td>");
@@ -2257,7 +2415,7 @@ sub _mostnicks
 
     if (keys %{ $self->{stats}->{nicks}->{$sortnicks[0]} } > 1) {
 
-        $self->_headline($self->_template_text('mostnickstopic'));
+        $self->_headline($self->_template_text('mostnickstopic'), undef, undef, 'mostnickstopic');
 
         my $names1 = $self->_template_text('names1');
         my $names2 = $self->_template_text('names2');
@@ -2341,7 +2499,7 @@ sub _mostactivebyhour
 
     if ($lastline>=0) {
 
-        $self->_headline($self->_template_text('activenickbyhourtopic'));
+        $self->_headline($self->_template_text('activenickbyhourtopic'), undef, undef, 'activenickbyhourtopic');
 
         _html("<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>");
         _html("<td>&nbsp;</td>");
@@ -2447,7 +2605,7 @@ sub _activegenders {
 
     return unless @topgender;
 
-    $self->_headline($self->_template_text('activegenderstopic'));
+    $self->_headline($self->_template_text('activegenderstopic'), undef, undef, 'activegenderstopic');
     _html("<table border=\"0\" width=\"$self->{cfg}->{tablewidth}\"><tr>");
     _html(" <td>&nbsp;</td>"
     . "<td class=\"tdtop\"><b>" . $self->_template_text('gender') . "</b></td>"

+ 556 - 0
modules/Pisg/Insights.pm

@@ -0,0 +1,556 @@
+package Pisg::HTMLGenerator;
+
+# Extra sections for the stats page, added to Pisg::HTMLGenerator:
+#
+#   overview            key numbers at a glance
+#   relations           who talks to whom: interactive map, closest pairs, social roles
+#   time personalities  night owls, early birds ...
+#   concentration       how much of the channel the top talkers write
+#   signature words     the word each regular owns
+#   navbar              a sticky bar to jump to any section
+#
+# Everything here reads data pisg already collected (plus who-talks-to-whom from
+# Pisg::Parser::Logfile). Each section has its own Show... option.
+
+use strict;
+use warnings;
+use Pisg::Common;
+use JSON::PP ();
+use Encode ();
+
+my @BUCKET_KEYS = qw(rel_time0 rel_time1 rel_time2 rel_time3);
+
+# ---- small helpers ---------------------------------------------------------------
+
+# Run one of the extra sections; if it fails, say so and carry on. The rest of the page
+# is more important than any one extra section.
+sub _ins_safe
+{
+    my ($self, $method) = @_;
+    return if eval { $self->$method(); 1 };
+    my $err = $@;
+    $err =~ s/\s+$//;
+    print STDERR "Warning: skipped the '$method' section: $err\n";
+}
+
+sub _ins_num
+{
+    my ($self, $n) = @_;
+    $n = int($n + 0.5);
+    1 while $n =~ s/^(-?\d+)(\d{3})/$1,$2/;
+    return $n;
+}
+
+sub _ins_esc { my ($self, $t) = @_; return htmlentities($t, $self->{cfg}->{charset}); }
+
+# A "word" needs at least two real letters: ASCII art, punctuation and lone symbols are not words.
+sub _has_letters
+{
+    my ($self, $w) = @_;
+    my $u = eval { Encode::decode('UTF-8', $w, Encode::FB_CROAK | Encode::LEAVE_SRC) };
+    $u = $w unless defined $u;
+    return $u =~ /\p{L}{2}/ ? 1 : 0;
+}
+
+sub _ins_isbot
+{
+    my ($self, $nick) = @_;
+    return 1 if is_ignored($nick);
+    my $sex = $self->{users}->{sex}{$nick};
+    return ($sex && $sex eq 'b') ? 1 : 0;
+}
+
+# Nicks that are people (not ignored, not marked as bots), busiest first.
+sub _ins_humans
+{
+    my $self = shift;
+    return $self->{ins_humans} ||= do {
+        my $l = $self->{stats}->{lines};
+        [ sort { $l->{$b} <=> $l->{$a} or $a cmp $b } grep { !$self->_ins_isbot($_) } keys %$l ];
+    };
+}
+
+# How many lines a nick needs before we rank it on things like "night owl".
+sub _ins_minlines
+{
+    my $self = shift;
+    my $t = $self->{cfg}->{bignumbersthreshold};
+    $t = 0 unless defined $t and $t =~ /^\d+(?:\.\d+)?$/;
+    return $t < 20 ? 20 : ($t > 100 ? 100 : $t);
+}
+
+sub _ins_json
+{
+    my ($self, $data) = @_;
+    # Bytes in, bytes out (no utf8 layer), like the rest of the page. The escapes stop
+    # anything in a nick from closing the <script> element or starting a tag.
+    my $json = JSON::PP->new->canonical->encode($data);
+    $json =~ s/</\\u003c/g;
+    $json =~ s/>/\\u003e/g;
+    $json =~ s/&/\\u0026/g;
+    return $json;
+}
+
+# Small CSS/JS files live next to the themes (layout/). Read like a stylesheet.
+sub _ins_slurp
+{
+    my ($self, $name) = @_;
+    my $file = $self->{cfg}->{cssdir} . $name;
+    my $fh;
+    open($fh, '<', $file) or open($fh, '<', $self->{cfg}->{search_path} . "/$file") or return '';
+    local $/;
+    my $text = <$fh>;
+    close($fh);
+    return $text;
+}
+
+# ---- navbar ----------------------------------------------------------------------
+
+# Sections that read fine in half the width. On a wide screen two of them sit side by side; the tables
+# and charts that need the whole width keep it. The key is the language-independent template name.
+my %HALF = map { $_ => 1 } qw(bignumtopic othernumtopic latesttopic mostwordstopic referencetopic
+    smileytopic karmatopic urlstopic chartstopic mostnickstopic activegenderstopic
+    concentrationtopic signaturetopic);
+sub _card_is_half { my ($self, $key) = @_; return defined $key && $HALF{$key} ? 1 : 0; }
+
+# Shorten a label to $max characters, at a word boundary, without ever cutting a
+# multi-byte UTF-8 character in half.
+sub _ins_cut
+{
+    my ($self, $text, $max) = @_;
+    my $utf8 = $self->{cfg}->{charset} =~ /^utf-?8$/i;
+    my $chars = $utf8 ? Encode::decode('UTF-8', $text, Encode::FB_DEFAULT) : $text;
+    return $text if length($chars) <= $max;
+    $chars = substr($chars, 0, $max);
+    $chars =~ s/\s+\S*$// if $chars =~ /\s/;              # back up to the last whole word
+    $chars .= '...';
+    return $utf8 ? Encode::encode('UTF-8', $chars) : $chars;
+}
+
+# Called by _headline for every section: remember it so the navbar can link to it.
+sub _nav_register
+{
+    my ($self, $title, $id, $label) = @_;
+    my $plain = $title;
+    $plain =~ s/<[^>]*>//g;
+    unless (defined $label and length $label) {
+        $label = $plain;
+        $label =~ s/\s*\(.*$//;                     # "Daily activity (last 31 days)" -> "Daily activity"
+        $label =~ s/^\s+|\s+$//g;
+        $label = $self->_ins_cut($label, 30);
+    }
+    my $n = scalar @{ $self->{nav} ||= [] } + 1;
+    unless (defined $id and length $id) {
+        # A readable id from the title, but not when the title has accents or another script
+        # (they would be mangled): those get a plain number instead.
+        if ($plain =~ /[\x80-\xFF]/) {
+            $id = '';
+        } else {
+            ($id = lc $plain) =~ s/[^a-z0-9]+/-/g;
+            $id =~ s/^-+|-+$//g;
+        }
+    }
+    $id = "section-$n" unless length $id;
+    $id = "$id-$n" if grep { $_->{id} eq $id } @{ $self->{nav} };
+    push @{ $self->{nav} }, { id => $id, label => $label, full => $plain };
+    return $id;
+}
+
+# The marker sits where the bar goes; it is replaced once every section is known.
+sub _nav_marker { my $self = shift; _html('<!--PISG_NAV-->') if $self->_nav_wanted; }
+
+sub _nav_wanted
+{
+    my $self = shift;
+    return $self->{cfg}->{shownavbar} && $self->{cfg}->{colorscheme} ne 'none';
+}
+
+sub _insert_navbar
+{
+    my $self = shift;
+    return unless $self->_nav_wanted;
+    my @items = @{ $self->{nav} || [] };
+    my $path = $self->{outfile_tmp} or return;            # the page is finished in a temp file, see create_output
+    open(my $in, '<', $path) or return;
+    local $/;
+    my $page = <$in>;
+    close($in);
+
+    my $bar = '';
+    if (@items > 1) {
+        # Two lists from the same links; the stylesheet shows one of them.
+        #  * narrow screens: one slim bar (where you are + a button that opens the whole list), a
+        #    native <details>, so it works even without JavaScript;
+        #  * wide screens: a fixed menu down the left side, always open.
+        my $label = $self->_template_text('nav_label');
+        my $chan  = $self->_ins_esc($self->{cfg}->{channel});
+        my $links = join('', map { '<li><a href="#' . $_->{id} . '" title="' . $self->_ins_esc($_->{full} || $_->{label}) . '">'
+                                   . $self->_ins_esc($_->{label}) . '</a></li>' } @items);
+        $bar = '<nav class="pisg-nav" id="pisg-nav" aria-label="' . $label . '"><details class="nav-menu">'
+             . '<summary><span class="nav-current" id="nav-current">' . $self->_ins_esc($items[0]{label}) . '</span>'
+             . '<span class="nav-toggle">' . $label . '</span></summary><ul class="nav-list">' . $links . '</ul></details>'
+             . '<div class="nav-side"><a class="nav-brand" href="#pagetitle1">' . $chan . '</a>'
+             . '<ul class="nav-side-list">' . $links . '</ul></div></nav>';
+    }
+    $page =~ s/<!--PISG_NAV-->/$bar/;
+
+    if (open(my $out, '>', $path)) {
+        print $out $page;
+        close($out);
+    }
+}
+
+# ---- CSS for pages whose theme has never heard of these sections ---------------------
+
+sub _ins_basecss { my $self = shift; return $self->_ins_slurp('insights-base.css'); }
+
+# ---- overview --------------------------------------------------------------------
+
+sub _insights_overview
+{
+    my $self = shift;
+    my $st = $self->{stats};
+    my $humans = $self->_ins_humans;
+    return unless @$humans;
+
+    my ($lines, $words, $questions, $joins, $kicks, $links, $distinct) = (0) x 7;
+    $lines += $_ for values %{ $st->{lines} || {} };
+    $words += $_ for values %{ $st->{words} || {} };
+    $questions += $_ for values %{ $st->{questions} || {} };
+    $joins += $_ for values %{ $st->{joins} || {} };
+    $kicks += $_ for values %{ $st->{kicked} || {} };
+    for my $u (values %{ $st->{urlcounts} || {} }) { $links += $u; $distinct++; }
+    return unless $lines;
+
+    my $days = $st->{days} || 1;
+    my $times = $st->{times} || {};
+    my $total = 0;
+    $total += $_ for values %$times;
+    my ($busy, $quiet) = (0, 0);
+    my ($bmax, $qmin) = (-1, undef);
+    for my $h (0 .. 23) {
+        my $v = $times->{ sprintf('%02d', $h) } || 0;
+        ($bmax, $busy) = ($v, $h) if $v > $bmax;
+        ($qmin, $quiet) = ($v, $h) if !defined $qmin or $v < $qmin;
+    }
+    my $pct = sub { my ($n, $d) = @_; return $d ? sprintf('%.1f', 100 * $n / $d) : '0.0' };
+    my $hourfmt = sub { my $h = shift; return sprintf('%02d:00-%02d:00', $h, ($h + 1) % 24) };
+    my $top = $humans->[0];
+
+    # The first three numbers become the headline sentence; the rest is a list of facts.
+    my @facts = (
+        [ 'ov_words',     $self->_ins_num($words),                  '' ],
+        [ 'ov_lpd',       $self->_ins_num($lines / $days),          '' ],
+        [ 'ov_busy',      $hourfmt->($busy),  $self->_template_text('ov_pct_sub', pct => $pct->($bmax, $total)) ],
+        [ 'ov_quiet',     $hourfmt->($quiet), $self->_template_text('ov_pct_sub', pct => $pct->($qmin, $total)) ],
+        [ 'ov_questions', $self->_ins_num($questions), $self->_template_text('ov_pct_lines_sub', pct => $pct->($questions, $lines)) ],
+        [ 'ov_links',     $self->_ins_num($links),     $self->_template_text('ov_links_sub', distinct => $self->_ins_num($distinct)) ],
+        [ 'ov_top',       $self->_ins_esc($top),       $self->_template_text('ov_pct_lines_sub', pct => $pct->($st->{lines}{$top}, $lines)) ],
+    );
+    push @facts, [ 'ov_joins', $self->_ins_num($joins), '' ] if $joins;
+    push @facts, [ 'ov_kicks', $self->_ins_num($kicks), '' ] if $kicks;
+
+    $self->_headline($self->_template_text('overviewtopic'), 'overview');
+    _html('<p class="hero">' . $self->_template_text('ov_hero',
+        lines => '<b>' . $self->_ins_num($lines) . '</b>',
+        nicks => '<b>' . $self->_ins_num(scalar keys %{ $st->{lines} }) . '</b>',
+        days  => '<b>' . $self->_ins_num($days) . '</b>') . '</p>');
+    _html('<dl class="facts">');
+    for my $f (@facts) {
+        _html('<div class="fact"><dt>' . $self->_template_text($f->[0]) . '</dt><dd>' . $f->[1]
+            . (length $f->[2] ? '<small>' . $f->[2] . '</small>' : '') . '</dd></div>');
+    }
+    _html('</dl>');
+}
+
+# ---- who talks to whom -------------------------------------------------------------
+
+# Strength of a connection: a line addressed to someone counts most, then a mention,
+# then answering right after them.
+sub _ins_strength { my ($self, $d, $m, $x) = @_; return 3 * $d + $m + $x; }
+
+# Everything the relation sections need, worked out once.
+sub _ins_relations
+{
+    my $self = shift;
+    return $self->{ins_rel} if $self->{ins_rel};
+
+    my $st = $self->{stats};
+    my $rel = $st->{relations} || {};
+    my %isbot;
+    my %pair;                                   # "a\0b" (a lt b) => {a,b, ab=>[d,m,x], ba=>[d,m,x]}
+    my (%in, %out, %listen, %partners);         # per nick
+
+    for my $from (keys %$rel) {
+        next if $isbot{$from} //= $self->_ins_isbot($from);
+        for my $to (keys %{ $rel->{$from} }) {
+            next if $isbot{$to} //= $self->_ins_isbot($to);
+            my ($d, $m, $x) = @{ $rel->{$from}{$to} };
+            my ($lo, $hi) = $from lt $to ? ($from, $to) : ($to, $from);
+            my $p = $pair{"$lo\0$hi"} ||= { a => $lo, b => $hi, ab => [0, 0, 0], ba => [0, 0, 0] };
+            my $slot = $from eq $lo ? 'ab' : 'ba';
+            my @v = ($d, $m, $x);
+            $p->{$slot}[$_] += $v[$_] for 0 .. 2;
+        }
+    }
+    for my $p (values %pair) {
+        my ($ab, $ba) = ($p->{ab}, $p->{ba});
+        $p->{w} = $self->_ins_strength($ab->[0] + $ba->[0], $ab->[1] + $ba->[1], $ab->[2] + $ba->[2]);
+        my ($lo, $hi) = ($p->{a}, $p->{b});
+        $out{$lo} += 3 * $ab->[0] + $ab->[1];   $out{$hi} += 3 * $ba->[0] + $ba->[1];
+        $in{$hi}  += 3 * $ab->[0] + $ab->[1];   $in{$lo}  += 3 * $ba->[0] + $ba->[1];
+        $listen{$hi} += $ab->[2];               $listen{$lo} += $ba->[2];   # x for lo->hi = hi answered lo
+        push @{ $partners{$lo} }, [$hi, $p->{w}];
+        push @{ $partners{$hi} }, [$lo, $p->{w}];
+    }
+    $self->{ins_rel} = { pair => \%pair, in => \%in, out => \%out, listen => \%listen, partners => \%partners };
+    return $self->{ins_rel};
+}
+
+sub _insights_relations
+{
+    my $self = shift;
+    my $st = $self->{stats};
+    return unless $st->{relations} and %{ $st->{relations} };
+    my $r = $self->_ins_relations;
+    my $humans = $self->_ins_humans;
+    my $limit = $self->{cfg}->{relationnicks} || 30;
+    my @nodes = @$humans[0 .. ($limit > @$humans ? $#$humans : $limit - 1)];
+    return if @nodes < 2;
+    my %idx;
+    @idx{@nodes} = 0 .. $#nodes;
+    my $minw = $self->{cfg}->{relationminweight};
+
+    my @edges;
+    for my $p (sort { $b->{w} <=> $a->{w} } values %{ $r->{pair} }) {
+        next unless exists $idx{ $p->{a} } and exists $idx{ $p->{b} } and $p->{w} >= $minw;
+        push @edges, { a => $idx{ $p->{a} }, b => $idx{ $p->{b} }, w => $p->{w}, ab => $p->{ab}, ba => $p->{ba} };
+        last if @edges >= 150;
+    }
+    return unless @edges;
+
+    my @jnodes;
+    for my $n (@nodes) {
+        my @top = sort { $b->[1] <=> $a->[1] } @{ $r->{partners}{$n} || [] };
+        splice(@top, 5) if @top > 5;
+        push @jnodes, {
+            id       => $n,
+            lines    => 0 + $st->{lines}{$n},
+            words    => 0 + ($st->{words}{$n} || 0),
+            hours    => [ map { 0 + ($_ || 0) } @{ $st->{line_times}{$n} || [] }[0 .. 3] ],
+            partners => [ map { [ $_->[0], 0 + $_->[1] ] } @top ],
+        };
+    }
+    my $i18n = { map { $_ => $self->_template_text($_) } qw(
+        rel_pick rel_lines rel_words rel_active rel_partners rel_between rel_toward rel_strength
+        rel_direct rel_mentions rel_replies rel_time0 rel_time1 rel_time2 rel_time3 rel_nolinks ) };
+
+    $self->_headline($self->_template_text('relationstopic'), 'relations');
+    _html('<p class="intro">' . $self->_template_text('rel_intro') . '</p>');
+    _html('<div class="relmap" id="relmap">'
+        . '<div class="relmap-canvas"><svg id="relmap-svg" role="img" aria-label="' . $self->_template_text('relationstopic')
+        . '" viewBox="0 0 960 620" preserveAspectRatio="xMidYMid meet"></svg></div>'
+        . '<div class="relmap-side"><div class="relmap-legend">'
+        . join('', map { '<span class="lg lg' . $_ . '"><i></i>' . $self->_template_text($BUCKET_KEYS[$_]) . '</span>' } 0 .. 3)
+        . '</div><div class="relmap-info" id="relmap-info" aria-live="polite">' . $self->_template_text('rel_pick') . '</div></div>'
+        . '</div>');
+    _html('<noscript><p class="intro">' . $self->_template_text('rel_nojs') . '</p></noscript>');
+    _html('<script type="application/json" id="relmap-data">' . $self->_ins_json({ nodes => \@jnodes, edges => \@edges, i18n => $i18n }) . '</script>');
+    $self->{ins_script_relmap} = 1;
+
+    $self->_insights_pairs;
+    $self->_insights_roles;
+}
+
+sub _insights_pairs
+{
+    my $self = shift;
+    my $r = $self->_ins_relations;
+    my @top = sort { $b->{w} <=> $a->{w} or $a->{a} cmp $b->{a} } values %{ $r->{pair} };
+    my $max = $self->{cfg}->{nickhistory} > 10 ? $self->{cfg}->{nickhistory} : 10;
+    splice(@top, $max) if @top > $max;
+    return unless @top;
+
+    $self->_headline($self->_template_text('pairstopic'), 'pairs');
+    _html('<table border="0" width="' . $self->{cfg}->{tablewidth} . '"><tr><td>&nbsp;</td>'
+        . '<td class="tdtop"><b>' . $self->_template_text('pair') . '</b></td>'
+        . '<td class="tdtop"><b>' . $self->_template_text('rel_strength') . '</b></td>'
+        . '<td class="tdtop"><b>' . $self->_template_text('pair_detail') . '</b></td></tr>');
+    my $i = 0;
+    for my $p (@top) {
+        $i++;
+        my $wid = int(100 * $p->{w} / $top[0]{w});
+        my ($ab, $ba) = ($p->{ab}, $p->{ba});
+        my $detail = $self->_template_text('pair_line', a => $self->_ins_esc($p->{a}), b => $self->_ins_esc($p->{b}),
+            ad => $ab->[0], am => $ab->[1], bd => $ba->[0], bm => $ba->[1], replies => $ab->[2] + $ba->[2]);
+        _html('<tr><td class="' . ($i == 1 ? 'hirankc' : 'rankc') . '" align="left">' . $i . '</td>'
+            . '<td class="hicell"><b>' . $self->_ins_esc($p->{a}) . '</b> &harr; <b>' . $self->_ins_esc($p->{b}) . '</b></td>'
+            . '<td class="hicell"><div class="bar"><span style="width:' . ($wid || 1) . '%"></span></div> ' . $self->_ins_num($p->{w}) . '</td>'
+            . '<td class="hicell small">' . $detail . '</td></tr>');
+    }
+    _html('</table>');
+}
+
+sub _insights_roles
+{
+    my $self = shift;
+    my $st = $self->{stats};
+    my $r = $self->_ins_relations;
+    my $min = $self->_ins_minlines;
+    my @pool = grep { $st->{lines}{$_} >= $min } @{ $self->_ins_humans };
+    return unless @pool >= 3;
+
+    my $best = sub {
+        my ($score, $desc) = @_;              # highest score wins
+        my ($win, $ws) = (undef, undef);
+        for my $n (@pool) {
+            my $s = $score->($n);
+            ($win, $ws) = ($n, $s) if defined $s and (!defined $ws or $s > $ws);
+        }
+        return ($win, $ws);
+    };
+    my $strong = sub {                        # partners with a real connection
+        my $n = shift;
+        return scalar grep { $_->[1] >= 5 } @{ $r->{partners}{$n} || [] };
+    };
+    my @roles;
+    my ($n, $v);
+    ($n, $v) = $best->(sub { $r->{in}{ $_[0] } || 0 });
+    push @roles, [ 'role_talkedto', $n, $self->_template_text('role_talkedto_v', n => $self->_ins_num($v)) ] if $n and $v;
+    ($n, $v) = $best->(sub { $r->{out}{ $_[0] } ? 100 * $r->{out}{ $_[0] } / (3 * $st->{lines}{ $_[0] }) : 0 });
+    push @roles, [ 'role_outgoing', $n, $self->_template_text('role_outgoing_v', pct => sprintf('%.0f', $v)) ] if $n and $v;
+    ($n, $v) = $best->(sub { $strong->($_[0]) });
+    push @roles, [ 'role_connector', $n, $self->_template_text('role_connector_v', n => $v) ] if $n and $v;
+    ($n, $v) = $best->(sub { $r->{listen}{ $_[0] } || 0 });
+    push @roles, [ 'role_listener', $n, $self->_template_text('role_listener_v', n => $self->_ins_num($v)) ] if $n and $v;
+    ($n, $v) = $best->(sub { $st->{lines}{ $_[0] } / (1 + $strong->($_[0])) });
+    push @roles, [ 'role_lonewolf', $n, $self->_template_text('role_lonewolf_v', lines => $self->_ins_num($st->{lines}{$n}), n => $strong->($n)) ] if $n;
+    return unless @roles;
+
+    $self->_headline($self->_template_text('rolestopic'), 'roles');
+    _html('<div class="cardgrid">');
+    for my $ro (@roles) {
+        _html('<div class="minicard"><span class="mc-title">' . $self->_template_text($ro->[0]) . '</span>'
+            . '<span class="mc-name">' . $self->_ins_esc($ro->[1]) . '</span>'
+            . '<span class="mc-desc">' . $self->_template_text($ro->[0] . '_d') . '</span>'
+            . '<span class="mc-value">' . $ro->[2] . '</span></div>');
+    }
+    _html('</div>');
+}
+
+# ---- time personalities -------------------------------------------------------------
+
+sub _insights_personalities
+{
+    my $self = shift;
+    my $st = $self->{stats};
+    my $min = $self->_ins_minlines;
+    my @pool = grep { $st->{lines}{$_} >= $min and $st->{line_times}{$_} } @{ $self->_ins_humans };
+    return unless @pool >= 4;
+
+    my @cols;
+    for my $bk (0 .. 3) {
+        my @rank;
+        for my $n (@pool) {
+            my $sum = 0;
+            $sum += $_ || 0 for @{ $st->{line_times}{$n} }[0 .. 3];
+            next unless $sum;
+            push @rank, [ $n, 100 * ($st->{line_times}{$n}[$bk] || 0) / $sum ];
+        }
+        @rank = sort { $b->[1] <=> $a->[1] or $st->{lines}{ $b->[0] } <=> $st->{lines}{ $a->[0] } or $a->[0] cmp $b->[0] } @rank;
+        splice(@rank, 3) if @rank > 3;
+        push @cols, \@rank;
+    }
+    $self->_headline($self->_template_text('persontopic'), 'personalities');
+    _html('<div class="cardgrid">');
+    my @names = qw(per_night per_morning per_afternoon per_evening);
+    for my $bk (0 .. 3) {
+        my ($from, $to) = ($bk * 6, $bk * 6 + 5);
+        _html('<div class="minicard t' . $bk . '"><span class="mc-title">' . $self->_template_text($names[$bk]) . '</span>'
+            . '<span class="mc-desc">' . $self->_template_text('per_range', from => $from, to => $to) . '</span><ol class="mc-list">'
+            . join('', map { '<li><b>' . $self->_ins_esc($_->[0]) . '</b> <span>' . sprintf('%.0f', $_->[1]) . '%</span></li>' } @{ $cols[$bk] })
+            . '</ol></div>');
+    }
+    _html('</div>');
+}
+
+# ---- concentration ------------------------------------------------------------------
+
+sub _insights_concentration
+{
+    my $self = shift;
+    my $st = $self->{stats};
+    my @humans = @{ $self->_ins_humans };
+    return unless @humans >= 3;
+    my $total = 0;
+    $total += $st->{lines}{$_} for @humans;
+    return unless $total;
+
+    my ($run, $half, %at) = (0, 0);
+    my $i = 0;
+    for my $n (@humans) {
+        $run += $st->{lines}{$n};
+        $i++;
+        $at{$i} = 100 * $run / $total;
+        $half ||= $i if $run * 2 >= $total;
+    }
+    my @steps = grep { $_ <= @humans } (1, 3, 5, 10, 20);
+    $self->_headline($self->_template_text('concentrationtopic'), 'concentration', undef, 'concentrationtopic');
+    _html('<p class="intro">' . $self->_template_text('conc_half', n => $half) . '</p>');
+    _html('<table border="0" width="' . $self->{cfg}->{tablewidth} . '">');
+    for my $s (@steps) {
+        _html('<tr><td class="hicell" style="width:9em">' . $self->_template_text('conc_top', n => $s) . '</td>'
+            . '<td class="hicell"><div class="bar"><span style="width:' . sprintf('%.1f', $at{$s}) . '%"></span></div></td>'
+            . '<td class="hicell" style="width:5em">' . sprintf('%.1f', $at{$s}) . '%</td></tr>');
+    }
+    _html('</table>');
+}
+
+# ---- signature words ------------------------------------------------------------------
+
+sub _insights_signature
+{
+    my $self = shift;
+    my $st = $self->{stats};
+    my $sig = $st->{signature} || {};
+    my @nicks = grep { $sig->{$_} and !$self->_ins_isbot($_) } @{ $self->_ins_humans };
+    return unless @nicks;
+    splice(@nicks, 15) if @nicks > 15;
+
+    $self->_headline($self->_template_text('signaturetopic'), 'signature', undef, 'signaturetopic');
+    _html('<p class="intro">' . $self->_template_text('sig_intro') . '</p>');
+    _html('<table border="0" width="' . $self->{cfg}->{tablewidth} . '"><tr><td>&nbsp;</td>'
+        . '<td class="tdtop"><b>' . $self->_template_text('nick') . '</b></td>'
+        . '<td class="tdtop"><b>' . $self->_template_text('sig_word') . '</b></td>'
+        . '<td class="tdtop"><b>' . $self->_template_text('sig_uses') . '</b></td>'
+        . '<td class="tdtop"><b>' . $self->_template_text('sig_share') . '</b></td></tr>');
+    my $i = 0;
+    for my $n (@nicks) {
+        my ($word, $uses, $share) = @{ $sig->{$n} };
+        $i++;
+        _html('<tr><td class="' . ($i == 1 ? 'hirankc' : 'rankc') . '" align="left">' . $i . '</td>'
+            . '<td class="hicell"><b>' . $self->_ins_esc($n) . '</b></td>'
+            . '<td class="hicell sigword">' . $self->_ins_esc($word) . '</td>'
+            . '<td class="hicell">' . $self->_ins_num($uses) . '</td>'
+            . '<td class="hicell"><div class="bar"><span style="width:' . $share . '%"></span></div> ' . $share . '%</td></tr>');
+    }
+    _html('</table>');
+}
+
+# ---- scripts, written once near the end of the page --------------------------------------
+
+sub _insights_scripts
+{
+    my $self = shift;
+    return unless $self->_nav_wanted or $self->{ins_script_relmap};
+    if ($self->{ins_script_relmap}) {
+        my $js = $self->_ins_slurp('relmap.js');
+        _html("<script>\n$js\n</script>") if length $js;
+    }
+    if ($self->_nav_wanted) {
+        my $js = $self->_ins_slurp('pagenav.js');
+        _html("<script>\n$js\n</script>") if length $js;
+    }
+}
+
+1;

+ 147 - 2
modules/Pisg/Parser/Logfile.pm

@@ -4,6 +4,7 @@ package Pisg::Parser::Logfile;
 # found at the end of the file.
 
 use strict;
+use Encode ();
 use Storable;
 
 $^W = 1;
@@ -58,6 +59,17 @@ sub new
     # precompile the regexps used (we can't use /o since the config might be different per channel)
     $self->{foulwords_regexp} = qr/($self->{cfg}->{foulwords})/i if $self->{cfg}->{foulwords};
     $self->{ignorewords_regexp} = qr/$self->{cfg}->{ignorewords}/i if $self->{cfg}->{ignorewords};
+    # BadUrls: a URL containing any of these words (case-insensitive, anywhere in the
+    # URL) is left out of the URL statistics. Everything is literal text except the
+    # wildcards * (any characters) and ? (one character), so it is not a regexp.
+    my @badurls = grep { length } split(/\s+/, $self->{cfg}->{badurls} || '');
+    if (@badurls) {
+        my $alt = join '|', map { my $w = quotemeta; $w =~ s/\\\*/.*/g; $w =~ s/\\\?/./g; $w } @badurls;
+        $self->{badurls_regexp} = qr/$alt/i;
+    }
+    # Who talks to whom (relation map, best friends, signature words) needs extra data per
+    # line. Only collect it when one of those sections is switched on.
+    $self->{rel_on} = ($self->{cfg}->{showrelations} || $self->{cfg}->{showsignaturewords}) ? 1 : 0;
     $self->{violentwords_regexp} = qr/^($self->{cfg}->{violentwords}) (\S+)(.*)/i if $self->{cfg}->{violentwords};
     $self->{chartsregexp} = qr/^$self->{cfg}->{chartsregexp}/i if $self->{cfg}->{chartsregexp};
 
@@ -173,6 +185,7 @@ sub analyze
 
     $self->_pick_random_lines(\%stats, \%lines);
     _uniquify_nicks(\%stats);
+    $self->_resolve_relations(\%stats) if $self->{rel_on};
 
     my ($sec,$min,$hour) = gmtime(time() - $starttime);
     my $processtime = sprintf('%02d hours, %02d minutes and %02d seconds', $hour, $min, $sec);
@@ -283,6 +296,21 @@ sub _parse_dir
 }
 
 # This parses the file...
+# substr() cuts at a byte offset, which can split a multi-byte UTF-8 character in
+# two and leave invalid UTF-8 in the page. For UTF-8 output, back up to a
+# character boundary; for other charsets this is a plain substr().
+sub _truncate
+{
+    my ($self, $text, $len) = @_;
+    my $t = substr($text, 0, $len);
+    if ($self->{cfg}->{charset} =~ /^utf-?8$/i and $t =~ /([\xC0-\xFF])([\x80-\xBF]*)\z/) {
+        my ($lead, $have) = (ord($1), length($2));
+        my $need = $lead >= 0xF0 ? 3 : $lead >= 0xE0 ? 2 : 1;
+        $t = substr($t, 0, length($t) - 1 - $have) if $have < $need;
+    }
+    return $t;
+}
+
 sub _parse_file
 {
     my $self = shift;
@@ -375,6 +403,24 @@ sub _parse_file
                     }
                     $stats->{lastnick} = $nick;
 
+                    if ($self->{rel_on}) {
+                        # Turn taking: this nick spoke right after another one.
+                        my $prev = $stats->{rel_lastnick};
+                        $stats->{rel_turns}{$prev}{$nick}++ if defined $prev and $prev ne '' and $prev ne $nick;
+                        $stats->{rel_lastnick} = $nick;
+
+                        # "nick: text" / "nick, text" talks to that nick (checked against real nicks later).
+                        if ($saying =~ /^\s*[\@+%~&]?([\w\[\]\\`^{|}\x80-\xFF-]{2,30})\s*[:,]/) {
+                            $stats->{rel_direct}{$nick}{lc $1}++;
+                        }
+                        # Every word (links removed), to find nicks mentioned and words a nick favours.
+                        (my $plain = $saying) =~ s{\S+://\S+|\bwww\.\S+}{ }gi;
+                        foreach my $w ($plain =~ /([\w\[\]\\`^{|}\x80-\xFF-]{2,30})/g) {   # bytes >= 0x80 keep UTF-8 letters whole
+                            next if $w =~ /^\d+$/;
+                            $stats->{rel_words}{$nick}{lc $w}++;
+                        }
+                    }
+
                     my $len = length($saying);
                     if ($len > $self->{cfg}->{minquote} && $len < $self->{cfg}->{maxquote}) {
                         push @{ $lines->{sayings}{$nick} }, $saying;
@@ -382,7 +428,7 @@ sub _parse_file
                         # Just fill the users first saying in if he hasn't
                         # said anything yet, to get rid of empty quotes.
                         if ($len > $self->{cfg}->{maxquote} - 3) {
-                            push @{ $lines->{sayings}{$nick} }, substr($saying, 0, $self->{cfg}->{maxquote} - 3) . '...';
+                            push @{ $lines->{sayings}{$nick} }, $self->_truncate($saying, $self->{cfg}->{maxquote} - 3) . '...';
                         } else {
                             push @{ $lines->{sayings}{$nick} }, $saying;
                         }
@@ -438,7 +484,7 @@ sub _parse_file
                     # Find URLs
                     if (my @urls = match_urls($saying)) {
                         foreach my $url (@urls) {
-                            if(!url_is_ignored($url)) {
+                            if(!url_is_ignored($url) and !($self->{badurls_regexp} and $url =~ $self->{badurls_regexp})) {
                                 $stats->{urlcounts}{$url}++;
                                 $stats->{urlnicks}{$url} = $nick;
                             }
@@ -735,6 +781,97 @@ sub _random_line
     return $out || $out2;
 }
 
+# Turn the raw per-line data into who-talks-to-whom, once every nick is known (a nick
+# can be mentioned before it ever speaks).
+#   $stats->{relations}{$from}{$to} = [direct, mentions, replies]
+#     direct   - lines that start "to: ..." or "to, ..."
+#     mentions - other times "to" (or an alias) appears in a line
+#     replies  - times "to" spoke right after "from"
+#   $stats->{signature}{$nick} = [word, uses, share]   the word a nick owns most
+sub _resolve_relations
+{
+    my ($self, $stats) = @_;
+    my %canon;
+    my $resolve = sub {
+        my $tok = shift;
+        return $canon{$tok} if exists $canon{$tok};
+        my $n = is_nick($tok);
+        return $canon{$tok} = ($n and exists $stats->{lines}{$n} and !is_ignored($n)) ? $n : '';
+    };
+
+    # The channel's own name ("canada" in #Canada) is said all the time and is not a nick.
+    (my $chanword = lc($self->{cfg}->{channel} || '')) =~ s/^#+//;
+
+    my (%rel, %mentioned);
+    foreach my $from (keys %{ $stats->{rel_words} || {} }) {
+        next if is_ignored($from);
+        my $direct = $stats->{rel_direct}{$from} || {};
+        foreach my $tok (keys %{ $stats->{rel_words}{$from} }) {
+            my $to = $resolve->($tok) or next;
+            next if $to eq $from;
+            my $d = $direct->{$tok} || 0;
+            my $m = $stats->{rel_words}{$from}{$tok} - $d;
+            $m = 0 if $m < 0 or length($tok) < 3 or $tok eq $chanword;   # 1-2 letter nicks are too often ordinary words
+            $rel{$from}{$to}[0] += $d;
+            $rel{$from}{$to}[1] += $m;
+            $mentioned{$to} += $m;
+        }
+    }
+    # A nick "mentioned" far more often than its activity explains is most likely an ordinary
+    # word (Dude, Guest, Canada ...), not a person: keep the direct addresses, drop the mentions.
+    my %wordlike = map { $_ => 1 }
+        grep { $mentioned{$_} > 5 * ($stats->{lines}{$_} || 0) + 30 } keys %mentioned;
+    if (%wordlike) {
+        foreach my $from (keys %rel) {
+            foreach my $to (keys %{ $rel{$from} }) {
+                $rel{$from}{$to}[1] = 0 if $wordlike{$to};
+            }
+        }
+    }
+    foreach my $from (keys %{ $stats->{rel_turns} || {} }) {
+        next if is_ignored($from) or !exists $stats->{lines}{$from};
+        foreach my $to (keys %{ $stats->{rel_turns}{$from} }) {
+            next if is_ignored($to) or !exists $stats->{lines}{$to};
+            $rel{$from}{$to}[2] += $stats->{rel_turns}{$from}{$to};
+        }
+    }
+    foreach my $from (keys %rel) {
+        foreach my $to (keys %{ $rel{$from} }) {
+            $_ ||= 0 for @{ $rel{$from}{$to} }[0 .. 2];
+        }
+    }
+    $stats->{relations} = \%rel;
+
+    # Signature word: used a lot by this nick and hardly by anyone else.
+    my (%global, %sig);
+    foreach my $n (keys %{ $stats->{rel_words} || {} }) {
+        $global{$_} += $stats->{rel_words}{$n}{$_} for keys %{ $stats->{rel_words}{$n} };
+    }
+    my $minlen = $self->{cfg}->{wordlength} > 4 ? $self->{cfg}->{wordlength} : 4;
+    foreach my $n (keys %{ $stats->{rel_words} || {} }) {
+        next if is_ignored($n) or ($stats->{lines}{$n} || 0) < 30;
+        my ($best, $bestscore, $bc, $bs) = ('', 0, 0, 0);
+        foreach my $w (keys %{ $stats->{rel_words}{$n} }) {
+            my $c = $stats->{rel_words}{$n}{$w};
+            next if $c < 4 or length($w) < $minlen or is_nick($w);      # a nick (even an ignored bot) is a name, not a word
+            # needs real letters (not ASCII art or punctuation): decode UTF-8 to tell, else use the bytes
+            my $u = eval { Encode::decode('UTF-8', $w, Encode::FB_CROAK | Encode::LEAVE_SRC) };   # LEAVE_SRC: keep $w intact
+            $u = $w unless defined $u;
+            next unless $u =~ /\p{L}{3}/;
+            next if $self->{ignorewords_regexp} and $w =~ /$self->{ignorewords_regexp}/;
+            my $total = $global{$w} or next;
+            my $share = $c / $total;
+            next if $share < 0.4;
+            my $score = $c * $share;
+            ($best, $bestscore, $bc, $bs) = ($w, $score, $c, $share) if $score > $bestscore;
+        }
+        $sig{$n} = [$best, $bc, int($bs * 100 + 0.5)] if length $best;
+    }
+    $stats->{signature} = \%sig;
+
+    delete @{$stats}{qw(rel_words rel_direct rel_turns rel_lastnick)};   # raw data no longer needed
+}
+
 sub _uniquify_nicks {
     my ($stats) = @_;
 
@@ -849,6 +986,14 @@ sub _merge_stats
         #print "$key -> $s->{$key}\n";
         if ($key =~ /^(logfile|firsttime|days|version)/) { # don't merge these
             next;
+        } elsif ($key =~ /^(rel_words|rel_direct|rel_turns)$/) { # {key}->{}->{} = int: add
+            foreach my $subkey (keys %{$s->{$key}}) {
+                foreach my $value (keys %{$s->{$key}->{$subkey}}) {
+                    $stats->{$key}->{$subkey}->{$value} += $s->{$key}->{$subkey}->{$value};
+                }
+            }
+        } elsif ($key eq 'rel_lastnick') { # str: copy
+            $stats->{$key} = $s->{$key};
         } elsif ($key =~ /^(oldtime|lastnick|lastnormal|monocount)$/) { # {key} = int/str: copy
             $stats->{$key} = $s->{$key};
         } elsif ($key =~ /^(parsedlines|totallines)$/) { # {key} = int: add

+ 2 - 1
pisg

@@ -62,11 +62,12 @@ if (\$version) {
     print \$pisg->{cfg}->{version} . "\n";
 } else {
     \$pisg->run();
+    exit 1 if \$pisg->{failed_channels};       # some channel was skipped (already reported above)
 }
 END
     if ($@) {
         print STDERR "Could not load pisg! Reason:\n$@\n";
-        return undef;
+        exit 1;                    # so cron and monitoring can see that it failed
     }
 }
 

+ 430 - 0
pisg.cfg.example

@@ -0,0 +1,430 @@
+# pisg.cfg.example - a complete configuration for pisg, ready to copy.
+#
+#   1. Copy this file to pisg.cfg
+#   2. Change the values marked  EDIT  (log path, format, network, output file, name)
+#   3. Run ./pisg
+#
+# Every option pisg understands is listed below with its meaning. Options are set to
+# pisg's own default unless the comment says "recommended". Lines starting with # are
+# comments; an option shown commented out is off/unset - remove the # to use it.
+#
+# Syntax reminders:
+#   <set Name="value">      a global option, applies to every channel
+#   <channel="#name"> ... </channel>   settings for one channel (they override <set>)
+#   <user nick="..." ...>   per-user settings
+# Each <set> must be on one line, and every value must be in quotes.
+
+
+##############################################################################
+#  GLOBAL OPTIONS - apply to every channel below
+##############################################################################
+
+# ---- General options -------------------------------------------------------
+
+# use a different color scheme for stats page - recommended: modern, light/dark theme;
+# try midnight, amoled, terminal, canada, or default
+<set ColorScheme="modern">
+
+# alternate stylesheets for stats page - extra stylesheets the visitor can switch to
+#<set AltColorScheme="midnight.css amoled.css">
+
+# define the language / translation to use
+<set Lang="EN">
+
+# define a file as page header - HTML file inserted above the statistics
+#<set PageHead="header.html">
+
+# define a file as page footer - HTML file inserted below the statistics
+#<set PageFoot="footer.html">
+
+# make pisg silent, suppress messages
+<set Silent="0">
+
+# use a cache to speed up log parsing - cache parsed logs here to speed up runs (delete
+# it when you change settings)
+#<set CacheDir="cache/">
+
+# name of the channel list file for a landing page
+<set ChannelIndex="channels.json">
+
+# adds a button at the top of every stats page that leads back to your landing page
+<set HomeLink="">
+
+# enable/disable the section bar at the top of the page
+<set ShowNavBar="1">
+
+# ---- Options for various statistics features -------------------------------
+
+# number of days to show in "Daily Actitity" - recommended: show the last 14 days as a
+# bar chart
+<set DailyActivity="14">
+
+# enable/disable "Most Active Times"
+<set ShowActiveTimes="1">
+
+# enable/disable "Most Active Nicks"
+<set ShowActiveNicks="1">
+
+# enable/disable "Big Numbers" sections
+<set ShowBigNumbers="1">
+
+# enable/disable "Latest topics" sections
+<set ShowTopics="1">
+
+# enable/disable "number of lines"
+<set ShowLines="1">
+
+# enable/disable "words per line" - recommended: also show words per line
+<set ShowWpl="1">
+
+# enable/disable "characters per line" - recommended: also show characters per line
+<set ShowCpl="1">
+
+# enable/disable "number of words" - recommended: also show total words
+<set ShowWords="1">
+
+# show when a user was last seen on a channel
+<set ShowLastSeen="1">
+
+# show when a nick was active
+<set ShowTime="1">
+
+# mIRCStats like behaviour of time bar
+<set ShowLineTime="0">
+
+# ShowLineTime like behavior of words column
+<set ShowWordTime="0">
+
+# enable or disable the random quotes
+<set ShowRandQuote="1">
+
+# enable or disable the legend of the time bars
+<set ShowLegend="1">
+
+# enable or disable the kick line
+<set ShowKickLine="1">
+
+# enable or disable the action line
+<set ShowActionLine="1">
+
+# enable or disable the shout line
+<set ShowShoutLine="1">
+
+# set how many decimals to show
+<set ShowFoulDecimals="1">
+
+# enable or disable the foul line
+<set ShowFoulLine="0">
+
+# enable or disable the violent lines
+<set ShowViolentLines="1">
+
+# enable or disable "Most used words"
+<set ShowMuw="1">
+
+# enable or disable "Most referenced nicks"
+<set ShowMrn="1">
+
+# enable or disable "Most referenced URLs"
+<set ShowMru="1">
+
+# enable or disable channel music charts
+<set ShowCharts="0">
+
+# enable or disable op statistics
+<set ShowOps="1">
+
+# enable or disable voice statistics
+<set ShowVoices="0">
+
+# enable or disable halfop statistics
+<set ShowHalfops="0">
+
+# show who changed nick most often - recommended: show who changed nick most often
+<set ShowMostNicks="1">
+
+# show stats on which gender talked most
+<set ShowActiveGenders="0">
+
+# show most used smileys - recommended: show the most used smileys
+<set ShowSmileys="1">
+
+# show channel karma - recommended: show karma (nick++ / nick--)
+<set ShowKarma="1">
+
+# show most active nicks by hour - recommended: show the most active nicks by time of
+# day
+<set ShowMostActiveByHour="1">
+
+# only count stats for top talkers, ignore less-active users
+<set ShowOnlyTop="0">
+
+# show graphs in most active nicks by hour
+<set ShowMostActiveByHourGraph="1">
+
+# enable/disable the channel overview
+<set ShowOverview="1">
+
+# enable/disable the relation map and its tables
+<set ShowRelations="1">
+
+# number of nicks on the relation map
+<set RelationNicks="30">
+
+# minimum connection strength shown as a line on the relation map
+<set RelationMinWeight="3">
+
+# enable/disable the time personalities section
+<set ShowTimePersonalities="1">
+
+# enable/disable the "who carries the channel" section
+<set ShowConcentration="1">
+
+# enable/disable the signature words section
+<set ShowSignatureWords="1">
+
+# ignore specified words - words to leave out of the word statistics
+#<set IgnoreWords="badword otherword">
+
+# Control random quote output
+<set NoIgnoredQuotes="0">
+
+# specify words considered to be bad/FoulWords language
+<set FoulWords="ass fuck bitch shit scheisse scheiße kacke arsch ficker ficken schlampe">
+
+# specify words considered to be aggressive/violent
+<set ViolentWords="slaps beats smacks">
+
+# words that keep a URL out of the URL statistics - matched anywhere in a URL, any case;
+# * = any characters, ? = one; for spam and unwanted image hosts
+#<set BadUrls="imagetwist imgur.com postimg.cc/*">
+
+# minimum numbers of letters for a random quote
+<set MinQuote="25">
+
+# maximum numbers of letters for a random quote
+<set MaxQuote="65">
+
+# minimum number of characters in an interesting word
+<set WordLength="5">
+
+# maximum allowed length of a "word" with no spaces
+<set QuoteWidth="80">
+
+# Minimum number of lines per user for some "Big Numbers" statistics
+<set BigNumbersThreshold="sqrt">
+
+# nicks to show in "Most Active Nicks"
+<set ActiveNicks="25">
+
+# nicks to show in "These didn't make it.."
+<set ActiveNicks2="30">
+
+# number of nicks to show in "Most Active Nicks By Hour"
+<set ActiveNicksByHour="10">
+
+# maximum number of nicks to show in "users with most nicknames"
+<set MostNicksHistory="5">
+
+# show nicks used in "most nicks"
+<set MostNicksVerbose="1">
+
+# maximum number of topics to show - recommended: show the last 5 topics
+<set TopicHistory="5">
+
+# maximum number of URLs to show
+<set UrlHistory="5">
+
+# number of songs to show
+<set ChartsHistory="5">
+
+# how to recognize songs played
+<set ChartsRegexp="(?:is )?(?:np:|(?:now )?playing:? |listening to:? )(?:MPEG stream from)?\s*(.*)">
+
+# maximum number of words to show
+<set WordHistory="10">
+
+# maximum number of nicks to show in "Most referenced nicks"
+<set NickHistory="5">
+
+# maximum number of smileys to show in smiley stats
+<set SmileyHistory="10">
+
+# maximum number of nicks to show in "Karma"
+<set KarmaHistory="5">
+
+# track nick changes and create aliases - recommended: follow nick changes so Alice,
+# Alice_ and Alice- count as one person
+<set NickTracking="1">
+
+# maximum number of nicks in lists
+<set NickLimit="10">
+
+# sort "most active nicks" by words
+<set SortByWords="0">
+
+# ---- Picture options -------------------------------------------------------
+
+# path to images on stats page
+<set PicLocation=".">
+
+# number of user pictures per row
+<set UserPics="1">
+
+# path to user pictures (HTML page) - folder of user pictures, as seen by the web page
+#<set ImagePath="images/">
+
+# use a default user picture - picture shown for users who have none
+#<set DefaultPic="images/nobody.png">
+
+# path to user pictures (output generation) - folder of user pictures, as seen by pisg
+# (for pic="x_*.jpg" globs)
+#<set ImageGlobPath="/var/www/pisg/images/">
+
+# define a standard width for user pictures - show every user picture this wide, in
+# pixels
+#<set PicWidth="55">
+
+# define a standard height for user pictures - show every user picture this tall, in
+# pixels
+#<set PicHeight="55">
+
+# ---- Misc options ----------------------------------------------------------
+
+# character set to use for stats page - recommended: UTF-8 shows accents, emoji and non-
+# Latin scripts correctly
+<set Charset="utf-8">
+
+# character set for logfiles - convert logs from this charset (needs the Text::Iconv
+# perl module)
+#<set LogCharset="iso-8859-1">
+
+# fallback character set for logfiles - used for lines that are not valid in LogCharset
+# (needs Text::Iconv)
+#<set LogCharsetFallback="iso-8859-1">
+
+# use a different time zone than the local machine
+<set TimeOffset="+0">
+
+# use regular expressions in user aliases
+<set RegexpAliases="0">
+
+# filename of language file
+<set LangFile="lang.txt">
+
+# path to directory with CSS files
+<set CssDir="layout/">
+
+# colors for color gradient in most active nicks section
+<set HiCell="#BABADD">
+<set HiCell2="#CCCCCC">
+
+# colors for color gradient in most active nicks section
+<set HiCell2="#CCCCCC">
+
+# dump raw statistics into file - debugging: dump the raw statistics to this file
+#<set StatsDump="stats.dump">
+
+# ---- Advanced (not in the original documentation) -------------------------
+
+# nicks of bots, for log formats that cannot tell bots from people (DCpp)
+#<set BotNicks="bot1 bot2">
+
+# width in pixels of the statistics tables
+<set TableWidth="574">
+
+# image id of the horizontal bar for hours 0-5
+<set Pic_H_0="blue-h">
+
+# image id of the horizontal bar for hours 6-11
+<set Pic_H_6="green-h">
+
+# image id of the horizontal bar for hours 12-17
+<set Pic_H_12="yellow-h">
+
+# image id of the horizontal bar for hours 18-23
+<set Pic_H_18="red-h">
+
+# image id of the vertical bar for hours 0-5
+<set Pic_V_0="blue-v">
+
+# image id of the vertical bar for hours 6-11
+<set Pic_V_6="green-v">
+
+# image id of the vertical bar for hours 12-17
+<set Pic_V_12="yellow-v">
+
+# image id of the vertical bar for hours 18-23
+<set Pic_V_18="red-v">
+
+
+##############################################################################
+#  YOUR CHANNEL - copy this block for each extra channel
+##############################################################################
+
+<channel="#example">
+  # Logfile: the log to read; you can list several Logfile lines  EDIT
+  Logfile="/path/to/example.log"
+
+  # LogDir: use INSTEAD of Logfile to read a whole folder of dated logs
+  #LogDir="/path/to/logs/"
+
+  # LogPrefix: with LogDir: only read files starting with this
+  #LogPrefix="example.log."
+
+  # LogSuffix: with LogDir: date format at the end of the file names, so they sort by
+  # date
+  #LogSuffix=".%d%b%Y"
+
+  # NFiles: with LogDir: only parse the newest N files (0 = all)
+  #NFiles="30"
+
+  # Format: your log format: eggdrop, mIRC, xchat, irssi, ... (see docs/FORMATS)  EDIT
+  Format="eggdrop"
+
+  # Network: the IRC network, shown on the page  EDIT
+  Network="ExampleNet"
+
+  # OutputFile: the page pisg writes  EDIT
+  OutputFile="/var/www/html/example.html"
+
+  # OutputTag: replaces %t in OutputFile; used with the -nf / -t command line options
+  #OutputTag="-week"
+
+  # Maintainer: who is named as maintainer on the page  EDIT
+  Maintainer="Your Name"
+
+  # LogType: only "Logfile" exists, leave it
+  LogType="Logfile"
+
+  # Any global option can be overridden for this channel only, for example:
+  #Lang="FR"
+  #ColorScheme="midnight"
+</channel>
+
+
+##############################################################################
+#  USERS - link nicks together, add pictures, mark bots
+##############################################################################
+# nick    the name shown in the stats (required)
+# alias   other nicks of the same person, space separated; * matches anything
+#         (Joe* also counts Joe_, Joe^away ...). NickTracking="1" also finds many
+# pic     picture shown next to the user      bigpic  larger picture it links to
+# link    a web address or e-mail address      sex     m, f or b (bot)
+# ignore  y = leave this nick out of the stats altogether (for bots)
+
+<user nick="Alice" alias="Alice_ Alice-* AliceAway" pic="alice.png" link="https://example.com/alice" sex="f">
+<user nick="Bob" alias="Bob_ Bobby" pic="bob.png" bigpic="bob-big.png" link="bob@example.com" sex="m">
+<user nick="ChanBot" sex="b" ignore="y">
+
+##############################################################################
+#  LINKS - keep addresses out of "Most referenced URLs"
+##############################################################################
+<link url="https://example.com/spam" ignore="y">
+
+##############################################################################
+#  INCLUDE - share users between channels or config files
+##############################################################################
+# Put your <user> lines in users.cfg and load them here (an included file cannot
+# include another file):
+#<include="users.cfg">

+ 276 - 0
scripts/adiirc2eggdrop.py

@@ -0,0 +1,276 @@
+#!/usr/bin/env python3
+"""Convert an AdiIRC channel log to eggdrop log format, so pisg can read it.
+
+    adiirc2eggdrop.py mychannel.log OUTDIR --channel '#example' --nick YourNick \\
+        --tz Europe/Paris --before '2026-01-31 03:00' --prefix example.log.
+
+What it does
+  * Keeps only PUBLIC channel events (messages, actions, joins, parts, quits,
+    kicks, nick changes, mode and topic changes). Everything else in a client
+    log - /whois output, notices, private messages to services/users, server
+    text - is dropped on purpose: it is not channel history and may contain
+    private data. This is a whitelist, not a blacklist.
+  * Converts the client's local time to UTC (eggdrop logs are in server time),
+    using --tz. Rules such as daylight saving come from the tz database.
+  * Only writes events strictly before --before (UTC), so the import stops where
+    eggdrop's own logging starts and nothing is counted twice.
+  * Splits output into one file per eggdrop log day. Eggdrop rotates at
+    03:00 (switch-logfiles-at 300), so file YYYYMMDD holds YYYYMMDD 03:00 up to
+    the next day 02:59:59, matching the names eggdrop writes with
+    logfile-suffix ".%Y%m%d".
+  * Never overwrites: an existing output file is skipped and reported.
+
+pisg reads these with  LogDir=... LogPrefix="example.log."  in alphabetical
+order, which is chronological with the YYYYMMDD suffix.
+"""
+import argparse
+import datetime as dt
+import re
+import sys
+import zoneinfo
+from collections import Counter
+from pathlib import Path
+
+ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ap.add_argument("infile")
+ap.add_argument("outdir")
+ap.add_argument("--channel", required=True, help="channel name as eggdrop writes it, e.g. '#example'")
+ap.add_argument("--nick", required=True, help="your own nick (used for 'You were kicked')")
+ap.add_argument("--tz", required=True, help="timezone the client logged in, e.g. Europe/Paris or America/New_York (IANA name)")
+ap.add_argument("--before", required=True, help="UTC cutoff 'YYYY-MM-DD HH:MM'; later events are skipped")
+ap.add_argument("--first-date", help="local date of the first line, YYYY-MM-DD. Optional: by default it is "
+                "worked out from the first 'Day changed' marker in the log")
+ap.add_argument("--prefix", default="channel.log.", help="output file name prefix (default %(default)s)")
+ap.add_argument("--format", choices=("eggdrop", "znc"), default="eggdrop",
+                help="eggdrop (default): eggdrop log days, 03:00 rotation. znc: what ZNC's log module writes "
+                     "(energymech format, one YYYY-MM-DD.log per calendar day), for pisg Format=\"energymech\"")
+ap.add_argument("--znc-tz", default="UTC", help="timezone ZNC writes its time stamps in (default %(default)s); --format znc only")
+ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
+ap.add_argument("--show-actions", action="store_true", help="print the lines treated as /me actions")
+args = ap.parse_args()
+
+CH = args.channel
+TZ = zoneinfo.ZoneInfo(args.tz)
+UTC = dt.timezone.utc
+CUTOFF = dt.datetime.strptime(args.before, "%Y-%m-%d %H:%M").replace(tzinfo=UTC)
+JITTER = 300                                      # seconds; smaller backwards steps are clock jitter
+ROTATE = dt.timedelta(hours=3)                    # eggdrop switch-logfiles-at 300
+MONTHS = {m: i for i, m in enumerate("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(), 1)}
+P = r"[@+%~&!.]?"                                 # channel status prefix on a nick
+
+COLOUR = re.compile(r"\x03\d{0,2}(?:,\d{1,2})?|[\x02\x0f\x16\x1d\x1f]")
+LINE = re.compile(r"^\[(\d\d):(\d\d):(\d\d)\] (.*)$")
+DAY = re.compile(r"\* Day changed to \w+, (\d+)\. (\w+) (\d+)$")
+
+# Text of "* nick ..." lines that are WHOIS/server output, not a /me action.
+NOT_ACTION = re.compile(
+    r"^\* \S+ (?:is \S+@\S+ \*|on <?[@+%~&]*#|using \S+ |is logged in|has been idle|End of /WHOIS"
+    r"|has modes|created on|is now known as|sets mode|changes topic|was kicked)"
+    r"|^\* (?:#\S+ (?:has modes|created on)|Scanning|Topic |Now talking|Rejoin|Disconnected|Attempting|Connect"
+    r"|You |Joins:|Parts:|Quits:)"
+    r"|^\* \S+ (?:#\S+ ){2,}"
+    r"|^\* \S+ (?:invites you|has been invited|is away|is back|is an IRC|is a |End of /NAMES)"
+    r"|^\* [#/]"
+)
+
+PARTICIPANTS = set()   # nicks seen speaking/joining/changing nick: only they can /me
+
+
+def collect_participants(text):
+    for raw_line in text.split("\n"):
+        b = COLOUR.sub("", raw_line.rstrip("\r"))
+        mm = LINE.match(b)
+        if not mm:
+            continue
+        b = mm.group(4)
+        for rx in (rf"<{P}([^>\s]+)> ", rf"\* Joins: {P}(\S+) ", rf"\* Parts: {P}(\S+) ", rf"\* Quits: {P}(\S+) ",
+                   rf"\* {P}(\S+) is now known as {P}(\S+)$"):
+            x = re.match(rx, b)
+            if x:
+                PARTICIPANTS.update(g for g in x.groups() if g)
+                break
+
+
+kept = Counter()
+dropped = Counter()
+events = []          # (utc datetime, eggdrop text)
+actions_seen = []
+order_debug = []
+
+
+ZNC = args.format == "znc"
+
+
+def convert(body):
+    """Return (text, kind) for a whitelisted event, else (None, reason). The text is in the chosen output format."""
+    m = re.match(rf"<{P}([^>\s]+)> (.*)$", body)
+    if m:
+        return f"<{m.group(1)}> {m.group(2)}", "message"
+    m = re.match(rf"\* Joins: {P}(\S+) \((\S+)\)$", body)
+    if m:
+        return (f"*** Joins: {m.group(1)} ({m.group(2)})" if ZNC else f"{m.group(1)} ({m.group(2)}) joined {CH}."), "join"
+    m = re.match(rf"\* Parts: {P}(\S+) \((\S+)\)(?: \((.*)\))?$", body)
+    if m:
+        why = f" ({m.group(3)})" if m.group(3) else ""
+        return (f"*** Parts: {m.group(1)} ({m.group(2)}){why}" if ZNC else f"{m.group(1)} ({m.group(2)}) left {CH}.{why}"), "part"
+    m = re.match(rf"\* Quits: {P}(\S+) \((\S+)\) \((.*)\)$", body)
+    if m:
+        return (f"*** Quits: {m.group(1)} ({m.group(2)}) ({m.group(3)})" if ZNC else f"{m.group(1)} ({m.group(2)}) left irc: {m.group(3)}"), "quit"
+    m = re.match(rf"\* {P}(\S+) is now known as {P}(\S+)$", body)
+    if m:
+        return (f"*** {m.group(1)} is now known as {m.group(2)}" if ZNC else f"Nick change: {m.group(1)} -> {m.group(2)}"), "nick"
+    m = re.match(rf"\* {P}(\S+) was kicked from (#\S+) by {P}(\S+) \((.*)\)$", body)
+    if m:
+        return (f"*** {m.group(1)} was kicked by {m.group(3)} ({m.group(4)})" if ZNC else f"{m.group(1)} kicked from {CH} by {m.group(3)}: {m.group(4)}"), "kick"
+    m = re.match(rf"\* You were kicked by {P}(\S+) \((.*)\)$", body)
+    if m:
+        return (f"*** {args.nick} was kicked by {m.group(1)} ({m.group(2)})" if ZNC else f"{args.nick} kicked from {CH} by {m.group(1)}: {m.group(2)}"), "kick"
+    m = re.match(rf"\* {P}(\S+) sets mode: (.+)$", body)
+    if m:
+        return (f"*** {m.group(1)} sets mode: {m.group(2)}" if ZNC else f"{CH}: mode change '{m.group(2)}' by {m.group(1)}!*@*"), "mode"
+    m = re.match(rf"\* {P}(\S+) changes topic to: (.*)$", body)
+    if m:
+        return (f"*** {m.group(1)} changes topic to '{m.group(2)}'" if ZNC else f"Topic changed on {CH} by {m.group(1)}!*@*: {m.group(2)}"), "topic"
+    m = re.match(rf"\* {P}(\S+) (.+)$", body)
+    if m and not NOT_ACTION.match(body) and m.group(1) in PARTICIPANTS:
+        if args.show_actions:
+            actions_seen.append(body)
+        return (f"* {m.group(1)} {m.group(2)}" if ZNC else f"Action: {m.group(1)} {m.group(2)}"), "action"
+    return None, "other"
+
+
+prev = None
+raw = Path(args.infile).read_text(encoding="utf-8", errors="replace")
+collect_participants(raw)
+
+
+def derive_first_date(text):
+    """The log only carries dates in 'Day changed' markers. Work the start date back
+    from the first marker: every backwards step of the clock before it is a new
+    session on a later day, so start + steps = marker date - 1."""
+    steps, last = 0, None
+    for raw_line in text.split("\n"):
+        mm = LINE.match(COLOUR.sub("", raw_line.rstrip("\r")))
+        if not mm:
+            continue
+        dd = DAY.match(mm.group(4))
+        if dd:
+            first_marker = dt.date(int(dd[3]), MONTHS[dd[2]], int(dd[1]))
+            return first_marker - dt.timedelta(days=1 + steps)
+        tod = dt.time(int(mm[1]), int(mm[2]), int(mm[3]))
+        if last is not None and tod < last:
+            steps += 1
+        last = tod
+    sys.exit("no 'Day changed' marker found; pass --first-date")
+
+
+local_date = dt.date.fromisoformat(args.first_date) if args.first_date else derive_first_date(raw)
+print(f"log starts on local date {local_date}")
+prev_tod = None          # previous line's local time of day
+last_line = None
+rollovers = 0
+marker_conflicts = 0
+for ln in raw.split("\n"):
+    ln = COLOUR.sub("", ln.rstrip("\r"))
+    m = LINE.match(ln)
+    if not m:
+        if ln.strip():
+            dropped["unparsed (no timestamp)"] += 1
+        continue
+    h, mi, s, body = int(m[1]), int(m[2]), int(m[3]), m[4]
+    d = DAY.match(body)
+    if d:
+        new_date = dt.date(int(d[3]), MONTHS[d[2]], int(d[1]))
+        if new_date < local_date:
+            marker_conflicts += 1        # our inferred date had already run ahead
+        local_date = new_date
+        prev_tod = dt.time(0, 0, 0)
+        continue
+    tod = dt.time(h, mi, s)
+    if prev_tod is not None and tod < prev_tod:
+        back = (dt.datetime.combine(dt.date.min, prev_tod) - dt.datetime.combine(dt.date.min, tod)).total_seconds()
+        if back > JITTER:
+            # Clock jumped backwards with no "Day changed": a new client session on
+            # a later day (the client only writes the marker when connected at midnight).
+            local_date += dt.timedelta(days=1)
+            rollovers += 1
+            prev_tod = tod
+        # else: a second or two of jitter between near-simultaneous events; same day
+    else:
+        prev_tod = tod
+    text, kind = convert(body)
+    if text is not None and kind != "message" and ln == last_line:
+        dropped["duplicate state event (two windows logging)"] += 1
+        continue
+    last_line = ln
+    if text is None:
+        last_line = ln
+    # (text, kind) handled below
+    if text is None:
+        dropped[kind] += 1
+        continue
+    local = dt.datetime.combine(local_date, dt.time(h, mi, s), tzinfo=TZ)
+    utc = local.astimezone(UTC)
+    if prev and utc < prev - dt.timedelta(seconds=JITTER):
+        dropped["out-of-order (skipped)"] += 1
+        if len(order_debug) < 12:
+            order_debug.append(f"local {local:%Y-%m-%d %H:%M:%S} is before previous kept event {prev.astimezone(TZ):%Y-%m-%d %H:%M:%S}: {body[:60]}")
+        continue
+    prev = utc if prev is None else max(prev, utc)
+    if utc >= CUTOFF:
+        dropped["after cutoff (eggdrop has these)"] += 1
+        continue
+    kept[kind] += 1
+    events.append((utc, text))
+
+# Group into log days and write.
+files = {}
+if ZNC:
+    ZTZ = zoneinfo.ZoneInfo(args.znc_tz)
+    for utc, text in events:
+        local_z = utc.astimezone(ZTZ)
+        files.setdefault(local_z.date(), []).append((local_z, text))
+else:
+    # eggdrop log days run 03:00 to 02:59:59
+    for utc, text in events:
+        files.setdefault((utc - ROTATE).date(), []).append((utc, text))
+
+out = Path(args.outdir)
+out.mkdir(parents=True, exist_ok=True)
+written = skipped = 0
+for day in sorted(files):
+    name = f"{day:%Y-%m-%d}.log" if ZNC else f"{args.prefix}{day:%Y%m%d}"
+    lines = []
+    if not ZNC:
+        first_utc = files[day][0][0]
+        lines.append(f"[03:00:00] --- {first_utc:%a %b %d %Y}")      # self-describing header
+    cur = files[day][0][0].date()
+    for utc, text in files[day]:
+        if not ZNC and utc.date() != cur:
+            cur = utc.date()
+            lines.append(f"[00:00:00] --- {utc:%a %b %d %Y}")    # midnight, like eggdrop
+        lines.append(f"[{utc:%H:%M:%S}] {text}")
+    if args.dry_run:
+        written += 1
+        continue
+    target = out / name
+    if target.exists():
+        print(f"SKIP existing {target}")
+        skipped += 1
+        continue
+    with open(target, "x", encoding="utf-8", newline="\n") as fh:   # "x": refuse to overwrite
+        fh.write("\n".join(lines) + "\n")
+    written += 1
+
+print(f"\nkept {sum(kept.values())} events: {dict(kept)}")
+print(f"dropped: {dict(dropped)}")
+if events:
+    print(f"range (UTC): {events[0][0]:%Y-%m-%d %H:%M} .. {events[-1][0]:%Y-%m-%d %H:%M}; {len(files)} log days")
+print(f"date rollovers inferred from a backwards clock: {rollovers}; "
+      f"'Day changed' markers that disagreed with the inferred date: {marker_conflicts}")
+print(f"{'would write' if args.dry_run else 'wrote'} {written} files, skipped {skipped}")
+if order_debug:
+    print("\nfirst out-of-order events:\n  " + "\n  ".join(order_debug))
+if args.show_actions:
+    print("\n--- lines treated as actions ---")
+    print("\n".join(a[:120] for a in actions_seen))

+ 202 - 0
scripts/eggdrop-pisg-test.tcl

@@ -0,0 +1,202 @@
+# Tests for eggdrop-pisg.tcl, run outside eggdrop with a mock of the eggdrop commands:
+#     tclsh8.6 scripts/eggdrop-pisg-test.tcl
+# They never touch a real users file: everything happens in a temporary folder.
+
+set here [file dirname [file normalize [info script]]]
+set tmp [file join [expr {[info exists ::env(TMPDIR)] ? $::env(TMPDIR) : "/tmp"}] "pisgtest.[pid]"]
+file mkdir $tmp
+
+# ---- a mock of the eggdrop commands the script uses ---------------------------------------
+set ::sent {}; set ::binds {}; set ::logs {}; set ::acctreply {}
+proc bind {type flags cmd proc} { lappend ::binds [list $type $flags $cmd $proc] }
+proc putserv {line} { lappend ::sent $line }
+proc puthelp {line} { lappend ::sent $line }
+proc putlog {line}  { lappend ::logs $line }
+proc timer {args} {}
+proc matchattr {hand flags} { return [expr {$hand eq "boss"}] }
+proc getaccount {nick} { if {[dict exists $::acctreply $nick]} { return [dict get $::acctreply $nick] } else { return "" } }
+
+source [file join $here eggdrop-pisg.tcl]
+set pisg::usersfile [file join $tmp users.cfg]
+set pisg::maincfg   [file join $tmp pisg.cfg]
+set pisg::minsecs 0
+
+set fails 0; set passes 0
+proc ok {cond msg} { if {$cond} { incr ::passes } else { incr ::fails; puts "  FAIL: $msg" } }
+# run a command as if typed in a private message; returns what the bot said (NOTICE texts)
+proc say {cmd nick uhost hand arg} {
+    set ::sent {}
+    pisg::dispatch $cmd $nick $uhost $hand $arg
+    set out {}
+    foreach l $::sent { regexp {^NOTICE \S+ :(.*)$} $l -> t; lappend out $t }
+    return $out
+}
+proc first {lines} { return [lindex $lines 0] }
+proc filetext {} { return [pisg::read_file $pisg::usersfile] }
+proc reset {} { file delete -force $pisg::usersfile; file delete -force $pisg::maincfg; array unset pisg::hist; set ::acctreply {} }
+
+set ALICE  "~u@alice.users.undernet.org"
+set BOB  "~b@bob.users.undernet.org"
+set ANON "~x@dsl-123.example.net"
+
+puts "1. identity"
+reset
+ok [string match "*Log in to X*" [first [say info Alice $ANON * "Alice male"]]] "someone not logged in to X gets login instructions"
+ok [expr {[pisg::account Alice $ALICE] eq "alice"}] "account name from the host"
+ok [expr {[pisg::account Alice $ANON] eq ""}] "no account for an ordinary host"
+set ::acctreply [dict create Alice "AliceAcct"]
+ok [expr {[pisg::account Alice $ANON] eq "aliceacct"}] "getaccount is used first, lower-cased"
+set ::acctreply [dict create Alice "*"]
+ok [expr {[pisg::account Alice $ALICE] eq "alice"}] "getaccount '*' (not logged in) falls back to the host"
+set ::acctreply {}
+ok [string match "*Log in to X*" [first [say info Alice $ANON * "Alice male"]]] "still no login: still no edit"
+ok [expr {![file exists $pisg::usersfile]}] "nothing written for an unidentified user"
+
+puts "2. creating and updating a profile"
+reset
+set r [first [say info Alice $ALICE * "Alice male https://example.org/me.png https://example.org"]]
+ok [string match "Saved*" $r] "profile saved: $r"
+set t [filetext]
+ok [string match {*<user nick="Alice" pic="https://example.org/me.png" sex="m" link="https://example.org" owner="alice">*} $t] "file line is right: $t"
+ok [string match "*do not edit below this line*" $t] "marker written"
+set r [first [say info Alice $ALICE * "Alice female"]]
+set t [filetext]
+ok [string match {*sex="f"*} $t] "sex updated"
+ok [string match {*pic="https://example.org/me.png"*} $t] "picture kept when only sex changes"
+ok [string match "*female*" $r] "reply describes the profile"
+say info Alice $ALICE * "Alice link=mailto-not https://new.example.org/x" ;# an invalid token must change nothing
+ok [string match {*link="https://example.org"*} [filetext]] "invalid input changes nothing"
+ok [string match "*don't understand*" [first [say info Alice $ALICE * "Alice blah"]]] "unknown word explained"
+
+puts "3. claiming names"
+reset
+ok [string match "*only create a profile for the nick you are using*" [first [say info Bob $BOB * "Alice male"]]] "cannot create a profile for a nick you are not using"
+say info Alice $ALICE * "Alice male"
+ok [string match "*already taken*" [first [say info Alice $BOB * "Alice male"]]] "cannot take a name somebody has"
+ok [string match "*don't have a profile called Alice*" [first [say merge Bob $BOB * "Alice"]]] "cannot merge into somebody else's profile"
+say info Bob $BOB * "Bob male"
+ok [string match "*already have a profile named Bob*" [first [say info Bob $BOB * "Zed male"]]] "one profile per account"
+ok [string match "*already have a profile named Bob*" [first [say info Bob2 $BOB * "Bob2 male"]]] "still one profile per account"
+
+puts "4. merging nicks"
+reset
+say info Alice $ALICE * "Alice male"
+ok [string match "Done*" [first [say merge Alice_ $ALICE * "Alice"]]] "Alice_ merged into Alice"
+ok [string match {*alias="Alice_"*} [filetext]] "alias written"
+ok [string match "*already counted*" [first [say merge Alice_ $ALICE * "Alice"]]] "merging twice is harmless"
+ok [string match "*already Alice*" [first [say merge Alice $ALICE * "Alice"]]] "merging the main nick into itself is refused"
+say info Bob $BOB * "Bob male"
+ok [string match "*already used by another profile*" [first [say merge Alice_ $BOB * "Bob"]]] "an alias held by someone else cannot be taken"
+say merge Alice- $ALICE * "Alice"
+ok [string match {*alias="Alice_ Alice-"*} [filetext]] "second alias appended"
+ok [string match "Done*" [first [say unmerge Alice $ALICE * "Alice_"]]] "unmerge works"
+ok [string match {*alias="Alice-"*} [filetext]] "alias removed"
+ok [string match "*not one of your aliases*" [first [say unmerge Alice $ALICE * "Nobody"]]] "unmerge of a stranger refused"
+
+puts "5. your hand-written lines are protected and preserved"
+reset
+set f [open $pisg::maincfg w]; puts $f "# main\n<user nick=\"Zed\" alias=\"Zeddy Z-\" sex=\"m\">"; close $f
+set f [open $pisg::usersfile w]; puts $f "# my notes\n<user nick=\"Manny\" pic=\"a.png\">"; close $f
+ok [string match "*already taken*" [first [say info Zed "~z@zed.users.undernet.org" * "Zed male"]]] "a nick from pisg.cfg cannot be claimed"
+ok [string match "*already taken*" [first [say info Zeddy "~z@zeddy.users.undernet.org" * "Zeddy male"]]] "an alias from pisg.cfg cannot be claimed"
+ok [string match "*already taken*" [first [say info Manny "~m@manny.users.undernet.org" * "Manny male"]]] "a hand-written line in users.cfg cannot be claimed"
+say info Alice $ALICE * "Alice male"; say info Bob $BOB * "Bob male"
+set t [filetext]
+ok [string match "*# my notes*" $t] "your comment survives"
+ok [string match {*<user nick="Manny" pic="a.png">*} $t] "your <user> line survives untouched"
+ok [expr {[string first "# my notes" $t] < [string first "managed by" $t]}] "your part stays above the marker"
+ok [expr {[string first {nick="Alice"} $t] < [string first {nick="Bob"} $t]}] "managed entries are sorted"
+ok [string match "*set by hand*" [first [say show Alice $ALICE * "Zed"]]] "show says a hand-written profile exists"
+
+puts "6. hostile and invalid input"
+reset
+foreach bad {
+    "Alice male http://example.org/me.png"           "Alice male javascript:alert(1)"
+    "Alice male https://localhost/me.png"             "Alice male https://127.0.0.1/me.png"
+    "Alice male https://example.org/a\"b.png"         "Alice male https://example.org/me.png?x=<script>"
+    "Alice male https://exa mple.org/me.png"          "Alice male https://example..org/me.png"
+    "Se<b male"                                     "Alice sex=alien"
+    "Alice male https://.org/x.png" } {
+    set r [say info Alice $ALICE * $bad]
+    ok [expr {![string match "Saved*" [first $r]]}] "refused: $bad"
+}
+ok [expr {![file exists $pisg::usersfile]}] "nothing written by any of them"
+ok [pisg::valid_url "https://example.org/a/b?c=d"] "a normal https link is fine"
+ok [expr {![pisg::valid_url "https://example.org/[string repeat a 300]"]}] "over-long links refused"
+ok [pisg::valid_email "me@example.org"] "e-mail is a valid link"
+ok [expr {![pisg::valid_email "me@example.org\"><b>"]}] "hostile e-mail refused"
+set r [first [say info Alice $ALICE * "Alice me@example.org"]]
+ok [string match "Saved*" $r] "an e-mail address works as the link"
+
+puts "7. nicks with characters that are special in Tcl lists"
+reset
+set ODD "~o@odd.users.undernet.org"
+ok [string match "Saved*" [first [say info {[x]{y}} $ODD * {[x]{y} male}]]] "profile for a nick with braces and brackets"
+ok [string match "Done*" [first [say merge {a\b} $ODD * {[x]{y}}]]] "alias with a backslash"
+ok [string match "Done*" [first [say merge {c|d^e} $ODD * {[x]{y}}]]] "alias with | and ^"
+lassign [pisg::load] man ents
+ok [expr {[llength $ents] == 1}] "one entry read back"
+ok [expr {[dict get [lindex $ents 0] name] eq "{\[x\]{y}}" || [dict get [lindex $ents 0] name] eq {[x]{y}}}] "name read back exactly"
+ok [expr {[dict get [lindex $ents 0] aliases] eq [list {a\b} {c|d^e}]}] "aliases read back exactly: [dict get [lindex $ents 0] aliases]"
+set again [say show {a\b} $ODD * ""]
+ok [expr {[string first {[x]{y}} [first $again]] >= 0 && [string first {a\b} [first $again]] >= 0}] "show works and prints the special characters literally: [first $again]"
+
+puts "8. show, delete, delete-anybody"
+reset
+say info Alice $ALICE * "Alice male https://example.org/me.png"
+ok [string match "*Alice: male*" [first [say show Bob $BOB * "Alice"]]] "anyone can look a profile up"
+ok [string match "*No profile*" [first [say show Bob $BOB * "Nobody"]]] "unknown profile"
+ok [string match "*no profile yet*" [first [say show Bob $BOB * ""]]] "no argument, no profile: says so"
+ok [string match "*Alice: male*" [first [say show Alice $ALICE * ""]]] "no argument shows your own"
+ok [string match "*You don't have a profile*" [first [say del Bob $BOB * ""]]] "delete without a profile"
+ok [string match "*Only bot masters*" [first [say deluser Bob $BOB * "Alice"]]] "a normal user cannot delete somebody else's"
+ok [string match "Deleted*" [first [say deluser Boss "~b@boss.users.undernet.org" boss "Alice"]]] "a bot master can"
+ok [expr {![string match {*nick="Alice"*} [filetext]]}] "gone from the file"
+say info Alice $ALICE * "Alice male"
+ok [string match "Deleted*" [first [say del Alice $ALICE * ""]]] "you can delete your own"
+ok [expr {![string match {*nick="Alice"*} [filetext]]}] "gone from the file"
+set f [open $pisg::maincfg w]; puts $f "<user nick=\"Zed\">"; close $f
+ok [string match "*written by hand*" [first [say deluser Boss "~b@boss.users.undernet.org" boss "Zed"]]] "a bot master cannot delete a hand-written entry"
+
+puts "9. help"
+reset
+set r [say help Alice $ALICE * ""]
+ok [string match "*pisginfo*pisgmerge*pisgshow*" [first $r]] "lists the commands"
+ok [string match "*!pisghelp pisgshow*" [first $r]] "gives the !pisghelp <command> example"
+foreach c {pisginfo pisgmerge pisgunmerge pisgshow pisgdel pisgdeluser pisgstats pisghelp} {
+    ok [string match "*$c*" [first [say help Alice $ALICE * $c]]] "help for $c"
+}
+ok [string match "*pisgshow*" [first [say help Alice $ALICE * "!pisgshow"]]] "!pisgshow accepted"
+ok [string match "*pisgshow*" [first [say help Alice $ALICE * "show"]]] "'show' short form accepted"
+ok [string match "*don't know a command*" [first [say help Alice $ALICE * "nonsense"]]] "unknown command"
+
+puts "10. rate limits"
+reset
+set pisg::minsecs 5
+say info Alice $ALICE * "Alice male"
+ok [string match "*Slow down*" [first [say info Alice $ALICE * "Alice female"]]] "two changes in a row are slowed"
+ok [string match "*Alice*" [first [say show Alice $ALICE * ""]]] "reading is never slowed"
+set pisg::minsecs 0; set pisg::maxperhour 3; array unset pisg::hist
+for {set i 0} {$i < 3} {incr i} { say info Alice $ALICE * "Alice male" }
+ok [string match "*Slow down*" [first [say info Alice $ALICE * "Alice female"]]] "too many changes in an hour are refused"
+ok [string match "*Alice*" [first [say show Alice $ALICE * ""]]] "still can read"
+set pisg::maxperhour 30
+
+puts "11. replies stay short enough for IRC, and carry no control codes"
+reset
+set long "https://example.org/[string repeat p 250].png"
+say info Alice $ALICE * "Alice male $long https://example.org/[string repeat q 250]"
+foreach l [say show Alice $ALICE * ""] { ok [expr {[string length $l] <= 400}] "line is [string length $l] characters" }
+ok [expr {[pisg::clean "a\x01b\x02c\r\nd"] eq "a b c  d"}] "control codes removed"
+
+puts "12. bindings"
+set types {}; foreach b $::binds { lappend types [lrange $b 0 2] }
+foreach c {help info merge unmerge show del deluser} {
+    ok [expr {[lsearch $types "pub - !pisg$c"] >= 0}] "channel command !pisg$c"
+    ok [expr {[lsearch $types "msg - pisg$c"] >= 0}] "private message pisg$c"
+}
+ok [expr {[lsearch $types "pub - !pisgstats"] >= 0}] "!pisgstats is open to everyone"
+
+file delete -force $tmp
+puts "\n$passes passed, $fails failed"
+exit [expr {$fails ? 1 : 0}]

+ 402 - 44
scripts/eggdrop-pisg.tcl

@@ -1,57 +1,415 @@
-#pisg.tcl v0.15 by HM2K - auto stats script for pisg (perl irc statistics generator)
-#based on a script by Arganan
-
-# WARNING - READ THIS
+# eggdrop-pisg.tcl - pisg for eggdrop: !pisgstats, and profiles people edit themselves.
+#
+# Based on pisg.tcl by HM2K / Arganan (the !pisgstats command). The profile commands are new
+# in pisg 1.0.a. People set their own sex, picture, link and merge their nicks in chat; the
+# bot writes them to users.cfg, which pisg reads through  <include="/path/to/users.cfg">  on
+# its next run. Nothing else is needed: no web page, no database.
 #
-# If you use this script, PLEASE read the documentation about the "Silent"
-# option. If you get the message "an error occured: Pisg v0.** - perl irc
-# statistics generator" in the channel, you are NOT running silent. Fix it.
+# Commands (in the channel with !, or in a private message without it):
+#   pisghelp [command]     what the commands do
+#   pisginfo <name> [male|female] [picture-url] [link]      set your profile
+#   pisgmerge <name>       count the nick you are using now as <name>
+#   pisgunmerge <nick>     stop counting <nick> as yours
+#   pisgshow [name]        show a profile
+#   pisgdel                delete your own profile
+#   pisgdeluser <name>     bot masters only: delete anybody's profile
+#   pisgstats              give the address of the stats page (anybody)
+#
+# Who is who: the bot only edits a profile for someone it can identify, by the services account
+# (eggdrop's getaccount, or the account.users.undernet.org host X gives to people logged in
+# with +x). A profile belongs to the account, not the nick, so changing nick is fine. You can
+# only claim the nick you are using right now, so nobody can take somebody else's name.
 
-set pisgver "0.16"
+namespace eval pisg {
+    variable version   "1.0.a"
 
-#Location of pisg execuitable perl script
-set pisgexe "/home/bot/pisg-0.73/pisg"
+    # ---- settings -------------------------------------------------------------------------
+    # Change these to match your setup, or keep your own values in pisg.local.tcl next to this file
+    # (see below), so an update of this script never overwrites them.
+    variable exe        "/path/to/pisg/pisg"                ;# the pisg script
+    variable url        "https://example.org/stats/"        ;# folder the stats pages are in (with the closing /)
+    variable chan       "#yourchannel"
+    variable outdir     "/path/to/pisg/output"              ;# where pisg writes the pages (for the "updated" age)
+    variable adminflags "m"                ;# who may use pisgdeluser
+    variable usersfile  "/path/to/pisg/users.cfg"           ;# written by the bot, read by pisg
+    variable maincfg    "/path/to/pisg/pisg.cfg"            ;# your hand-written <user> lines are protected
+    variable trigger    "!"
+    variable accounthost {^[^@]*@([^.@]+)\.users\.undernet\.org$}   ;# Undernet: the host X gives to a logged-in +x user. Other networks: see getaccount
+    variable maxaliases 20
+    variable minsecs    2                  ;# seconds between two changes by the same account
+    variable maxperhour 30                 ;# changes per hour per account
+    variable autorun    0                  ;# 1: run pisg from a timer (cron normally does)
+    variable autorunmin 180
 
-#URL of the generated stats
-set pisgurl "http://nemesisforce.com/ircstats"
+    # Your own settings: a file called pisg.local.tcl in the same folder is read here, after the
+    # defaults above. It only needs lines such as   set ::pisg::url "https://example.org/stats/"
+    set localsettings [file join [file dirname [info script]] pisg.local.tcl]
+    if {[file exists $localsettings]} { source $localsettings }
 
-#channel that the stats are generated for
-set pisgchan "#pisg"
+    variable marker "# ---- managed by eggdrop-pisg.tcl: do not edit below this line, the bot rewrites it ----"
+    variable hist;   array set hist {}
 
-#Users with these flags can operate this function (friendly/friends by default)
-set pisgflags "f"
+    # ---- checks ---------------------------------------------------------------------------
+    proc valid_nick {n} { return [regexp {^[A-Za-z_\[\]\\`^{|}][A-Za-z0-9_\-\[\]\\`^{|}]{0,29}$} $n] }
 
-#How often the stats will be updated in minutes, ie: 30 - stats will be updated every 30 minutes
-set pisgtime "180"
+    # a web address that is safe to put in a page and in a config line
+    proc valid_url {u} {
+        if {[string length $u] > 300} { return 0 }
+        if {![regexp {^https://([A-Za-z0-9.-]+)(:[0-9]+)?(/[^\s"'<>\\]*)?$} $u -> host]} { return 0 }
+        # a real domain name: labels of letters, digits and inner hyphens, ending in a letters-only TLD.
+        # That also rules out localhost, numeric addresses, ".org", "a..b" and "-x.com".
+        if {![regexp {^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$} $host]} { return 0 }
+        return 1
+    }
+    proc valid_image {u} {
+        return [expr {[valid_url $u] && [regexp -nocase {^[^?#]*\.(png|jpe?g|gif|webp)([?#].*)?$} $u]}]
+    }
+    proc valid_email {e} {
+        return [regexp {^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,200}\.[A-Za-z]{2,}$} $e]
+    }
+    proc clean {s} { regsub -all {[\x00-\x1f\x7f]} $s " " s; return $s }     ;# no control codes in what we send
 
-bind pub $pisgflags !pisgstats pub:pisgcmd1
+    # ---- who is asking --------------------------------------------------------------------
+    # The services account name of the person, or "" when they are not identified.
+    proc account {nick uhost} {
+        variable accounthost
+        if {[llength [info commands getaccount]] && ![catch {getaccount $nick} a]} {
+            if {$a ne "" && $a ne "*"} { return [string tolower $a] }
+        }
+        if {[regexp $accounthost $uhost -> acct]} { return [string tolower $acct] }
+        return ""
+    }
+    proc login_help {} {
+        return "I can only change a profile for someone I can identify. Log in to X and hide your host: /msg x@channels.undernet.org login <username> <password> then /mode <yournick> +x , then try again."
+    }
 
-proc pub:pisgcmd1 {nick host hand chan arg} {
-	global pisgexe pisgurl
-	append out "PRIVMSG $chan :"  
-	if {[catch {exec $pisgexe} error]} { 
-	append out "$pisgexe an error occured: [string totitle $error]" 
-	} else { 
-	append out "Stats: [string tolower ${pisgurl}[string trimleft $chan # ].html]"
-	}
-	puthelp $out
-}
+    # ---- the users file -------------------------------------------------------------------
+    # An entry is a dict: name aliases sex pic link owner.
+    proc attr {line key} {
+        if {[regexp "\\m$key=(\[\"'\])(.+?)\\1" $line -> q v]} { return $v }
+        return ""
+    }
+    proc read_file {path} {
+        if {![file exists $path]} { return "" }
+        set f [open $path r]; fconfigure $f -encoding utf-8
+        set d [read $f]; close $f
+        return $d
+    }
+    # -> {manual-text entries}: the part above the marker is yours and is kept as it is
+    proc load {} {
+        variable usersfile; variable marker
+        set data [read_file $usersfile]
+        set i [string first $marker $data]
+        if {$i < 0} { set manual $data; set managed "" } else {
+            set manual [string range $data 0 [expr {$i - 1}]]
+            set managed [string range $data [expr {$i + [string length $marker]}] end]
+        }
+        set entries {}
+        foreach line [split $managed "\n"] {
+            if {![string match "<user *" [string trim $line]]} { continue }
+            set name [attr $line nick]
+            if {$name eq ""} { continue }
+            # aliases are split with regexp, never with list commands: nicks may contain { } [ ] \ which are special in Tcl lists
+            lappend entries [dict create name $name aliases [regexp -all -inline {\S+} [attr $line alias]] sex [attr $line sex] \
+                pic [attr $line pic] link [attr $line link] owner [attr $line owner]]
+        }
+        return [list $manual $entries]
+    }
+    proc save {manual entries} {
+        variable usersfile; variable marker
+        set out [string trimright $manual "\n"]
+        if {$out eq ""} { set out "# pisg users. Lines above the marker are yours; the bot manages the ones below." }
+        append out "\n\n$marker\n"
+        foreach e [lsort -command {apply {{a b} {string compare -nocase [dict get $a name] [dict get $b name]}}} $entries] {
+            set l "<user nick=\"[dict get $e name]\""
+            if {[llength [dict get $e aliases]]} { append l " alias=\"[join [dict get $e aliases] { }]\"" }
+            foreach {k a} {pic pic sex sex link link} {
+                if {[dict get $e $k] ne ""} { append l " $a=\"[dict get $e $k]\"" }
+            }
+            append l " owner=\"[dict get $e owner]\">"
+            append out "$l\n"
+        }
+        set tmp "$usersfile.tmp[pid]"
+        set f [open $tmp w 0644]; fconfigure $f -encoding utf-8
+        puts -nonewline $f $out; close $f
+        file rename -force $tmp $usersfile                        ;# atomic: pisg never reads half a file
+    }
 
-proc pisgcmd_timer {} {
-	global pisgexe pisgurl pisgtime chan
-	append out "PRIVMSG gchan :" 
-	if {[catch {exec $pisgexe} error]} { 
-	append out "$pisgexe an error occured: [string totitle $error]" 
-	} else { 
-	append out "Stats: [string tolower ${pisgurl}[string trimleft $chan # ].html]"
-	}
-	puthelp $out
-	timer $pisgtime pisgcmd_timer
-}
+    # every nick or alias somebody already holds: your hand-written lines, and everyone's entries
+    proc reserved {manual entries} {
+        variable maincfg
+        set r [dict create]
+        foreach text [list $manual [read_file $maincfg]] {
+            foreach line [split $text "\n"] {
+                set t [string trim $line]
+                if {[string match "#*" $t] || ![string match "*<user*" $t]} { continue }
+                set n [attr $line nick]
+                if {$n ne ""} { dict set r [string tolower $n] manual }
+                foreach a [regexp -all -inline {\S+} [attr $line alias]] { dict set r [string tolower $a] manual }
+            }
+        }
+        foreach e $entries {
+            dict set r [string tolower [dict get $e name]] [dict get $e owner]
+            foreach a [dict get $e aliases] { dict set r [string tolower $a] [dict get $e owner] }
+        }
+        return $r
+    }
+    proc find_owner {entries acct} {
+        set i 0
+        foreach e $entries { if {[dict get $e owner] eq $acct} { return $i }; incr i }
+        return -1
+    }
+    proc find_name {entries name} {
+        set i 0
+        foreach e $entries { if {[string equal -nocase [dict get $e name] $name]} { return $i }; incr i }
+        return -1
+    }
+    proc sex_word {s} { switch $s { m {return male} f {return female} default {return "not set"} } }
+    proc describe {e} {
+        set s "[dict get $e name]: [sex_word [dict get $e sex]]"
+        if {[dict get $e pic]  ne ""} { append s ", picture [dict get $e pic]" }
+        if {[dict get $e link] ne ""} { append s ", link [dict get $e link]" }
+        if {[llength [dict get $e aliases]]} { append s ", also counted as: [join [dict get $e aliases] {, }]" }
+        return $s
+    }
+
+    # ---- rate limit ------------------------------------------------------------------------
+    proc allowed {acct} {
+        variable hist; variable minsecs; variable maxperhour
+        set now [clock seconds]
+        set recent {}
+        if {[info exists hist($acct)]} { foreach t $hist($acct) { if {$now - $t < 3600} { lappend recent $t } } }
+        if {[llength $recent] && $now - [lindex $recent end] < $minsecs} { set hist($acct) $recent; return 0 }
+        if {[llength $recent] >= $maxperhour} { set hist($acct) $recent; return 0 }
+        lappend recent $now; set hist($acct) $recent
+        return 1
+    }
+
+    # ---- the commands: each returns the lines to say back --------------------------------------
+    proc parse_profile {words} {
+        # -> {ok sex pic link error}
+        set sex ""; set pic ""; set link ""
+        foreach w $words {
+            if {[regexp {^(sex|pic|link|mail)=(.*)$} $w -> k v]} {
+                if {$k eq "sex"} { set w $v } elseif {$k eq "pic"} { set pic $v; continue } else { set link $v; continue }
+            }
+            switch -nocase -- $w {
+                m - male   { set sex m; continue }
+                f - female { set sex f; continue }
+            }
+            if {[valid_image $w] && $pic eq ""} { set pic $w } elseif {[valid_url $w] && $link eq ""} { set link $w } \
+            elseif {[valid_email $w] && $link eq ""} { set link $w } else {
+                return [list 0 "" "" "" "I don't understand '[clean $w]': give male or female, a picture address (https, ending in .png .jpg .gif or .webp) and/or a link (https or an e-mail address)."]
+            }
+        }
+        if {$pic ne "" && ![valid_image $pic]} { return [list 0 "" "" "" "That picture address is not valid: it must be https and end in .png, .jpg, .gif or .webp."] }
+        if {$link ne "" && ![valid_url $link] && ![valid_email $link]} { return [list 0 "" "" "" "That link is not valid: use an https address or an e-mail address."] }
+        return [list 1 $sex $pic $link ""]
+    }
+
+    proc cmd_info {nick acct isadmin arg} {
+        set words [regexp -all -inline {\S+} $arg]
+        if {[llength $words] < 1} { return [list "Usage: pisginfo <name> \[male|female\] \[picture-url\] \[link\]   (ask: pisghelp pisginfo)"] }
+        if {$acct eq ""} { return [list [login_help]] }
+        set name [lindex $words 0]
+        if {![valid_nick $name]} { return [list "'[clean $name]' is not a valid nick."] }
+        lassign [parse_profile [lrange $words 1 end]] ok sex pic link err
+        if {!$ok} { return [list $err] }
+        if {$sex eq "" && $pic eq "" && $link eq ""} { return [list "Nothing to set. Give male or female, a picture and/or a link (ask: pisghelp pisginfo)."] }
+        lassign [load] manual entries
+        set i [find_owner $entries $acct]
+        if {$i >= 0} {
+            set e [lindex $entries $i]
+            if {![string equal -nocase [dict get $e name] $name]} {
+                return [list "You already have a profile named [dict get $e name]. Use that name, or delete it first with pisgdel."]
+            }
+        } else {
+            if {![string equal -nocase $name $::pisg::currentnick]} {
+                return [list "You can only create a profile for the nick you are using now ($::pisg::currentnick). Change to $name first, or use your current nick."]
+            }
+            set res [reserved $manual $entries]
+            if {[dict exists $res [string tolower $name]]} {
+                return [list "$name is already taken by another profile."]
+            }
+            set e [dict create name $name aliases "" sex "" pic "" link "" owner $acct]
+            set entries [linsert $entries end $e]; set i [expr {[llength $entries] - 1}]
+        }
+        if {$sex  ne ""} { dict set e sex  $sex }
+        if {$pic  ne ""} { dict set e pic  $pic }
+        if {$link ne ""} { dict set e link $link }
+        set entries [lreplace $entries $i $i $e]
+        save $manual $entries
+        return [list "Saved. [describe $e]. It shows on the next stats update."]
+    }
+
+    proc cmd_merge {nick acct isadmin arg} {
+        set name [lindex [regexp -all -inline {\S+} $arg] 0]
+        if {$name eq ""} { return [list "Usage: pisgmerge <name>: counts the nick you are using now ($nick) as <name>. (ask: pisghelp pisgmerge)"] }
+        if {$acct eq ""} { return [list [login_help]] }
+        if {![valid_nick $name]} { return [list "'[clean $name]' is not a valid nick."] }
+        lassign [load] manual entries
+        set i [find_owner $entries $acct]
+        if {$i < 0 || ![string equal -nocase [dict get [lindex $entries $i] name] $name]} {
+            return [list "You don't have a profile called $name. Create it first, from your main nick: pisginfo $name male|female"]
+        }
+        set e [lindex $entries $i]
+        if {[string equal -nocase $nick $name]} { return [list "You are already $name."] }
+        if {[lsearch -nocase [dict get $e aliases] $nick] >= 0} { return [list "$nick is already counted as $name."] }
+        set res [reserved $manual $entries]
+        if {[dict exists $res [string tolower $nick]]} { return [list "$nick is already used by another profile, so I can't add it."] }
+        if {[llength [dict get $e aliases]] >= $::pisg::maxaliases} { return [list "That is the maximum of $::pisg::maxaliases aliases."] }
+        dict lappend e aliases $nick
+        set entries [lreplace $entries $i $i $e]
+        save $manual $entries
+        return [list "Done: $nick now counts as $name. It shows on the next stats update."]
+    }
+
+    proc cmd_unmerge {nick acct isadmin arg} {
+        set a [lindex [regexp -all -inline {\S+} $arg] 0]
+        if {$a eq ""} { return [list "Usage: pisgunmerge <nick>: stops counting <nick> as yours."] }
+        if {$acct eq ""} { return [list [login_help]] }
+        lassign [load] manual entries
+        set i [find_owner $entries $acct]
+        if {$i < 0} { return [list "You don't have a profile."] }
+        set e [lindex $entries $i]
+        set k [lsearch -nocase [dict get $e aliases] $a]
+        if {$k < 0} { return [list "$a is not one of your aliases."] }
+        dict set e aliases [lreplace [dict get $e aliases] $k $k]
+        set entries [lreplace $entries $i $i $e]
+        save $manual $entries
+        return [list "Done: $a no longer counts as [dict get $e name]."]
+    }
+
+    proc cmd_show {nick acct isadmin arg} {
+        set name [lindex [regexp -all -inline {\S+} $arg] 0]
+        lassign [load] manual entries
+        if {$name eq ""} {
+            set i [expr {$acct ne "" ? [find_owner $entries $acct] : -1}]
+            if {$i < 0} { set i [find_name $entries $nick] }
+            if {$i < 0} { return [list "You have no profile yet. Make one with: pisginfo $nick male|female <picture-url> <link>"] }
+        } else {
+            set i [find_name $entries $name]
+            if {$i < 0} {
+                set res [reserved $manual {}]
+                if {[dict exists $res [string tolower $name]]} { return [list "$name has a profile set by hand in the pisg config."] }
+                return [list "No profile for [clean $name]."]
+            }
+        }
+        return [list [describe [lindex $entries $i]]]
+    }
+
+    proc cmd_del {nick acct isadmin arg} {
+        if {$acct eq ""} { return [list [login_help]] }
+        lassign [load] manual entries
+        set i [find_owner $entries $acct]
+        if {$i < 0} { return [list "You don't have a profile."] }
+        set name [dict get [lindex $entries $i] name]
+        save $manual [lreplace $entries $i $i]
+        return [list "Deleted the profile $name."]
+    }
+
+    proc cmd_deluser {nick acct isadmin arg} {
+        if {!$isadmin} { return [list "Only bot masters can delete other people's profiles. To delete your own, use pisgdel."] }
+        set name [lindex [regexp -all -inline {\S+} $arg] 0]
+        if {$name eq ""} { return [list "Usage: pisgdeluser <name>"] }
+        lassign [load] manual entries
+        set i [find_name $entries $name]
+        if {$i < 0} {
+            set res [reserved $manual {}]
+            if {[dict exists $res [string tolower $name]]} { return [list "$name is written by hand in a config file. Edit that file to remove it."] }
+            return [list "No profile for [clean $name]."]
+        }
+        set d [lindex $entries $i]
+        save $manual [lreplace $entries $i $i]
+        return [list "Deleted the profile [dict get $d name] (account [dict get $d owner])."]
+    }
+
+    proc help_text {} {
+        return {
+            pisghelp {"pisghelp [command]: what the commands do. Try: pisghelp pisginfo"}
+            pisginfo {"pisginfo <name> [male|female] [picture-url] [link]: set your profile on the stats page. <name> is the nick you are using now. Any of the parts can be given later, alone. Picture: https address ending in .png .jpg .gif or .webp. Link: an https address or an e-mail address."
+                      "Example: pisginfo Alice male https://example.org/me.png https://example.org"}
+            pisgmerge {"pisgmerge <name>: counts the nick you are using now as <name>, so Alice_ and Alice- are the same person as Alice. Do it once from each nick, after creating <name> with pisginfo."}
+            pisgunmerge {"pisgunmerge <nick>: stops counting <nick> as yours."}
+            pisgshow {"pisgshow [name]: shows a profile. Without a name, shows yours."}
+            pisgdel {"pisgdel: deletes your own profile, so your picture, link and merged nicks are removed from the stats."}
+            pisgdeluser {"pisgdeluser <name>: bot masters only: deletes anybody's profile."}
+            pisgstats {"pisgstats: regenerates the stats now and gives the address (friends of the bot only)."}
+        }
+    }
+    proc cmd_help {nick acct isadmin arg} {
+        variable trigger
+        set h [help_text]
+        set c [string tolower [string trimleft [lindex [regexp -all -inline {\S+} $arg] 0] $trigger]]
+        if {$c ne "" && ![string match "pisg*" $c]} { set c "pisg$c" }
+        if {$c eq ""} {
+            return [list "Commands: pisginfo, pisgmerge, pisgunmerge, pisgshow, pisgdel, pisgdeluser, pisgstats. Use them in the channel with $trigger in front, or in a private message without it. For details: ${trigger}pisghelp <command>, e.g. ${trigger}pisghelp pisgshow." \
+                "To change a profile you must be logged in to X with +x, so I can tell who you are."]
+        }
+        if {![dict exists $h $c]} { return [list "I don't know a command called '[clean $c]'. Try ${trigger}pisghelp."] }
+        return [dict get $h $c]
+    }
+
+    # ---- eggdrop glue -----------------------------------------------------------------------
+    variable currentnick ""
+    proc say {nick lines} {
+        foreach l $lines {
+            set l [clean $l]
+            while {[string length $l] > 380} {                 ;# IRC lines are limited to 512 bytes in all
+                set cut [string last " " $l 380]; if {$cut < 100} { set cut 380 }
+                putserv "NOTICE $nick :[string range $l 0 $cut]"
+                set l [string trimleft [string range $l [expr {$cut + 1}] end]]
+            }
+            putserv "NOTICE $nick :$l"
+        }
+    }
+    proc dispatch {cmd nick uhost hand arg} {
+        variable currentnick
+        set currentnick $nick
+        set acct [account $nick $uhost]
+        set edits {info merge unmerge del deluser}
+        if {[lsearch $edits $cmd] >= 0 && $acct ne "" && ![allowed $acct]} {
+            say $nick [list "Slow down a little, then try again."]; return
+        }
+        set isadmin [expr {$hand ne "*" && [matchattr $hand $::pisg::adminflags]}]
+        if {[catch {cmd_$cmd $nick $acct $isadmin $arg} lines]} {
+            putlog "pisg: error in $cmd for $nick: $lines"
+            set lines [list "Something went wrong on my side, sorry. The bot owner can see it in the log."]
+        } elseif {[lsearch $edits $cmd] >= 0 && [regexp {^(Saved|Done|Deleted)} [lindex $lines 0]]} {
+            putlog "pisg: $cmd by $nick (account $acct): [lindex $lines 0]"
+        }
+        say $nick $lines
+    }
+    foreach c {help info merge unmerge show del deluser} {
+        proc pub_$c {nick uhost hand chan arg} "dispatch $c \$nick \$uhost \$hand \$arg"
+        proc msg_$c {nick uhost hand arg}      "dispatch $c \$nick \$uhost \$hand \$arg"
+        bind pub - "${trigger}pisg$c"  ::pisg::pub_$c
+        bind msg - "pisg$c"            ::pisg::msg_$c
+    }
+
+    # The original command, kept: regenerate the stats now and give the link.
+    # Open to everyone: it only points at the page the hourly cron job keeps fresh (it used to run
+    # pisg inside the bot, which froze the bot for the length of the run and stayed silent
+    # for anyone without the f flag).
+    proc pub_stats {nick host hand chan arg} {
+        variable url; variable outdir
+        set page "[string tolower [string trimleft $chan #]].html"
+        set age ""
+        if {![catch {file mtime "$outdir/$page"} t]} {
+            set m [expr {([clock seconds] - $t) / 60}]
+            set age [expr {$m < 90 ? " (updated $m min ago)" : " (updated [expr {$m / 60}] h ago)"}]
+        }
+        puthelp "PRIVMSG $chan :Stats: ${url}${page}$age"
+    }
+    bind pub - "${trigger}pisgstats" ::pisg::pub_stats
 
-if {![info exists {pisgset}]} {
-  set pisgset 1
-  timer 2 pisgcmd_timer
+    # Optional timer (off by default: the hourly cron job already runs pisg).
+    proc timer_run {} {
+        variable exe; variable autorunmin
+        catch {exec $exe}
+        timer $autorunmin ::pisg::timer_run
+    }
+    if {$autorun && ![info exists ::pisg_timer_set]} { set ::pisg_timer_set 1; timer 2 ::pisg::timer_run }
 }
 
-putlog "pisg.tcl $pisgver loaded"
+putlog "eggdrop-pisg.tcl $::pisg::version loaded (profiles: !pisghelp)"

+ 63 - 0
scripts/pisg-autoalias-test.py

@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Tests for pisg-autoalias.py:  python3 scripts/pisg-autoalias-test.py
+Builds small logs in a temporary folder and checks each rule; touches nothing else."""
+import os
+import subprocess
+import sys
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SCRIPT = os.path.join(HERE, "pisg-autoalias.py")
+passed = failed = 0
+
+
+def ok(cond, msg):
+    global passed, failed
+    if cond:
+        passed += 1
+    else:
+        failed += 1
+        print("  FAIL:", msg)
+
+
+with tempfile.TemporaryDirectory() as tmp:
+    os.mkdir(os.path.join(tmp, "logs"))
+    L = []
+    join = lambda n, h: L.append(f"[10:00:00] {n} (~u@{h}) joined #Test.")
+    part = lambda n, h: L.append(f"[11:00:00] {n} (~u@{h}) left #Test.")
+    say = lambda n: L.append(f"[10:30:00] <{n}> hello there")
+    join("Alice", "alice.users.undernet.org"); join("Alice_", "alice.users.undernet.org"); part("+Al", "alice.users.undernet.org")
+    for _ in range(5): say("Alice")
+    say("Alice_")
+    join("Gina", "irccloud.example.net"); join("Hank", "irccloud.example.net")
+    join("Bob", "bob.users.undernet.org"); join("Bobby", "bob.users.undernet.org")
+    join("Carl", "shared.users.undernet.org"); join("Carla", "shared.users.undernet.org")
+    for i in range(31): join(f"Crowd{i}", "crowd.users.undernet.org")
+    join("Solo", "solo.users.undernet.org")
+    join("%Dan", "DAN.users.undernet.org"); join("Dan2", "dan.users.undernet.org"); say("Dan2"); say("Dan2")
+    open(os.path.join(tmp, "logs", "t.log.20260101"), "w").write("\n".join(L) + "\n")
+    open(os.path.join(tmp, "logs", ".hidden.tmp"), "w").write(
+        "[10:00:00] Zed (~u@zed.users.undernet.org) joined #Test.\n[10:00:00] Zed2 (~u@zed.users.undernet.org) joined #Test.\n")
+    open(os.path.join(tmp, "manual.cfg"), "w").write('# mine\n<user nick="Bob" alias="B2" sex="m">\n<user nick="Carl">\n<user nick="Carla" sex="f">\n')
+    out = os.path.join(tmp, "out.cfg")
+    run = lambda: subprocess.run([sys.executable, SCRIPT, "--logdir", os.path.join(tmp, "logs"), "--prefix", "t.log.",
+                                  "--manual", os.path.join(tmp, "manual.cfg"), "--out", out], capture_output=True, text=True)
+    r = run()
+    ok(r.returncode == 0, "runs: " + r.stderr[-200:])
+    t = open(out).read()
+    ok('nick="Alice" alias="Al Alice_"' in t, "busiest nick names the group; the +Al prefix is stripped and merged")
+    ok("Gina" not in t and "Hank" not in t, "a shared non-X host is never merged")
+    ok('nick="Bob" alias="Bobby"' in t and "B2" not in t, "a group touching your entry joins YOUR entry")
+    ok("Carl" not in t and "Carla" not in t, "a group touching two of your entries is skipped")
+    ok("Crowd" not in t, "an oversized group is skipped")
+    ok("Solo" not in t, "a single-nick account produces nothing")
+    ok('nick="Dan2" alias="Dan"' in t, "host match ignores case; %-prefix stripped; busiest chatter named")
+    ok("Zed" not in t, "hidden and temporary files in the log folder are ignored")
+    first = [l for l in t.splitlines() if "Generated by" not in l]
+    run()
+    second = [l for l in open(out).read().splitlines() if "Generated by" not in l]
+    ok(first == second, "a second run gives the same result")
+    ok(not [f for f in os.listdir(tmp) if f.startswith(".autoalias")], "no temporary file left behind")
+
+print(f"{passed} passed, {failed} failed")
+sys.exit(1 if failed else 0)

+ 172 - 0
scripts/pisg-autoalias.py

@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+"""Merge nicks that belong to the same person, from what the logs already say.
+
+On networks with account services (Undernet: X), an authenticated user's host is fixed by the
+service, for example  chatte.users.undernet.org . Nicks that were seen with the same such host
+are the same person: nobody can borrow another account's host. This script reads the join, part
+and quit lines of your logs, groups nicks by that host, and writes a pisg config file with one
+<user nick="..." alias="..."> line per group.
+
+    pisg-autoalias.py --logdir ~/eggdrop/logs --prefix example.log. \\
+        --manual ~/pisg/pisg.cfg --manual ~/pisg/users.cfg --out ~/pisg/aliases.auto.cfg
+
+Then, in pisg.cfg (a config can include other files, but an included file cannot include another):
+
+    <include="/home/you/pisg/aliases.auto.cfg">
+
+It is safe to run before every pisg run: the output is rewritten each time.
+
+Rules
+  * Only hosts matching --hosts (default *.users.undernet.org) count. Shared IPs, gateways and
+    bouncers are never merged automatically: they prove nothing about who is behind them.
+  * Anything you define by hand wins. A group that touches one <user nick=...> of yours is added
+    to that entry (its nick becomes the name); a group that touches two of your entries is
+    skipped and reported, because you said they are different people.
+  * The name of a group is its busiest nick (most chat lines in the logs).
+  * Groups larger than --max-group are skipped and reported (a shared account, most likely).
+"""
+import argparse
+import fnmatch
+import glob
+import os
+import re
+import sys
+import tempfile
+import time
+from collections import Counter, defaultdict
+
+ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ap.add_argument("--logdir", action="append", default=[], help="folder of logs (repeatable)")
+ap.add_argument("--prefix", default="", help="only files whose name starts with this")
+ap.add_argument("--logfile", action="append", default=[], help="a single log file (repeatable)")
+ap.add_argument("--manual", action="append", default=[], help="pisg config files whose <user> lines are yours (repeatable)")
+ap.add_argument("--out", help="file to write (default: print to stdout)")
+ap.add_argument("--hosts", action="append", default=[], help="host pattern that identifies an account (default *.users.undernet.org)")
+ap.add_argument("--max-group", type=int, default=30)
+ap.add_argument("--report", action="store_true", help="print what was merged, and what was skipped and why")
+args = ap.parse_args()
+patterns = [h.lower() for h in (args.hosts or ["*.users.undernet.org"])]
+
+PREFIX = "@+%~&"
+# eggdrop format:  [12:34:56] nick (ident@host) joined #chan.   /  left #chan.   /  left irc: reason
+HOSTLINE = re.compile(r"^\[[\d:]+\] [" + re.escape(PREFIX) + r"]?([^\s()]+) \(([^)@\s]*)@([^)\s]+)\) (?:joined|left)\b")
+CHATLINE = re.compile(r"^\[[\d:]+\] <[" + re.escape(PREFIX) + r"]?([^>\s]+)> ")
+
+
+def log_files():
+    files = list(args.logfile)
+    for d in args.logdir:
+        d = os.path.expanduser(d)
+        for f in sorted(glob.glob(os.path.join(d, args.prefix + "*"))):
+            base = os.path.basename(f)
+            if os.path.isfile(f) and not base.startswith(".") and not base.endswith(".tmp"):
+                files.append(f)
+    return files
+
+
+def read_manual():
+    """nick or alias (lower case) -> the manual entry's name, for every <user ...> line you wrote."""
+    owner, entries = {}, set()
+    for path in args.manual:
+        path = os.path.expanduser(path)
+        if not os.path.exists(path):
+            continue
+        for line in open(path, encoding="utf-8", errors="replace"):
+            if line.lstrip().startswith("#") or "<user" not in line:
+                continue
+            m = re.search(r"\bnick=([\"'])(.+?)\1", line)
+            if not m:
+                continue
+            name = m.group(2)
+            entries.add(name.lower())
+            owner.setdefault(name.lower(), name)
+            a = re.search(r"\balias=([\"'])(.+?)\1", line)
+            for al in (a.group(2).split() if a else []):
+                if "*" not in al:
+                    owner.setdefault(al.lower(), name)
+    return owner, entries
+
+
+def main():
+    hosts = defaultdict(set)          # host -> nicks seen with it
+    chat = Counter()                  # nick -> chat lines
+    seen_join = Counter()             # nick -> join/part lines (tie-break)
+    files = log_files()
+    if not files:
+        sys.exit("no log files found")
+    for f in files:
+        try:
+            fh = open(f, encoding="utf-8", errors="replace")
+        except OSError:
+            continue
+        with fh:
+            for line in fh:
+                m = CHATLINE.match(line)
+                if m:
+                    chat[m.group(1)] += 1
+                    continue
+                m = HOSTLINE.match(line)
+                if m:
+                    nick, host = m.group(1), m.group(3).lower()
+                    if any(fnmatch.fnmatchcase(host, p) for p in patterns):
+                        hosts[host].add(nick)
+                        seen_join[nick] += 1
+
+    manual_owner, manual_entries = read_manual()
+    written, skipped = [], []
+    for host in sorted(hosts):
+        nicks = hosts[host]
+        if len(nicks) < 2:
+            continue
+        acct = host.split(".")[0]
+        if len(nicks) > args.max_group:
+            skipped.append((acct, sorted(nicks), f"{len(nicks)} nicks, more than --max-group ({args.max_group})"))
+            continue
+        # which of your own entries does this group touch?
+        touched = {manual_owner[n.lower()] for n in nicks if n.lower() in manual_owner}
+        if len(touched) > 1:
+            skipped.append((acct, sorted(nicks), "touches " + " and ".join(sorted(touched)) + ", which you defined as different people"))
+            continue
+        if touched:
+            name = next(iter(touched))
+        else:
+            name = max(nicks, key=lambda n: (chat[n], seen_join[n], n.lower() == n, n))
+        aliases = sorted((n for n in nicks if n.lower() != name.lower() and manual_owner.get(n.lower()) in (None, name)),
+                         key=str.lower)
+        if not aliases:
+            continue
+        written.append((acct, name, aliases, bool(touched)))
+
+    lines = [
+        "# Generated by scripts/pisg-autoalias.py on " + time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()) + ".",
+        "# Do not edit: this file is rewritten on every run. To change a result, define the nick yourself",
+        "# in pisg.cfg or users.cfg; a <user> line of yours always wins.",
+        f"# Nicks seen with the same authenticated host ({', '.join(patterns)}) are one person.",
+        "",
+    ]
+    for acct, name, aliases, joined in written:
+        lines.append(f'<user nick="{name}" alias="{" ".join(aliases)}">')
+    text = "\n".join(lines) + "\n"
+
+    if args.out:
+        out = os.path.expanduser(args.out)
+        d = os.path.dirname(os.path.abspath(out))
+        fd, tmp = tempfile.mkstemp(dir=d, prefix=".autoalias.")
+        with os.fdopen(fd, "w", encoding="utf-8") as fh:
+            fh.write(text)
+        os.chmod(tmp, 0o644)
+        os.replace(tmp, out)              # atomic: pisg never sees a half-written file
+    else:
+        sys.stdout.write(text)
+
+    if args.report or not args.out:
+        print(f"\n{len(files)} log files, {len(hosts)} authenticated hosts, "
+              f"{len(written)} groups merged ({sum(len(a) for _, _, a, _ in written)} nicks folded in), "
+              f"{len(skipped)} skipped", file=sys.stderr)
+        for acct, name, aliases, joined in sorted(written, key=lambda w: -len(w[2]))[:200]:
+            print(f"  {acct:<16} -> {name}{'  (your entry)' if joined else ''}: {', '.join(aliases)}", file=sys.stderr)
+        for acct, nicks, why in skipped:
+            print(f"  SKIPPED {acct}: {why}: {', '.join(nicks[:8])}", file=sys.stderr)
+
+
+main()

+ 125 - 0
scripts/znc-setup.sh

@@ -0,0 +1,125 @@
+#!/bin/bash
+# Put converted channel logs into a ZNC account's log folder and let pisg (a different Unix user) read it.
+#
+#   bash scripts/znc-setup.sh            show what it found and what it would do (changes nothing)
+#   bash scripts/znc-setup.sh --apply    do it
+# Run it as the ZNC account (no sudo needed) or with sudo; with sudo it uses a new group "pisg" instead.
+#
+# What --apply does, in order (nothing is deleted or overwritten):
+#   1. finds ZNC's log folder for the account (the log module's  moddata/log ),
+#   2. makes a group "pisg" holding the pisg user; the folders leading to the logs get group-execute
+#      (a path through them, no listing) and ONLY the channel folders being imported get group-read.
+#      Other networks and private-message logs stay owner-only. Files ZNC adds to those channel
+#      folders later are world-readable by ZNC's own umask, so they follow the folder's access,
+#   3. copies the staged logs  ~/znc-import/#channel/*.log  into  <network>/<channel>/  ("cp -n": an
+#      existing day file is never replaced),
+#   4. links  ~/znc-logs/<channel>  to those folders, so pisg.cfg never has to know ZNC's layout.
+#
+# Settings (environment): PISG_USER (required: the unix user that runs pisg), ZNC_HOME (default ~/.znc),
+# STAGE (default: ~/znc-import of that user), LINKS (~/znc-logs of that user), NETWORK (only needed if the account has several networks or no log yet).
+set -euo pipefail
+
+ZNC_HOME=${ZNC_HOME:-$HOME/.znc}
+PISG_USER=${PISG_USER:-}
+[ -n "$PISG_USER" ] || { echo "Set PISG_USER to the unix user that runs pisg, e.g.  PISG_USER=pisguser bash $0"; exit 1; }
+PISG_HOME=$(getent passwd "$PISG_USER" | cut -d: -f6)
+STAGE=${STAGE:-$PISG_HOME/znc-import}
+LINKS=${LINKS:-$PISG_HOME/znc-logs}
+APPLY=0; [ "${1:-}" = "--apply" ] && APPLY=1
+# As root: a dedicated group "pisg". As the ZNC account itself (no sudo needed): the existing group
+# "users", which both accounts are in (it has to be a group the ZNC account already belongs to).
+if [ "$(id -u)" = 0 ]; then ROOTMODE=1; GROUP=${GROUP:-pisg}; else ROOTMODE=0; GROUP=${GROUP:-users}; fi
+if [ "$ROOTMODE" = 0 ] && [ "$APPLY" = 1 ] && [ ! -O "$ZNC_HOME" ]; then
+  echo "Run this as the ZNC account (owner of $ZNC_HOME), or with sudo."; exit 1
+fi
+
+say() { printf '%s\n' "$*"; }
+run() { if [ "$APPLY" = 1 ]; then "$@"; else say "   would run: $*"; fi; }
+
+# 1. where does the log module write?
+mapfile -t ROOTS < <(find "$ZNC_HOME" -type d -path '*/moddata/log' 2>/dev/null | sort)
+if [ -n "${LOGROOT:-}" ]; then ROOTS=("$LOGROOT"); fi
+say "ZNC folder: $ZNC_HOME"
+if [ "${#ROOTS[@]}" -ne 1 ]; then
+  say "Found ${#ROOTS[@]} log folders (need exactly one):"; printf '  %s\n' "${ROOTS[@]:-}"
+  say "Load the log module first (/msg *status LoadMod log), or set LOGROOT=/path/to/moddata/log"
+  exit 2
+fi
+LOGROOT=${ROOTS[0]}
+say "log module folder: $LOGROOT"
+
+case "$LOGROOT" in
+  */networks/*/moddata/log) BASE=$LOGROOT ;;                               # network scope:  $WINDOW/...
+  */users/*/moddata/log)                                                   # user scope:     $NETWORK/$WINDOW/...
+    if [ -z "${NETWORK:-}" ]; then
+      mapfile -t NETS < <(find "$LOGROOT" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
+      if [ "${#NETS[@]}" -eq 1 ]; then NETWORK=${NETS[0]}
+      else say "Networks found in it: ${NETS[*]:-none}"; say "Pass NETWORK=<name> (say something in a channel first if there is none yet)"; exit 2; fi
+    fi
+    BASE=$LOGROOT/$NETWORK ;;
+  *) say "Global-scope log module (folder holds \$USER/\$NETWORK/...): pass LOGROOT= pointing at the network folder"; exit 2 ;;
+esac
+say "network folder: $BASE"
+say "staged logs: $STAGE"
+ls -d "$STAGE"/'#'* >/dev/null 2>&1 || { say "nothing staged in $STAGE"; exit 2; }
+
+# 2. group and permissions
+say; say "== access for $PISG_USER"
+if [ "$ROOTMODE" = 1 ]; then
+  getent group "$GROUP" >/dev/null || run groupadd "$GROUP"
+  id -nG "$PISG_USER" | tr ' ' '\n' | grep -qx "$GROUP" || run usermod -aG "$GROUP" "$PISG_USER"
+else
+  id -nG "$PISG_USER" | tr ' ' '\n' | grep -qx "$GROUP" || { say "$PISG_USER is not in group $GROUP; run with sudo instead"; exit 1; }
+fi
+say " group used for read access: $GROUP"
+d=$LOGROOT
+CHAIN=()
+while [ "$d" != "/" ] && [ "$d" != "$(dirname "$ZNC_HOME")" ]; do CHAIN+=("$d"); d=$(dirname "$d"); done
+CHAIN+=("$(dirname "$ZNC_HOME")")
+for d in "${CHAIN[@]}" "$BASE"; do
+  [ -d "$d" ] || continue
+  run chgrp "$GROUP" "$d"; run chmod g+x "$d"                              # a way through, no listing
+done
+# Only the channel folders named below become readable. Other networks and private-message logs
+# under the same log folder keep their owner-only permissions.
+
+# 3. copy the staged logs
+say; say "== logs"
+ZNC_OWNER=$(stat -c %U "$LOGROOT" 2>/dev/null || id -un)
+[ "$APPLY" = 1 ] && mkdir -p "$BASE" && chown "$ZNC_OWNER:$GROUP" "$BASE" && chmod 2710 "$BASE"
+for s in "$STAGE"/'#'*; do
+  name=$(basename "$s")
+  existing=$(find "$BASE" -mindepth 1 -maxdepth 1 -type d -iname "$name" 2>/dev/null | head -1 || true)
+  dest=${existing:-$BASE/$name}
+  n=$(find "$s" -name '*.log' | wc -l)
+  say " $name: $n day files -> $dest $([ -n "$existing" ] && echo '(folder already there)' || echo '(new folder)')"
+  if [ "$APPLY" = 1 ]; then
+    mkdir -p "$dest"; chown "$ZNC_OWNER:$GROUP" "$dest"; chmod 2750 "$dest"
+    cp --update=none --no-preserve=mode,ownership "$s"/*.log "$dest"/
+    chown "$ZNC_OWNER:$GROUP" "$dest"/*.log
+    find "$dest" -type f -name '*.log' -exec chmod 640 {} +
+  fi
+  # 4. link for pisg (lower case, no #)
+  link=$LINKS/$(echo "${name#\#}" | tr 'A-Z' 'a-z')
+  if [ "$APPLY" = 1 ] && [ "$ROOTMODE" = 1 ]; then
+    mkdir -p "$LINKS"; chown "$PISG_USER" "$LINKS"
+    ln -sfn "$dest" "$link"          # (no chown -h: on this system it follows the link and changes the target)
+  elif [ "$APPLY" = 1 ]; then
+    say "   (links are made by $PISG_USER afterwards: $link -> $dest)"
+  else
+    say "   would link $link -> $dest"
+  fi
+done
+
+if [ "$APPLY" = 1 ] && [ "$ROOTMODE" = 1 ]; then
+  say; say "== check: what $PISG_USER can now see"
+  for l in "$LINKS"/*; do
+    printf ' %s: ' "$l"
+    setpriv --reuid="$PISG_USER" --regid="$PISG_USER" --groups="$GROUP" ls "$l/" | wc -l | tr '\n' ' '; say "files readable"
+  done
+  say; say "Done. New group membership reaches $PISG_USER's cron jobs on their next run."
+elif [ "$APPLY" = 1 ]; then
+  say; say "Done. Tell $PISG_USER it finished: the links and the pisg run come next."
+else
+  say; say "Nothing changed. Run again with --apply to do it."
+fi

+ 315 - 0
setup.pl

@@ -0,0 +1,315 @@
+#!/usr/bin/perl
+# setup.pl - guided setup for pisg. Run it with:   perl setup.pl
+#
+# It looks for IRC logs that already exist on this computer (eggdrop, ZNC, irssi, WeeChat, HexChat,
+# mIRC / AdiIRC), asks a few questions, writes pisg.cfg, makes the first stats page, and helps you
+# run it on a schedule and put it on the web for free. Nothing is changed without asking, and an
+# existing pisg.cfg is never overwritten (it is backed up first, if you choose to replace it).
+#
+#   perl setup.pl --dry-run     show what would be written, change nothing
+#   perl setup.pl --help
+use strict;
+use warnings;
+use File::Basename qw(dirname basename);
+use File::Spec;
+use File::Path qw(make_path);
+use Cwd qw(abs_path);
+use POSIX qw(strftime);
+
+my $WIN      = $^O eq 'MSWin32';
+my $HOME     = $ENV{HOME} || $ENV{USERPROFILE} || '.';
+my $APPDATA  = $ENV{APPDATA} || '';
+my $HERE     = dirname(abs_path($0));
+my $DRY      = grep { $_ eq '--dry-run' } @ARGV;
+if (grep { $_ eq '--help' || $_ eq '-h' } @ARGV) { print "Usage: perl setup.pl [--dry-run]\n"; exit 0; }
+
+$| = 1;
+sub say_ { print @_, "\n" }
+sub rule { say_ "\n" . ('-' x 72) }
+sub ask {
+    my ($q, $def) = @_;
+    print $q . (defined $def && length $def ? " [$def]" : '') . ' ';
+    my $a = <STDIN>;
+    exit 0 unless defined $a;                       # end of input: stop quietly
+    $a =~ s/^\s+|\s+$//g;
+    return length $a ? $a : (defined $def ? $def : '');
+}
+sub yes {
+    my ($q, $def) = @_;
+    my $a = lc ask("$q (y/n)", $def ? 'y' : 'n');
+    return $a =~ /^y/;
+}
+sub slash { my $p = shift; $p =~ s{\\}{/}g; return $p }          # pisg.cfg accepts / everywhere, Windows too
+sub clean { my $v = shift; $v =~ s/["\r\n]//g; return $v }        # a value goes between quotes in pisg.cfg
+sub count_files { my ($dir, $prefix) = @_; my @f = grep { -f } glob(quotemeta_glob("$dir/$prefix") . '*'); return scalar @f }
+sub quotemeta_glob { my $s = shift; $s =~ s/([\[\]{}*?\\ ])/\\$1/g; return $s }
+
+# ---- what formats does this pisg know? --------------------------------------------------------
+my @FORMATS = sort map { basename($_, '.pm') } glob("$HERE/modules/Pisg/Parser/Format/*.pm");
+@FORMATS = grep { $_ ne 'Template' } @FORMATS;
+
+# ---- looking for logs ---------------------------------------------------------------------------
+# Each candidate: { app, channel, network, format, dir | file, prefix, note }
+my @found;
+sub add { push @found, { @_ } }
+
+sub detect_eggdrop {
+    my %seen;
+    my @confs = grep { -f } (glob("$HOME/eggdrop*/eggdrop.conf"), glob("$HOME/*/eggdrop.conf"), glob("$HOME/*/*/eggdrop.conf"));
+    for my $conf (grep { !$seen{$_}++ } @confs) {
+        my $base = dirname($conf);
+        open(my $fh, '<', $conf) or next;
+        my @lines = <$fh>; close $fh;
+        my $keep = (grep { /^\s*set\s+keep-all-logs\s+1/ } @lines) ? 1 : 0;
+        for (@lines) {
+            next unless /^\s*logfile\s+\S+\s+(\S+)\s+"?([^"\s]+)"?/;
+            my ($chan, $path) = ($1, $2);
+            next unless $chan =~ /^[#&]/;
+            $path = File::Spec->catfile($base, $path) unless File::Spec->file_name_is_absolute($path);
+            add(app => 'eggdrop', channel => $chan, network => '', format => 'eggdrop',
+                dir => dirname($path), prefix => basename($path) . '.',
+                note => $keep ? '' : "eggdrop keeps only the current log. For a long history add these to eggdrop.conf and .rehash:\n"
+                                   . "      set keep-all-logs 1\n      set logfile-suffix \".%Y%m%d\"\n      set switch-logfiles-at 300");
+        }
+    }
+}
+
+sub detect_znc {
+    my %roots = map { $_ => 1 } grep { -d } ($ENV{ZNC_DATADIR} || '', "$HOME/.znc", ($APPDATA ? "$APPDATA/znc" : ()));
+    for my $root (keys %roots) {
+        # user scope:  users/<user>/moddata/log/<network>/<#channel>/YYYY-MM-DD.log
+        for my $d (glob("$root/users/*/moddata/log/*/*")) {
+            next unless -d $d && basename($d) =~ /^[#&]/;
+            add(app => 'ZNC', channel => basename($d), network => basename(dirname($d)), format => 'energymech', dir => $d, prefix => '', note => '');
+        }
+        # network scope:  users/<user>/networks/<network>/moddata/log/<#channel>/...
+        for my $d (glob("$root/users/*/networks/*/moddata/log/*")) {
+            next unless -d $d && basename($d) =~ /^[#&]/;
+            (my $net = $d) =~ s{.*/networks/([^/]+)/moddata/log/.*}{$1};
+            add(app => 'ZNC', channel => basename($d), network => $net, format => 'energymech', dir => $d, prefix => '', note => '');
+        }
+        # global scope:  moddata/log/<user>/<network>/<#channel>/...
+        for my $d (glob("$root/moddata/log/*/*/*")) {
+            next unless -d $d && basename($d) =~ /^[#&]/;
+            add(app => 'ZNC', channel => basename($d), network => basename(dirname($d)), format => 'energymech', dir => $d, prefix => '', note => '');
+        }
+    }
+}
+
+# One log file per channel (irssi, WeeChat, HexChat, mIRC ...): the name holds the channel.
+sub file_candidates {
+    my ($app, $format, $dir, $rx, $network) = @_;
+    return unless -d $dir;
+    opendir(my $dh, $dir) or return;
+    for my $f (sort grep { !/^\./ && -f "$dir/$_" } readdir $dh) {
+        my $chan = $f =~ $rx ? $1 : next;
+        add(app => $app, channel => $chan, network => ($network // ''), format => $format, file => "$dir/$f", note => '');
+    }
+    closedir $dh;
+}
+
+sub detect_clients {
+    # irssi: ~/irclogs/<network>/<#channel>.log (autolog default)
+    for my $n (grep { -d } glob("$HOME/irclogs/*")) {
+        file_candidates('irssi', 'irssi', $n, qr/^([#&][^.]*)\.log$/, basename($n));
+    }
+    # WeeChat: irc.<server>.<#channel>.weechatlog
+    for my $d ("$HOME/.local/share/weechat/logs", "$HOME/.weechat/logs") {
+        file_candidates('WeeChat', 'weechat3', $d, qr/^irc\.[^.]+\.([#&].+)\.weechatlog$/);
+    }
+    # HexChat / XChat: logs/<NETWORK>/<#channel>.log
+    for my $r ("$HOME/.config/hexchat/logs", "$HOME/.xchat2/xchatlogs", ($APPDATA ? "$APPDATA/HexChat/logs" : ())) {
+        for my $n (grep { -d } glob("$r/*")) { file_candidates('HexChat', 'xchat', $n, qr/^([#&].+)\.log$/, basename($n)) }
+    }
+    # mIRC / AdiIRC on Windows: <#channel>.<network>.log or <#channel>.log
+    for my $d (($APPDATA ? ("$APPDATA/mIRC/logs", "$APPDATA/AdiIRC/Logs") : ()), "$HOME/.wine/drive_c/mIRC/logs") {
+        file_candidates('mIRC/AdiIRC', 'mIRC', $d, qr/^([#&][^.]+)(?:\..+)?\.log$/);
+        for my $n (grep { -d } glob("$d/*")) { file_candidates('mIRC/AdiIRC', 'mIRC', $n, qr/^([#&][^.]+)(?:\..+)?\.log$/, basename($n)) }
+    }
+}
+
+# ---- go -----------------------------------------------------------------------------------------
+rule();
+say_ "pisg setup" . ($DRY ? "  (dry run: nothing will be written)" : '');
+say_ "";
+say_ "pisg turns IRC chat logs into a web page of statistics: who talks most, when the channel is";
+say_ "busy, who talks to whom. I will look for logs on this computer, ask a few questions and write";
+say_ "the configuration for you. You can stop at any time with Ctrl+C; nothing is changed until the";
+say_ "end.";
+
+detect_eggdrop(); detect_znc(); detect_clients();
+
+my @chosen;
+rule();
+say_ "Step 1 of 4: which channels do you want statistics for?";
+say_ "";
+if (@found) {
+    say_ "I found these logs:";
+    my $i = 0;
+    for my $c (@found) {
+        $i++;
+        my $where = $c->{dir} ? $c->{dir} : $c->{file};
+        my $n = $c->{dir} ? count_files($c->{dir}, $c->{prefix} // '') . " files" : (-s $c->{file} ? int((-s $c->{file}) / 1024) . " KB" : 'empty');
+        printf "  %2d) %-14s %-16s %-10s %s (%s)\n", $i, $c->{app}, $c->{channel}, $c->{network}, $where, $n;
+    }
+    say_ "";
+    my $pick = ask("Type the numbers you want, separated by commas (for example 1,3), or 0 to type a folder yourself:", '1');
+    for my $n (grep { /^\d+$/ && $_ >= 1 && $_ <= @found } split /\s*,\s*/, $pick) { push @chosen, { %{ $found[$n - 1] } } }
+} else {
+    say_ "I did not find any IRC logs in the usual places.";
+    say_ "";
+    say_ "  * Using a bouncer or a bot? Turn logging on first: eggdrop needs a 'logfile' line, ZNC needs the";
+    say_ "    'log' module (/msg *status LoadMod log), irssi needs /set autolog on. Then run this again.";
+    say_ "  * Already have logs somewhere else? Type the folder below.";
+}
+if (!@chosen) {
+    say_ "";
+    my $where = ask("Folder or file with your logs (leave empty to stop):", '');
+    if (!length $where) { say_ "Nothing to do. Run this again once you have logs."; exit 0; }
+    $where =~ s/^~(?=\/|$)/$HOME/;
+    if (!-e $where) { say_ "That does not exist: $where"; exit 1; }
+    say_ "";
+    say_ "Which program wrote them? Formats pisg understands:";
+    say_ "  " . join(', ', @FORMATS);
+    my $fmt = ask("Format:", 'eggdrop');
+    my $chan = ask("Channel name (for example #mychannel):", '#mychannel');
+    push @chosen, { app => 'your logs', channel => $chan, network => '', format => $fmt,
+                    (-d $where ? (dir => $where, prefix => ask("Only files whose name starts with (empty: all of them):", '')) : (file => $where)), note => '' };
+}
+
+rule();
+say_ "Step 2 of 4: a few details";
+say_ "";
+my $maintainer = clean(ask("Your name or nick, shown as the maintainer of the page:", $ENV{USER} || $ENV{USERNAME} || 'me'));
+my $network    = clean(ask("Name of the IRC network (for example Undernet, Libera):", $chosen[0]{network} || 'IRC'));
+say_ "";
+say_ "Look of the pages:  modern (light and dark, follows your system)  midnight  amoled  terminal  default";
+my $scheme = clean(ask("Colour scheme:", 'modern'));
+my $outdir = ask("Folder for the finished pages:", File::Spec->catdir($HERE, 'output'));
+$outdir =~ s/^~(?=\/|$)/$HOME/;
+my $landing = @chosen > 1 || yes("Add a front page (index.html) that links to your channel pages?", 1);
+
+my $cfg = File::Spec->catfile($HERE, 'pisg.cfg');
+my @out;
+push @out, "# Written by setup.pl on " . strftime('%Y-%m-%d %H:%M', localtime) . ". Every option is explained in pisg.cfg.example.";
+push @out, qq(<set maintainer="$maintainer">), qq(<set ColorScheme="$scheme">), qq(<set Charset="utf-8">);
+push @out, qq(<set HomeLink="index.html">) if $landing;
+push @out, '';
+my %usedfile;
+for my $c (@chosen) {
+    my $slug = lc(clean($c->{channel})); $slug =~ s/^[#&]+//; $slug =~ s/[^a-z0-9._-]+/-/g; $slug ||= 'channel';
+    $slug .= '-' . (++$usedfile{$slug}) if $usedfile{$slug}++;
+    push @out, '<channel="' . clean($c->{channel}) . '">';
+    if ($c->{dir}) {
+        push @out, '  LogDir="' . clean(slash($c->{dir})) . '"';
+        push @out, '  LogPrefix="' . clean($c->{prefix}) . '"' if length($c->{prefix} // '');
+    } else {
+        push @out, '  Logfile="' . clean(slash($c->{file})) . '"';
+    }
+    push @out, '  Format="' . clean($c->{format}) . '"';
+    push @out, '  Network="' . ($c->{network} && $network eq 'IRC' ? clean($c->{network}) : $network) . '"';
+    push @out, '  OutputFile="' . clean(slash(File::Spec->catfile($outdir, "$slug.html"))) . '"';
+    push @out, '</channel>', '';
+}
+my $text = join("\n", @out) . "\n";
+
+rule();
+say_ "Step 3 of 4: this is the configuration I will write";
+say_ "";
+say_ "  $cfg";
+say_ "";
+say_ join("\n", map { "    $_" } @out);
+for my $c (@chosen) { say_ "  Note for $c->{channel}: $c->{note}\n" if $c->{note} }
+if ($DRY) { say_ "Dry run: nothing written."; exit 0; }
+exit 0 unless yes("Write it?", 1);
+
+if (-e $cfg) {
+    say_ "";
+    say_ "There is already a pisg.cfg here.";
+    if (yes("Back it up (pisg.cfg.bak-DATE) and replace it? If not, the new one is saved as pisg.cfg.new", 0)) {
+        my $bak = "$cfg.bak-" . strftime('%Y%m%d-%H%M%S', localtime);
+        rename($cfg, $bak) or do { say_ "Could not back it up: $!"; exit 1 };
+        say_ "  old file kept as $bak";
+    } else {
+        $cfg .= '.new';
+    }
+}
+make_path($outdir) unless -d $outdir;
+open(my $out, '>', $cfg) or do { say_ "Could not write $cfg: $!"; exit 1 };
+print $out $text; close $out;
+say_ "Wrote $cfg";
+say_ "  To use it instead of your current configuration:  mv pisg.cfg.new pisg.cfg" if $cfg =~ /\.new$/;
+if ($landing) {
+    my ($src, $dst) = ("$HERE/site/index.html", File::Spec->catfile($outdir, 'index.html'));
+    if (-f $src && !-e $dst) { require File::Copy; File::Copy::copy($src, $dst) and say_ "Copied the front page to $dst" }
+}
+
+# first run
+say_ "";
+if (yes("Make the first statistics now?", 1)) {
+    say_ "Running pisg (this can take a little while with a lot of logs) ...";
+    my $perl = $^X;
+    my $rc = system($perl, File::Spec->catfile($HERE, 'pisg'), ($cfg =~ /\.new$/ ? ('-co', $cfg) : ()));
+    if ($rc == 0) {
+        say_ "";
+        say_ "Done. Your page is in: $outdir";
+        say_ "  " . slash(File::Spec->catfile($outdir, 'index.html')) if $landing;
+    } else {
+        say_ "";
+        say_ "pisg reported a problem (above). Common causes: the wrong format for these logs, or logs with";
+        say_ "no timestamps. Run  perl setup.pl  again and pick another format, or ask on GitHub: https://github.com/PISG/pisg";
+    }
+}
+
+# scheduling
+rule();
+say_ "Step 4 of 4: keep the statistics up to date";
+say_ "";
+say_ "pisg makes a fresh page each time it runs. Run it on a schedule so the page stays current.";
+my $hours = ask("How often, in hours (1, 3, 6 or 12)?", '3');
+$hours = 3 unless $hours =~ /^(1|2|3|4|6|8|12|24)$/;
+if ($WIN) {
+    my $cmd = qq(schtasks /Create /SC HOURLY /MO $hours /TN "pisg" /TR "cmd /c cd /d \\"$HERE\\" && \\"$^X\\" pisg" /F);
+    say_ "";
+    say_ "  $cmd";
+    if (yes("Create this Windows scheduled task now?", 0)) { system($cmd) }
+    else { say_ "  (run the line above in a command prompt when you want it.)" }
+    say_ "  The computer must be on for it to run.";
+} else {
+    my $line = "5 */$hours * * * cd '$HERE' && '$^X' pisg >> pisg_cron.log 2>&1";
+    say_ "";
+    say_ "  $line";
+    if (yes("Add this line to your crontab now?", 0)) {
+        my $old = `crontab -l 2>/dev/null` // '';
+        if ($old =~ /pisg_cron\.log/) { say_ "  There is already a pisg line in your crontab; not adding another." }
+        elsif (open(my $c, '|-', 'crontab', '-')) {
+            print $c $old, (length $old && $old !~ /\n\z/ ? "\n" : ''), "$line\n";
+            close $c;
+            say_ "  Added.";
+        } else { say_ "  Could not run crontab: $!" }
+    } else { say_ "  (add it yourself with:  crontab -e )" }
+}
+
+rule();
+say_ "Put the pages on the web (free)";
+say_ "";
+say_ "Your pages are plain files, so any static host works. Good free choices:";
+say_ "";
+say_ "  * GitHub Pages     free, simple, and it can publish straight from a git repository.";
+say_ "                     1) create a free account at github.com and a repository named  YOURNAME.github.io";
+say_ "                     2) put the contents of your output folder in it and push";
+say_ "                     3) your stats are then at  https://YOURNAME.github.io/";
+say_ "  * Cloudflare Pages free, fast everywhere; upload the folder by drag and drop or connect a repository.";
+say_ "  * Netlify          free plan; drag the output folder onto app.netlify.com/drop.";
+say_ "  * GitLab Pages     free, like GitHub Pages (uses a .gitlab-ci.yml file).";
+say_ "  * Your own server  point Apache or nginx at the output folder.";
+say_ "";
+say_ "Two things to know:";
+say_ "  * The front page reads channels.json, so it needs a web server. Opening index.html straight from";
+say_ "    your disk will not list the channels. To try it on your own computer, run this inside the output";
+say_ "    folder, then open http://localhost:8000/ :   python3 -m http.server 8000";
+say_ "  * The pages show nicknames and a random line from the chat. Tell your channel that stats are";
+say_ "    published, and use BadUrls, <user ... ignore=\"y\"> or a private host if that matters to you.";
+say_ "";
+say_ "More: pisg.cfg.example lists every option, docs/pisg-doc.html is the whole manual.";
+say_ "Change your answers any time by editing pisg.cfg, or run  perl setup.pl  again.";

+ 21 - 0
site/README.txt

@@ -0,0 +1,21 @@
+pisg landing page
+=================
+
+index.html is a single static file: the statistics of your statistics. A headline for all channels
+together, a few facts (lines a day, busiest hour, links, questions), a row per channel to compare
+them (volume, busiest hour, a 24-hour profile, the last 30 days) and the top talkers of each. Every
+channel name links to that channel's own page. No PHP, no cron job, no cache to refresh.
+
+How it works
+  pisg writes channels.json next to each channel page every time it runs (option
+  ChannelIndex, on by default): totals, an hour-by-hour profile, the last 30 days and the top
+  talkers of that channel. index.html only reads that file, so it always matches the pages.
+
+To use it
+  1. Copy index.html into the folder your channel pages are written to (the folder
+     that holds channels.json).
+  2. Open that folder in a browser. Web servers pick index.html before index.php.
+  3. Optional: edit SITE_TITLE and SITE_LEAD near the top of index.html.
+
+Works on any static host, including GitHub Pages. Old links such as  https://example.org/stats/#canada
+still work: they jump straight to that channel's page.

+ 408 - 0
site/index.html

@@ -0,0 +1,408 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>IRC channel stats</title>
+<!--
+  pisg landing page: one static file, no PHP, no cron, no libraries.
+
+  pisg writes channels.json next to each channel page (option ChannelIndex): for every channel its
+  totals, a 24-hour profile, the last 30 days and the top talkers. This page turns that into the
+  statistics of the statistics: what all channels add up to, how they compare, and a link to each
+  channel's own page. Copy this file into the folder your channel pages are written to, next to
+  channels.json. It works on any static host, including GitHub Pages.
+
+  Change the two lines marked EDIT below to rename the page.
+-->
+<style>
+:root {
+  --bg: #f6f7f9; --surface: #ffffff; --fg: #171b21; --muted: #59626d; --line: #e0e3e8; --line2: #c6ccd4; --hover: #eceff3;
+  --mark: #2a78d6; --t0: #4a3aa7; --t1: #eda100; --t2: #1baf7a; --t3: #eb6834;
+  color-scheme: light dark;
+}
+@media (prefers-color-scheme: dark) {
+  :root { --bg: #0f1318; --surface: #171c23; --fg: #e8ebef; --muted: #98a2ae; --line: #252c35; --line2: #38414c; --hover: #1b2129;
+          --mark: #3987e5; --t0: #9085e9; --t1: #c98500; --t2: #199e70; --t3: #d95926; }
+}
+* { box-sizing: border-box; }
+html { -webkit-text-size-adjust: 100%; }
+body {
+  margin: 0; padding: 32px 24px 64px; background: var(--bg); color: var(--fg);
+  font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+}
+main { max-width: 1240px; margin: 0 auto; }
+:root { --mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
+a { color: inherit; }
+a:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
+[hidden] { display: none !important; }
+.num { font-variant-numeric: tabular-nums; }
+
+h1 { margin: 4px 0 6px; font: 700 clamp(24px, 4.6vw, 40px)/1.15 var(--mono); letter-spacing: -0.035em; overflow-wrap: anywhere; }
+.lead { margin: 0 0 8px; color: var(--muted); }
+
+/* headline sentence and facts: same shape as the overview on the channel pages */
+.hero { margin: 28px 0 18px; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.3; font-weight: 600; text-wrap: balance; }
+.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 48px; margin: 0; }
+.fact { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--line); }
+.fact dt { color: var(--muted); }
+.fact dd { margin: 0; text-align: right; font-family: var(--mono); font-weight: 600; overflow-wrap: anywhere; }
+.fact small { display: block; font: 400 12px/1.3 system-ui, sans-serif; color: var(--muted); }
+
+section { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--line2); }
+h2 { margin: 0 0 4px; font-size: 20px; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; }
+.intro { margin: 2px 0 16px; max-width: 68ch; color: var(--muted); }
+
+/* one row per channel */
+.cols, .row { display: grid; gap: 8px 24px; align-items: center;
+  grid-template-columns: minmax(150px, 1.15fr) minmax(190px, 1.5fr) minmax(150px, 1.1fr) minmax(200px, 1.5fr) minmax(120px, .8fr); }
+.cols { padding: 8px 0; border-bottom: 1px solid var(--line2); color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+.row { padding: 14px 0; border-bottom: 1px solid var(--line); }
+.row:hover { background: var(--hover); }
+.name > a:first-child { font: 700 17px/1.2 var(--mono); letter-spacing: -0.02em; text-decoration-color: var(--line2); text-underline-offset: 4px; overflow-wrap: anywhere; }
+.name > a:first-child:hover { text-decoration-color: currentColor; }
+.btn, .btn:link, .btn:visited { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; padding: 5px 12px; border: 1px solid var(--line2); border-radius: 6px;
+  background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 600; line-height: 1.3; text-decoration: none; }
+.btn:hover { background: var(--bg); border-color: var(--fg); }
+.btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
+.sub { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; }
+.big { font: 600 15px/1.2 var(--mono); }
+.track { height: 8px; margin: 6px 0 4px; border-radius: 4px; background: var(--line); overflow: hidden; }
+.fill { height: 100%; border-radius: 4px; background: var(--mark); }
+.what { font-family: var(--mono); font-weight: 600; }
+
+/* 24-hour profile: one bar per hour, coloured by time of day like the channel pages */
+.hours { display: flex; align-items: flex-end; gap: 2px; height: 38px; }
+.hours i { flex: 1 1 0; min-width: 0; border-radius: 2px 2px 0 0; }
+.hours .h0 { background: var(--t0); } .hours .h1 { background: var(--t1); }
+.hours .h2 { background: var(--t2); } .hours .h3 { background: var(--t3); }
+.hticks { display: flex; justify-content: space-between; margin-top: 3px; color: var(--muted); font: 11px/1 var(--mono); }
+.spark { display: block; width: 100%; max-width: 170px; height: 38px; }
+.spark rect { fill: var(--mark); }
+.legend { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 14px 0 0; color: var(--muted); font-size: 12px; }
+.legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; margin-right: 6px; border-radius: 2px; vertical-align: -1px; background: var(--c); }
+
+/* who talks most, per channel */
+.talkers { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 28px 40px; margin-top: 18px; }
+.talkers h3 { margin: 0 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--line2); font: 700 15px/1.2 var(--mono); }
+.talkers ol { margin: 0; padding: 0; list-style: none; }
+.talkers li { display: grid; grid-template-columns: minmax(0, 1fr) 64px auto; gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--line); }
+.talkers li b { font: 600 14px/1.2 var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.talkers li .track { margin: 0; }
+.talkers li span { color: var(--muted); font-size: 13px; }
+
+.msg { margin-top: 24px; padding: 14px 0; border-top: 1px solid var(--line2); color: var(--muted); }
+.msg strong { color: var(--fg); }
+code { padding: .1em .35em; border-radius: 4px; background: var(--surface); border: 1px solid var(--line); font-size: .9em; }
+footer { margin-top: 56px; padding-top: 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
+
+/* narrower: each channel becomes a small block, the column titles go away */
+@media (max-width: 980px) {
+  .cols { display: none; }
+  .row { grid-template-columns: 1fr 1fr; padding: 18px 0; }
+  .row > .name { grid-column: 1 / -1; }
+  .row > .profile { grid-column: 1 / -1; }
+}
+@media (max-width: 520px) {
+  body { padding: 24px 16px 48px; }
+  .row { grid-template-columns: 1fr; }
+  .fact { gap: 10px; }
+}
+</style>
+</head>
+<body>
+<main>
+  <h1 id="site-title">IRC channel stats</h1>
+  <p class="lead" id="site-lead">What the channels add up to, and where to read more about each one.</p>
+
+  <div id="overview" hidden>
+    <p class="hero" id="hero"></p>
+    <dl class="facts" id="facts"></dl>
+  </div>
+
+  <section id="channels" hidden>
+    <h2>Channels</h2>
+    <p class="intro">Every channel that has a statistics page, busiest first. Open one for the full page.</p>
+    <div class="cols" aria-hidden="true">
+      <div>Channel</div><div>Lines</div><div>Busiest hour</div><div>Through the day</div><div>Last 30 days</div>
+    </div>
+    <div id="rows"></div>
+    <p class="legend" aria-hidden="true">
+      <span style="--c: var(--t0)">night 0&ndash;5</span><span style="--c: var(--t1)">morning 6&ndash;11</span>
+      <span style="--c: var(--t2)">afternoon 12&ndash;17</span><span style="--c: var(--t3)">evening 18&ndash;23</span>
+    </p>
+  </section>
+
+  <section id="people" hidden>
+    <h2>Who talks most</h2>
+    <p class="intro">The five busiest people in each channel (bots left out where pisg knows them), with their share of the line count shown against the busiest of the five.</p>
+    <div class="talkers" id="talkers"></div>
+  </section>
+
+  <div class="msg" id="status" role="status">Loading channels&hellip;</div>
+  <noscript><div class="msg">This page needs JavaScript to list the channels.</div></noscript>
+
+  <footer>Statistics generated by <a href="https://github.com/PISG/pisg">pisg</a></footer>
+</main>
+
+<script>
+"use strict";
+
+// EDIT: the heading and the sentence under it
+var SITE_TITLE = "IRC channel stats";
+var SITE_LEAD = "What the channels add up to, and where to read more about each one.";
+// Where pisg writes the channel list (pisg option ChannelIndex), next to this page.
+var INDEX_URL = "channels.json";
+
+var $ = function (id) { return document.getElementById(id); };
+function el(tag, cls, text) {
+  var e = document.createElement(tag);
+  if (cls) e.className = cls;
+  if (text !== undefined && text !== null) e.textContent = text;      // textContent: names are never parsed as HTML
+  return e;
+}
+
+document.title = SITE_TITLE;
+$("site-title").textContent = SITE_TITLE;
+$("site-lead").textContent = SITE_LEAD;
+
+function say(html) { $("status").hidden = false; $("status").innerHTML = html; }
+function clearSay() { $("status").hidden = true; }
+
+// The channel list is data pisg wrote, but treat it as untrusted anyway: only accept a
+// plain relative .html path, so a bad entry can never become a javascript: or offsite URL.
+function safeFile(f) {
+  return typeof f === "string" && /^[\w.\-]+(\/[\w.\-]+)*\.html?$/i.test(f) && f.indexOf("..") === -1;
+}
+function norm(s) {
+  s = String(s).replace(/^#+/, "").toLowerCase();
+  return s.normalize ? s.normalize("NFD").replace(/[̀-ͯ]/g, "") : s;
+}
+function whole(v) { return typeof v === "number" && isFinite(v) ? v : 0; }
+function list(v, n) { return Array.isArray(v) && (n === undefined || v.length === n) ? v.map(whole) : null; }
+function fmt(n) { return Math.round(n).toLocaleString(); }
+function fmt1(n) { return n >= 100 ? fmt(n) : (Math.round(n * 10) / 10).toLocaleString(); }
+function pct(n, d) { return d ? (Math.round(1000 * n / d) / 10).toLocaleString() + "%" : "0%"; }
+function hourRange(h) { function p(x) { return (x < 10 ? "0" : "") + x; } return p(h) + ":00–" + p((h + 1) % 24) + ":00"; }
+
+function ago(iso) {
+  var t = Date.parse(iso);
+  if (isNaN(t)) return "";
+  var s = Math.round((t - Date.now()) / 1000), a = Math.abs(s);
+  var units = [["day", 86400], ["hour", 3600], ["minute", 60]];
+  var rtf = typeof Intl !== "undefined" && Intl.RelativeTimeFormat ? new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) : null;
+  for (var i = 0; i < units.length; i++) {
+    if (a >= units[i][1]) {
+      var n = Math.round(s / units[i][1]);
+      return rtf ? rtf.format(n, units[i][0]) : Math.abs(n) + " " + units[i][0] + "(s) ago";
+    }
+  }
+  return "just now";
+}
+
+// Everything worked out once, from the numbers pisg wrote. Older channels.json files have fewer fields;
+// whatever is missing simply leaves that piece out.
+function prepare(c) {
+  var hours = list(c.hours, 24), total = 0, peak = 0, i;
+  if (hours) for (i = 0; i < 24; i++) { total += hours[i]; if (hours[i] > hours[peak]) peak = i; }
+  var lines = whole(c.lines), days = whole(c.days);
+  return {
+    c: c, name: String(c.name), file: c.file, lines: lines, nicks: whole(c.nicks), days: days,
+    lpd: days ? lines / days : 0, words: whole(c.words), links: whole(c.links), questions: whole(c.questions),
+    hours: hours, hoursTotal: total, peak: peak, recent: list(c.recent),
+    top: Array.isArray(c.top) ? c.top.filter(function (t) { return t && typeof t.nick === "string"; }) : []
+  };
+}
+
+function sum(rows, k) { return rows.reduce(function (a, r) { return a + r[k]; }, 0); }
+
+function fact(dt, dd, sub) {
+  var f = el("div", "fact"), t = el("dt", null, dt), d = el("dd", "num", dd);
+  if (sub) d.appendChild(el("small", null, sub));
+  f.appendChild(t); f.appendChild(d);
+  return f;
+}
+function heroBit(text) { var b = el("b", null, text); return b; }
+
+function renderOverview(rows, newest) {
+  var lines = sum(rows, "lines"), days = rows.reduce(function (a, r) { return Math.max(a, r.days); }, 0);
+  var hero = $("hero");
+  hero.textContent = "";
+  hero.appendChild(heroBit(fmt(lines) + " lines"));
+  hero.appendChild(document.createTextNode(" in "));
+  hero.appendChild(heroBit(fmt(rows.length) + (rows.length === 1 ? " channel" : " channels")));
+  if (days) { hero.appendChild(document.createTextNode(", up to ")); hero.appendChild(heroBit(fmt(days) + " days")); hero.appendChild(document.createTextNode(" of history")); }
+
+  var f = $("facts");
+  f.textContent = "";
+  var lpd = rows.reduce(function (a, r) { return a + r.lpd; }, 0);
+  if (lpd) f.appendChild(fact("Lines per day, all channels", fmt(lpd)));
+  var busiest = rows.slice().sort(function (a, b) { return b.lpd - a.lpd; })[0];
+  if (busiest && busiest.lpd && rows.length > 1) f.appendChild(fact("Busiest channel", busiest.name, fmt(busiest.lpd) + " lines a day"));
+
+  // Hour by hour across every channel that has an hour profile.
+  var pool = [], t = 0, h;
+  for (h = 0; h < 24; h++) pool.push(0);
+  rows.forEach(function (r) { if (r.hours) r.hours.forEach(function (v, i) { pool[i] += v; t += v; }); });
+  if (t) {
+    var hi = 0, lo = 0;
+    for (h = 1; h < 24; h++) { if (pool[h] > pool[hi]) hi = h; if (pool[h] < pool[lo]) lo = h; }
+    f.appendChild(fact("Busiest hour, all channels", hourRange(hi), pct(pool[hi], t) + " of all lines"));
+    f.appendChild(fact("Quietest hour", hourRange(lo), pct(pool[lo], t) + " of all lines"));
+  }
+  var words = sum(rows, "words"), links = sum(rows, "links"), q = sum(rows, "questions");
+  if (words) f.appendChild(fact("Words", fmt(words), fmt1(words / Math.max(lines, 1)) + " per line"));
+  if (links) f.appendChild(fact("Links shared", fmt(links)));
+  if (q) f.appendChild(fact("Questions asked", fmt(q), pct(q, lines) + " of lines"));
+  if (newest) f.appendChild(fact("Last update", ago(newest) || "just now"));
+  $("overview").hidden = false;
+}
+
+function hoursChart(r) {
+  var wrap = el("div"), box = el("div", "hours");
+  if (!r.hours || !r.hoursTotal) return wrap;
+  var max = r.hours[r.peak] || 1;
+  r.hours.forEach(function (v, i) {
+    var b = el("i", "h" + Math.floor(i / 6));
+    b.style.height = Math.max(6, Math.round(100 * v / max)) + "%";     // the smallest hour still shows as a stub
+    b.title = (i < 10 ? "0" : "") + i + ":00 – " + pct(v, r.hoursTotal) + " of this channel's lines";
+    box.appendChild(b);
+  });
+  var ticks = el("div", "hticks");
+  ["0", "6", "12", "18", "23"].forEach(function (x) { ticks.appendChild(el("span", null, x)); });
+  wrap.appendChild(box); wrap.appendChild(ticks);
+  return wrap;
+}
+
+function sparkline(r) {
+  var NS = "http://www.w3.org/2000/svg", vals = r.recent;
+  if (!vals || !vals.length) return el("span", "sub", "");
+  var w = 150, h = 38, gap = 1.5, n = vals.length, bw = (w - gap * (n - 1)) / n;
+  var max = vals.reduce(function (a, v) { return Math.max(a, v); }, 0) || 1;
+  var svg = document.createElementNS(NS, "svg");
+  svg.setAttribute("viewBox", "0 0 " + w + " " + h);
+  svg.setAttribute("class", "spark");
+  svg.setAttribute("preserveAspectRatio", "none");
+  svg.setAttribute("role", "img");
+  svg.setAttribute("aria-label", "Lines per day over the last " + n + " days of the log, busiest day " + fmt(max) + " lines");
+  vals.forEach(function (v, i) {
+    var bh = Math.max(1, Math.round(h * v / max)), rc = document.createElementNS(NS, "rect");
+    rc.setAttribute("x", (i * (bw + gap)).toFixed(2)); rc.setAttribute("y", h - bh);
+    rc.setAttribute("width", bw.toFixed(2)); rc.setAttribute("height", bh);
+    var tt = document.createElementNS(NS, "title"); tt.textContent = fmt(v) + " lines";
+    rc.appendChild(tt); svg.appendChild(rc);
+  });
+  var box = el("div");
+  box.appendChild(svg);
+  box.appendChild(el("span", "sub", "busiest day " + fmt(max)));
+  return box;
+}
+
+function renderRows(rows) {
+  var host = $("rows"), max = rows.reduce(function (a, r) { return Math.max(a, r.lines); }, 0) || 1, total = sum(rows, "lines");
+  host.textContent = "";
+  rows.forEach(function (r) {
+    var row = el("div", "row");
+
+    var name = el("div", "name"), a = el("a", null, r.name);
+    a.href = r.file;
+    name.appendChild(a);
+    var meta = [];
+    if (r.c.network) meta.push(String(r.c.network));
+    var when = ago(r.c.updated);
+    if (when) meta.push("updated " + when);
+    if (meta.length) name.appendChild(el("span", "sub", meta.join(" · ")));
+    var view = el("a", "btn", "View stats \u2192");
+    view.href = r.file;
+    view.setAttribute("aria-label", "View the statistics of " + r.name);
+    name.appendChild(view);
+    row.appendChild(name);
+
+    var vol = el("div"), big = el("span", "big num", fmt(r.lines));
+    vol.appendChild(big);
+    vol.appendChild(document.createTextNode(" "));
+    vol.appendChild(el("span", "sub", pct(r.lines, total) + " of all"));
+    var track = el("div", "track"), fill = el("div", "fill");
+    fill.style.width = Math.max(1, Math.round(100 * r.lines / max)) + "%";
+    track.appendChild(fill); vol.appendChild(track);
+    var bits = [];
+    if (r.nicks) bits.push(fmt(r.nicks) + " nicks");
+    if (r.lpd) bits.push(fmt(r.lpd) + " a day");
+    if (r.days) bits.push(fmt(r.days) + " days");
+    vol.appendChild(el("span", "sub", bits.join(" · ")));
+    row.appendChild(vol);
+
+    var busy = el("div");
+    if (r.hoursTotal) {
+      busy.appendChild(el("span", "what num", hourRange(r.peak)));
+      busy.appendChild(el("span", "sub", pct(r.hours[r.peak], r.hoursTotal) + " of its lines"));
+    }
+    if (r.top.length) busy.appendChild(el("span", "sub", "top: " + r.top[0].nick + " (" + pct(whole(r.top[0].lines), r.lines) + ")"));
+    row.appendChild(busy);
+
+    var prof = hoursChart(r); prof.className = "profile";
+    row.appendChild(prof);
+    var spark = sparkline(r); spark.classList.add("trend");
+    row.appendChild(spark);
+    host.appendChild(row);
+  });
+  $("channels").hidden = false;
+}
+
+function renderPeople(rows) {
+  var host = $("talkers"), shown = 0;
+  host.textContent = "";
+  rows.forEach(function (r) {
+    if (!r.top.length) return;
+    shown++;
+    var box = el("div"), h3 = el("h3"), a = el("a", null, r.name);
+    a.href = r.file; h3.appendChild(a); box.appendChild(h3);
+    var ol = el("ol"), best = whole(r.top[0].lines) || 1;
+    r.top.slice(0, 5).forEach(function (t) {
+      var li = el("li"), tr = el("div", "track"), fl = el("div", "fill");
+      fl.style.width = Math.max(2, Math.round(100 * whole(t.lines) / best)) + "%";
+      tr.appendChild(fl);
+      li.appendChild(el("b", null, t.nick)); li.appendChild(tr);
+      li.appendChild(el("span", "num", fmt(whole(t.lines)) + " · " + pct(whole(t.lines), r.lines)));
+      li.firstChild.title = t.nick;
+      ol.appendChild(li);
+    });
+    box.appendChild(ol);
+    host.appendChild(box);
+  });
+  $("people").hidden = !shown;
+}
+
+function start(list) {
+  var chans = list.filter(function (c) { return c && typeof c.name === "string" && safeFile(c.file); });
+  if (!chans.length) {
+    say("<strong>No channels yet.</strong> Run <code>pisg</code> once: it writes <code>channels.json</code> next to its pages.");
+    return;
+  }
+  // Old links looked like  index.html#canada  and opened that channel: keep them working.
+  var want = norm((function () { var h = location.hash.replace(/^#/, ""); try { h = decodeURIComponent(h); } catch (e) {} return h; })());
+  if (want) {
+    for (var i = 0; i < chans.length; i++) if (norm(chans[i].name) === want) { location.replace(chans[i].file); return; }
+  }
+  var rows = chans.map(prepare).sort(function (a, b) { return b.lines - a.lines; });
+  var newest = chans.map(function (c) { return c.updated; }).filter(Boolean).sort().pop();
+  document.title = SITE_TITLE;
+  clearSay();
+  renderOverview(rows, newest);
+  renderRows(rows);
+  renderPeople(rows);
+}
+
+fetch(INDEX_URL, { cache: "no-store" })
+  .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
+  .then(function (d) {
+    if (!d || !Array.isArray(d.channels)) throw new Error("unexpected format");
+    start(d.channels);
+  })
+  .catch(function () {
+    say("<strong>Could not load the channel list.</strong> Run <code>pisg</code> so it creates " +
+        "<code>" + INDEX_URL + "</code>, and keep this page in the same folder as your channel pages.");
+  });
+</script>
+</body>
+</html>

Some files were not shown because too many files changed in this diff