Skip to content
fossil-skin Skin Architecture
Login

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 colours 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 behaviour fixes, served by Caddy at /a11y.js; build.py inlines it into the importable files

Two variants from one stylesheet

css.txt holds every colour 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 colours, which a light page does not need);
  3. switching the colour 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 colours 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 grey.

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 colours 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 colours
--tint-mute / --chat-mute 0.4 / 0.3 How far Fossil's branch and chat colours 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:

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).


Colours 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 colour 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 colours) and on chat messages (the sender's colour, m.uclr). With white-foreground on, those colours are already dark tints meant for white text, so the skin does not replace them. It only lays a translucent layer of the surface colour over them with background-image, which CSS paints above background-color:

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 colour get --surface.

Which colour 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.


Hamburger menu (☰)

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

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:

((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.

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. No page scrolls sideways.


TH1 Template Notes

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

Available variables in header/footer:

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:

<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.