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:
- replacing the
:rootblock with itsLIGHT_TOKENS; - 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); - switching the colour of the
<select>arrow, which lives in a data URI; - setting
white-foreground: 0in the details instead of1.
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.
Menu bar
- The bar is a flex row. As inline blocks, the line breaks between the
<a>elements inheader.txtrendered as 4px gaps, so an underline stopped short of the divider beside it. - Every item has a 2px transparent bottom border, coloured
--activeon 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, nottitle, so hovering it shows no tooltip.
Hamburger menu (☰)
Fossil's hbmenu.js toggles nav#hbdrop. Three parts work together:
header.txtsetsdata-anim-ms="0"on#hbdrop. That ishbmenu.js's own switch to turn off its 400ms height animation, which set an inlinemax-heightthat overrode the skin's limits. The skin slides the panel in over 160ms instead, skipped underprefers-reduced-motion.- On phones (
max-width: 600pxorpointer: 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,htmlgetsoverflow: hiddenand the menu barposition: sticky, both throughhtml:has(#hbdrop[style*="display: block"]), so the page stays still and ☰ stays on screen to close it. a11y.jsmeasures where the menu bar ends (--nav-bottom) on every open, in a capture listener that runs beforehbmenu.jsshows 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:
((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. 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:
$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:
<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:
- HTML is fully parsed before the script runs
- Fossil's inline config scripts (which set
window.fossil) have already executed - 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.