# Skin Architecture

How the skin works, in its dark and light variants, and how to change it.

---

## Files

| File | Purpose |
|------|---------|
| `skin/css.txt` | All CSS: design tokens, layout, and the overrides for colors Fossil hard-codes. It is the dark variant; the light one is generated from it |
| `skin/header.txt` | TH1 template: HTML injected before page content on every Fossil page |
| `skin/footer.txt` | TH1 template: HTML injected after page content |
| `skin/apply.py` | Writes the (dark) skin into every repo on this server |
| `skin/build.py` | Writes the four importable files in `dist/`: dark and light, each with the system font or Instrument Sans |
| `www/index.html` | Custom landing page served by Caddy at `/` |
| `www/a11y.js` | Accessibility and behavior fixes, served by Caddy at `/a11y.js`; `build.py` inlines it into the importable files |

---

## Two variants from one stylesheet

`css.txt` holds every color as a token in its `:root` block, including about a dozen that
used to be literal values further down (input backgrounds, selection rows, table stripes) and
the RGB triplets behind every translucent tint (`--green-rgb`, `--veil-rgb` and so on, used as
`rgb(var(--green-rgb) / 0.10)`).

`build.py` makes the light variant by:

1. replacing the `:root` block with its `LIGHT_TOKENS`;
2. dropping the block between `/* @dark-only */` and `/* @end-dark-only */` (the re-tinting
   of Fossil's ticket status colors, which a light page does not need);
3. switching the color of the `<select>` arrow, which lives in a data URI;
4. setting `white-foreground: 0` in the details instead of `1`.

Step 4 is why the light variant is its own skin and not a CSS toggle. Fossil reads
`white-foreground` on the server and chooses user, branch and pikchr colors from it: dark,
low-saturation tints for white text when it is on, pastels for dark text when it is off. A
class toggle on the dark skin would receive the dark tints and could only wash them toward
gray.

### Dark tokens

The dark variant is a dark rendering of Fossil's default skin: same layout (title bar, menu
band, horizontal submenu row), same sizes (16px body, 2.2em project title), with colors for
WCAG AA contrast on a near-black page. `www/index.html` repeats them.

| Token | Value | Usage |
|-------|-------|-------|
| `--bg` | `#1c1e22` | Page background |
| `--surface` | `#272a30` | Menu band, cards, code blocks |
| `--surface2` | `#31353d` | Hover states, selected rows |
| `--border` | `#41454e` | All borders |
| `--text` | `#e8e9eb` | Body text (13:1 on `--bg`) |
| `--muted` | `#b3b8c0` | Secondary text (7.3:1 on `--bg`) |
| `--link` | `#7fb6ef` | Links (7.2:1 on `--bg`); the default skin's `#3779BF`, lightened |
| `--title` | `#8cbdf0` | Project title |
| `--active` | `#f08a3c` | Active menu underline; the default skin's `#D26911`, lightened |
| `--green` / `--red` / `--yellow` | `#6fd48f` / `#f08a7e` / `#f2c94c` | Diff and status colors |
| `--tint-mute` / `--chat-mute` | `0.4` / `0.3` | How far Fossil's branch and chat colors are blended toward the surface |
| `--font` | Instrument Sans, then system-ui | Body text |
| `--mono` | ui-monospace stack | Code |

### Light tokens

| Token | Value | Usage |
|-------|-------|-------|
| `--bg` | `#ffffff` | Page background |
| `--surface` / `--surface2` | `#f4f5f7` / `#e8ebef` | Menu band, cards; hover |
| `--border` | `#d3d7dd` | All borders |
| `--text` | `#1c1e22` | Body text (16:1) |
| `--muted` | `#565e68` | Secondary text (6.6:1) |
| `--link` | `#2b67a8` | Links (5.8:1); near the default skin's `#3779BF` |
| `--title` / `--active` | `#3a7bc0` / `#d26911` | Project title; the default skin's menu accent |
| `--tint-mute` / `--chat-mute` | `0` / `0` | Fossil's pastels are shown as they are |

---

## Deploying skin changes

On this server, after editing any skin file:

```bash
python3 /var/fossil/skin/apply.py
```

Fossil serves the updated skin immediately; no restart needed. For other repositories, run
`python3 skin/build.py` and import the file from `dist/` (see the README).

---

## Colors Fossil hard-codes

### Timeline graph

Fossil's built-in CSS draws graph lines and arrows black, which vanish on a dark page. The skin
gives `.tl-line` a default of `--muted` without `!important`, so the per-branch color Fossil
sets inline (with `timeline-color-graph-lines`) still wins. Nodes, arrows and warp lines are
overridden as before.

### Branch rows and chat messages

Fossil sets an inline `background-color` on timeline cells (branch colors) and on chat
messages (the sender's color, `m.uclr`). With `white-foreground` on, those colors are
already dark tints meant for white text, so the skin does not replace them. It only lays a
translucent layer of the surface color over them with `background-image`, which CSS paints
above `background-color`:

```css
table.timelineTable td[style*="background"] {
  background-image: linear-gradient(rgb(var(--tint-rgb) / var(--tint-mute)), ...) !important;
}
body.chat .message-widget-content[style*="background"] { /* same, with --chat-mute */ }
```

`--tint-mute` (0.4) and `--chat-mute` (0.3) set how far they are blended toward the surface.
In the light variant both are 0. Messages without a color get `--surface`.

Which color each user gets is decided by Fossil, from a hash of the login, and can be
changed per repository with the `user-color-map` setting; see the README.

### Chat layout

Message bubbles get messaging-app corners. When Fossil right-aligns your own messages
(`body.chat.my-messages-right`, its default in tall windows), they point the other way. The
skin does not force right alignment, so the "Left-align my posts" chat setting keeps working.

The message options popup (click a message's header) is one card in `--surface2`, with quiet
timestamps, buttons at least 44px tall on touch screens, and "Delete globally" in `--red`
because it removes the message for everyone.

### isDark flag

Fossil's C code hard-codes `isDark: false` for custom skins, which affects the timeline graph
renderer. `a11y.js` sets `window.fossil.config.skin.isDark = true` and the body class
`fossil-dark-style`, but only when the stylesheet declares `color-scheme: dark`, so the light
variant, which embeds the same script, is left alone. It runs after Fossil's inline scripts
because of `defer`, and again on `window.load` because Fossil's load handlers may reset it.

---

## Menu bar

* The bar is a flex row. As inline blocks, the line breaks between the `<a>` elements in
  `header.txt` rendered as 4px gaps, so an underline stopped short of the divider beside it.
* Every item has a 2px transparent bottom border, colored `--active` on hover and on the
  current page. Highlighting therefore never changes an item's height or the bar's.
* The bar is named with `aria-label`, not `title`, so hovering it shows no tooltip.
* Every entry is shown on every screen size. When they do not fit, the bar scrolls sideways:
  ☰ is `position: sticky` at the left so it never scrolls away, a 28px fade at the right edge
  (`.mainmenu::after`) shows there is more, and `a11y.js` scrolls the bar so the current page's
  entry is in view. Fossil's own rule would hide entries marked `desktoponly` / `wideonly` on
  small screens; the skin's `.mainmenu a` display outranks it, on purpose.

## Hamburger menu (☰)

Fossil's `hbmenu.js` toggles `nav#hbdrop`. Three parts work together:

* `header.txt` sets `data-anim-ms="0"` on `#hbdrop`. That is `hbmenu.js`'s own switch to turn
  off its 400ms height animation, which set an inline `max-height` that overrode the skin's
  limits. The skin slides the panel in over 160ms instead, skipped under
  `prefers-reduced-motion`.
* On phones (`max-width: 600px` or `pointer: coarse`) the panel is a sheet from the bottom of
  the menu bar to the bottom of the screen that scrolls on its own
  (`overscroll-behavior: contain`). While it is open, `html` gets `overflow: hidden` and the
  menu bar `position: sticky`, both through `html:has(#hbdrop[style*="display: block"])`, so
  the page stays still and ☰ stays on screen to close it.
* `a11y.js` measures where the menu bar ends (`--nav-bottom`) on every open, in a capture
  listener that runs before `hbmenu.js` shows the panel, and again on the next frame. It moves
  focus to the first link only when the menu was opened from the keyboard.

## Timeline weekdays

`a11y.js` puts the full weekday on a small line above each `div.timelineDate`. It reads the
date Fossil prints, turns it into a day number with `Date.UTC`, and takes the weekday with Ben
Joffe's branchless method (benjoffe.com/fast-day-of-week), the same one zit uses:

```js
((Math.imul(day, 613566756) + 0x95000000 + (day >> 1) + (day >> 4)) >>> 0) >>> 29
```

It was checked against a plain modular calculation for every day from 1970 ± 100 million days
(no mismatch), and 10,000 dates take under 4ms. The span carries its own layout in its
`style` attribute, so a stylesheet the browser has cached from before cannot misplace it.

## Logo

The logo is shown in a 48px box (36px on phones) with `object-fit: contain`, so a repository
whose logo is not square shows it whole. A square logo looks the same as with `cover`.

## Phones

At `max-width: 600px` wide tables (`table.sortable`, `table.report`) scroll inside
themselves, label-and-value tables (check-in info) wrap their values, including the hash in
`span.nobr`, and the project title is 1.3em. Markdown tables scroll inside themselves too.
Fossil's label-and-field tables on ticket pages (`body.tkt`) and admin forms (`body.setup`)
stack into label above value, File Ages shows the check-in under each file, forms and their
fields are held to the screen width, and controls and submenu links are at least 44px tall.
Forum replies are indented 10px a level (Fossil's inline style asks for 4ex), up to ten levels.

## The page heading and Fossil's forum script

`header.txt` prints the page title as `h1.page-title`, shown only on wiki, document, directory
and artifact pages. It is not shown there either when the page opens with a heading of its own
(a wiki page's first heading, "History of ...", "Files in ..."), which would say it twice; the
rule uses `body:has(...)` to look for a heading at the top of `div.content`. Elsewhere it is hidden with `clip-path` and a 1px width, never with
`display: none`: Fossil's forum script (`fossil.page.forumpost.js`, `refillTapZone`) sizes its
expand control from the height of the first `h1` or `h2` on the page and loops forever if that
height is 0, which froze every long forum post.

## Markup the skin works around

* Fields Fossil emits as `<input>` with no type, or `type="input"`, get the text-field style.
* Charts drawn as `<svg width= height=>` with no `viewBox` (activity reports) get one from
  `a11y.js`, so `svg.fit { max-width: 100% }` can shrink them.
* Fossil puts `class="error"` on `<body>` for error pages and colors it yellow; the skin keeps
  the page as it is and marks only the message.
* Sortable column headers get `tabindex` and respond to Enter and Space.

---

## TH1 Template Notes

The `header.txt` and `footer.txt` files use Fossil's TH1 scripting language (a Tcl subset).

Available variables in header/footer:
- `$home`: base URL of the repo (e.g. `/edgesync`)
- `$<project_name>`: repo's project name (HTML-escaped substitution)
- `$<title>`: current page title
- `$current_page`: path of the current page
- `$mainmenu`: list of `{name url expr class}` tuples for nav links
- `$login`: current username if logged in

The `capexpr` function checks user capabilities; the mainmenu loop uses it to hide links the
current user can't reach.

---

## CSP and JavaScript

Fossil's Content Security Policy:
```
script-src 'self' 'nonce-<per-request-nonce>'
```

Inline scripts without the matching nonce are blocked. On this server `a11y.js` is served as
a static asset by Caddy at `/a11y.js`, which satisfies `'self'`. It's loaded in `header.txt`
with:

```html
<script defer src="/a11y.js"></script>
```

The importable files from `build.py` inline it instead, as `<script nonce="$nonce">`, run on
`DOMContentLoaded` for the same ordering.

The `defer` attribute ensures:
1. HTML is fully parsed before the script runs
2. Fossil's inline config scripts (which set `window.fossil`) have already executed
3. The script runs before `DOMContentLoaded`

`window.load` re-runs the timeline and isDark fixes because Fossil's own `load` handlers
insert SVG graph elements and may reset config values.
