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:
- replacing the
:rootblock with itsLIGHT_TOKENS; - 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); - switching the color 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 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:
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:
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 inheader.txtrendered as 4px gaps, so an underline stopped short of the divider beside it. - Every item has a 2px transparent bottom border, colored
--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. - Every entry is shown on every screen size. When they do not fit, the bar scrolls sideways:
☰ is
position: stickyat the left so it never scrolls away, a 28px fade at the right edge (.mainmenu::after) shows there is more, anda11y.jsscrolls the bar so the current page's entry is in view. Fossil's own rule would hide entries markeddesktoponly/wideonlyon small screens; the skin's.mainmenu adisplay outranks it, on purpose.
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. 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.
Two pages still scroll sideways: the Compact timeline view and the admin Security Audit page.
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. 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, ortype="input", get the text-field style. - Charts drawn as
<svg width= height=>with noviewBox(activity reports) get one froma11y.js, sosvg.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
tabindexand 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:
<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.