Introduction

Simple Gal is a static site generator built for photographers who want a portfolio that lasts.

You point it at a directory of images, run one command, and get a complete gallery site -- HTML, CSS, responsive images, and nothing else. No database. No CMS. No JavaScript framework. The output can be dropped onto any file server and it will work, today and twenty years from now.

Who it's for

Simple Gal is for photographers -- professional or otherwise -- who have accumulated galleries over time and want a clean, permanent home for them online. If you care about how your images are presented (compression, sharpening, aspect ratios, ordering) and you don't want to fight a platform to get it right, this is for you.

It is not for photographers who need comments, search, e-commerce, or client proofing. It generates a portfolio, not a platform.

The problem

Most gallery solutions share the same failure modes:

  • Features you didn't ask for. Login screens, likes, comments, social sharing buttons -- bolted on for the platform's benefit, not yours.
  • Breaking changes. Updates that rearrange your layout, change your URLs, or require migration work.
  • Subscription fees. Monthly charges for hosting what amounts to static files.
  • Server infrastructure. Maintenance, updates, security patches, database backups -- all for showing pictures.
  • Bespoke data models. Your images and metadata locked into a format that forces you to start over when you switch tools.
  • Poor photographic control. Aggressive compression, generic center-crops, no control over sharpening or aspect ratios.

These aren't edge cases. They're the norm. If you've been making photographs for a decade, you've probably been through several of these cycles already.

What Simple Gal gives you

Your filesystem is the data source. Directories become albums. Images become photos. Filenames and IPTC tags provide titles and descriptions. There is no tool-specific data format to migrate from -- your files are the truth.

Photographic control. You set the image quality, sharpening, responsive breakpoints, and thumbnail aspect ratios. Per gallery, if you want. No more fighting a platform's one-size-fits-all image pipeline.

Fast, clean output. Each page weighs about 9 KB before images. Navigation is page-based with smooth transitions. Mobile-first layout with swipe support. Light and dark modes. No spinners, no layout shift, no pop-ups.

Nothing to maintain. The generated site is plain HTML and CSS with about 30 lines of vanilla JavaScript (for keyboard shortcuts and swipe gestures -- click navigation is pure HTML/CSS). No runtime dependencies. No build tools. No framework updates to chase.

Installable. Every generated site is a Progressive Web App out of the box. Visitors can add it to their home screen for offline, app-like viewing.

How it works

Simple Gal runs a three-stage pipeline:

  1. Scan -- reads your content directory and builds a manifest of albums, images, pages, and configuration.
  2. Process -- generates responsive image sizes and thumbnails in AVIF format.
  3. Generate -- produces the final HTML site with inline CSS.

You run it with a single command:

simple-gal build

The output lands in dist/ and is ready to deploy. See the Quick Start to get a working gallery in five minutes, or read about The Forever Stack to understand why it's built this way.

Quick Start

This guide takes you from zero to a working photography gallery in about five minutes.

Install

Grab a pre-built binary from the releases page for your platform, or install via Cargo:

cargo install simple-gal

Verify the installation:

simple-gal --version

Create your content

Simple Gal reads from a content/ directory. Create one with an album inside it:

mkdir -p content/010-My-Album

Copy some JPEG or PNG files into that album directory:

cp ~/Photos/favorites/*.jpg content/010-My-Album/

That's it. The directory name becomes the album title (My-Album), and the numeric prefix (010) controls where it appears in navigation.

Build

Run the build command from the directory that contains content/:

simple-gal build

You'll see output like this:

Scanning content...
Found 1 album, 12 images
Processing images...
Generating site...
Done. Output: dist/

The dist/ directory now contains a complete, self-contained gallery site.

View

Open the generated site in your browser:

open dist/index.html

You should see a home page with a thumbnail grid for your album. Click into the album to browse photos with keyboard arrows, swipe gestures, or edge clicks.

Add a site title

By default the site has no title. Create a configuration file to set one:

echo 'site_title = "My Portfolio"' > content/config.toml

Rebuild:

simple-gal build

The title now appears in the header and in the browser tab.

Add more albums

Create additional directories with numeric prefixes to control their order:

mkdir content/020-Portraits
mkdir content/030-Travel

Drop images into each one and rebuild. The home page will show one thumbnail per album, ordered by prefix.

Add an album description

Create a description.md (or description.txt) file inside any album directory:

echo "Landscapes from the Pacific Northwest, 2019--2024." > content/010-My-Album/description.txt

The text appears above the thumbnail grid on that album's page.

Add a page

Markdown files in the content root with a numeric prefix become pages in the site navigation:

cat > content/040-About.md << 'EOF'
# About

Photographer based in Portland, Oregon.
Contact: photos@example.com
EOF

Rebuild, and "About" appears in the navigation bar.

Explore the full config

Run gen-config to see every available option with its default value:

simple-gal gen-config

Redirect it to a file to use as a starting point:

simple-gal gen-config > content/config.toml

From here, you can customize colors and theme, fonts, thumbnail aspect ratios, image quality, and more. Each album can override any setting with its own config.toml -- only the keys you change need to be listed.

Next steps

Directory Structure

Ordering and Naming

Metadata

Pages and Links

Albums and Groups

Configuration Overview

Simple Gal uses TOML configuration files that cascade through the directory tree. Each level inherits settings from its parent and can override specific keys without repeating the rest.

The config chain

Configuration is resolved in four layers, from least to most specific:

  1. Stock defaults -- built into the binary, always present
  2. Root config.toml -- in your content directory root
  3. Group config.toml -- in a group (nested album) directory
  4. Gallery config.toml -- in an individual album directory

Each layer merges on top of the previous one. Only the keys you specify are overridden; everything else passes through unchanged.

Stock defaults
  └─ content/config.toml              (root overrides)
       └─ content/020-Travel/config.toml       (group overrides)
            └─ content/020-Travel/010-Japan/config.toml  (gallery overrides)

Partial configs

Config files are sparse. You never need to specify every key -- just the ones you want to change. For example, a gallery that only needs different AVIF quality:

# content/020-Travel/010-Japan/config.toml
[images]
quality = 75

This gallery inherits all other settings (colors, fonts, thumbnail ratios, theme spacing) from its parent group, which in turn inherits from the root, which inherits from stock defaults.

Merge example

Consider this directory tree:

content/
├── config.toml
├── 010-Portraits/
│   ├── photo1.jpg
│   └── photo2.jpg
└── 020-Travel/
    ├── config.toml
    └── 010-Japan/
        ├── config.toml
        └── photo1.jpg

Root config (content/config.toml):

site_title = "My Portfolio"

[font]
font = "Playfair Display"
font_type = "serif"
weight = "400"

[thumbnails]
aspect_ratio = [3, 4]

Group config (content/020-Travel/config.toml):

[thumbnails]
aspect_ratio = [1, 1]

Gallery config (content/020-Travel/010-Japan/config.toml):

[images]
quality = 75

Here is what each level sees:

Setting010-Portraits020-Travel020-Travel/010-Japan
site_title"My Portfolio""My Portfolio""My Portfolio"
font.font"Playfair Display""Playfair Display""Playfair Display"
thumbnails.aspect_ratio[3, 4][1, 1][1, 1]
images.quality90 (stock default)90 (stock default)75

The 010-Portraits album has no config.toml, so it uses the root config as-is. The 020-Travel group overrides only the thumbnail aspect ratio; everything else flows through from root. The 010-Japan gallery inherits the square thumbnails from its parent group and overrides only the image quality.

Merge rules

  • Scalar values (strings, numbers): the child value replaces the parent value.
  • Arrays (like images.sizes): the child array replaces the parent array entirely. There is no element-level merging.
  • Subsections (like [colors.light]): merged key by key. Setting background in a child does not reset text or other sibling keys.
  • Absent keys: inherited from the parent level without change.

Unknown key rejection

Simple Gal rejects any key it does not recognize. This catches typos before they silently produce wrong output:

[images]
qualty = 90    # Error: unknown field "qualty"

The error message names the unknown field and lists the valid alternatives, so the fix is usually obvious.

Generating a starter config

Run simple-gal gen-config to print a fully-commented config.toml with every key and its stock default value:

simple-gal gen-config > content/config.toml

Edit the generated file to keep only the keys you want to customize, or leave them all in place as documentation. Either way works -- stock-default values are harmless to repeat.

Configuration Reference

Every configuration key, its type, default value, and purpose. All keys are optional. Run simple-gal gen-config to generate a complete annotated config file.

Top-level keys

KeyTypeDefaultDescription
content_rootstring"content"Path to the content directory. Only meaningful in the root config.
site_titlestring"Gallery"Site title used in breadcrumbs and the browser tab for the index page.
assets_dirstring"assets"Directory for static assets (favicon, fonts, etc.), relative to content root. Contents are copied verbatim to the output root. Silently skipped if it does not exist.
site_description_filestring"site"Stem of the site description file in the content root. If site.md or site.txt exists, its content is rendered on the index page.
content_root = "content"
site_title = "My Portfolio"
assets_dir = "assets"
site_description_file = "site"

[thumbnails]

Controls how thumbnails are cropped and sized.

KeyTypeDefaultDescription
aspect_ratio[u32, u32][4, 5]Width-to-height ratio for thumbnail crops. [1, 1] for square, [3, 2] for landscape.
sizeu32400Short-edge size in pixels for generated thumbnails.
[thumbnails]
aspect_ratio = [4, 5]
size = 400

Common aspect ratio choices:

RatioEffect
[1, 1]Square thumbnails
[4, 5]Slightly tall portrait (default)
[3, 2]Classic landscape
[2, 3]Tall portrait

[images]

Controls responsive image generation.

KeyTypeDefaultDescription
sizes[u32, ...][800, 1400, 2080]Pixel widths (longer edge) to generate for responsive <picture> elements.
qualityu3290AVIF encoding quality. 0 = smallest file / worst quality, 100 = largest file / best quality.
[images]
sizes = [800, 1400, 2080]
quality = 90

Validation rules:

  • quality must be 0--100.
  • sizes must contain at least one value.

[theme]

Layout spacing values. All values are CSS length strings.

KeyTypeDefaultDescription
thumbnail_gapstring"1rem"Gap between thumbnails in album and image grids.
grid_paddingstring"2rem"Padding around the thumbnail grid container.
[theme]
thumbnail_gap = "1rem"
grid_padding = "2rem"

[theme.mat_x]

Horizontal mat (spacing) around images on photo pages. Rendered as CSS clamp(min, size, max).

KeyTypeDefaultDescription
sizestring"3vw"Preferred/fluid value, typically viewport-relative.
minstring"1rem"Minimum bound.
maxstring"2.5rem"Maximum bound.
[theme.mat_x]
size = "3vw"
min = "1rem"
max = "2.5rem"

[theme.mat_y]

Vertical mat (spacing) around images on photo pages. Same structure as mat_x.

KeyTypeDefaultDescription
sizestring"6vw"Preferred/fluid value.
minstring"2rem"Minimum bound.
maxstring"5rem"Maximum bound.
[theme.mat_y]
size = "6vw"
min = "2rem"
max = "5rem"

[colors.light]

Light mode color scheme. Applied by default and when the user's system is set to light mode.

KeyTypeDefaultDescription
backgroundstring"#ffffff"Page background color.
textstring"#111111"Primary text color.
text_mutedstring"#666666"Secondary text: nav menu, breadcrumbs, captions.
borderstring"#e0e0e0"Border color.
separatorstring"#e0e0e0"Separator color: header underline, nav menu divider.
linkstring"#333333"Link color.
link_hoverstring"#000000"Link hover color.
[colors.light]
background = "#ffffff"
text = "#111111"
text_muted = "#666666"
border = "#e0e0e0"
separator = "#e0e0e0"
link = "#333333"
link_hover = "#000000"

[colors.dark]

Dark mode color scheme. Applied when the user's system prefers dark mode (prefers-color-scheme: dark).

KeyTypeDefaultDescription
backgroundstring"#000000"Page background color.
textstring"#fafafa"Primary text color.
text_mutedstring"#999999"Secondary text.
borderstring"#333333"Border color.
separatorstring"#333333"Separator color.
linkstring"#cccccc"Link color.
link_hoverstring"#ffffff"Link hover color.
[colors.dark]
background = "#000000"
text = "#fafafa"
text_muted = "#999999"
border = "#333333"
separator = "#333333"
link = "#cccccc"
link_hover = "#ffffff"

[font]

Typography settings. By default, fonts are loaded from Google Fonts. Set source to use a local font file instead.

KeyTypeDefaultDescription
fontstring"Space Grotesk"Font family name. Used as the Google Fonts family name, or as the font-family name for local fonts.
weightstring"600"Font weight to load.
font_typestring"sans""sans" or "serif". Determines the CSS fallback font stack.
sourcestring(none)Path to a local font file, relative to the site root. When set, generates @font-face CSS instead of loading from Google Fonts. Supported formats: .woff2, .woff, .ttf, .otf.
# Google Fonts (default behavior)
[font]
font = "Space Grotesk"
weight = "600"
font_type = "sans"

# Local font file
[font]
font = "My Custom Font"
weight = "400"
font_type = "sans"
source = "fonts/MyFont.woff2"

[processing]

Parallel image processing settings.

KeyTypeDefaultDescription
max_processesu32(auto: CPU core count)Maximum number of parallel image processing workers. When omitted, uses all available CPU cores. Values larger than the core count are clamped down.
[processing]
max_processes = 4

CSS custom properties

Config values are compiled into CSS custom properties, injected as inline <style> blocks in every page. The stylesheet references these variables rather than hardcoded values.

Color variables

CSS variableConfig key
--color-bgcolors.{light,dark}.background
--color-textcolors.{light,dark}.text
--color-text-mutedcolors.{light,dark}.text_muted
--color-bordercolors.{light,dark}.border
--color-separatorcolors.{light,dark}.separator
--color-linkcolors.{light,dark}.link
--color-link-hovercolors.{light,dark}.link_hover

Light mode values are set on :root. Dark mode values are set inside @media (prefers-color-scheme: dark).

Theme variables

CSS variableConfig keyGenerated as
--mat-xtheme.mat_x.*clamp(min, size, max)
--mat-ytheme.mat_y.*clamp(min, size, max)
--thumbnail-gaptheme.thumbnail_gapDirect value
--grid-paddingtheme.grid_paddingDirect value

Font variables

CSS variableConfig key
--font-familyfont.font + font.font_type (includes fallback stack)
--font-weightfont.weight

Colors and Theme

Simple Gal generates CSS custom properties from your color and theme configuration. Colors adapt automatically to the visitor's system preference (light or dark mode). Theme settings control the spatial layout around images and thumbnails.

Color schemes

Two independent color schemes are defined under [colors.light] and [colors.dark]. The light scheme is the default; the dark scheme activates via the prefers-color-scheme: dark media query.

Each scheme has seven color slots:

SlotUsed for
backgroundPage background
textPrimary body text
text_mutedSecondary text: navigation, breadcrumbs, captions
borderElement borders
separatorHeader underline, nav menu dividers
linkLink text
link_hoverLink text on hover

Overriding individual colors

You do not need to redefine all seven colors. Override only the ones you want to change:

[colors.light]
background = "#f5f0eb"
text = "#2a2a2a"

The remaining five light-mode colors keep their stock defaults. Dark mode is entirely unaffected.

Generated CSS

The color config produces two CSS blocks:

:root {
    --color-bg: #ffffff;
    --color-text: #111111;
    --color-text-muted: #666666;
    --color-border: #e0e0e0;
    --color-link: #333333;
    --color-link-hover: #000000;
    --color-separator: #e0e0e0;
}

@media (prefers-color-scheme: dark) {
    :root {
        --color-bg: #000000;
        --color-text: #fafafa;
        --color-text-muted: #999999;
        --color-border: #333333;
        --color-link: #cccccc;
        --color-link-hover: #ffffff;
        --color-separator: #333333;
    }
}

These variables are referenced throughout the stylesheet. You can also use them in a custom.css file to style additional elements consistently.

Mat spacing

In traditional photography, a mat (or mount) is the border between a print and its frame. Simple Gal uses this concept for the breathing room around full-size images on photo pages.

Two mat dimensions are configurable:

  • mat_x -- horizontal spacing (left and right of the image)
  • mat_y -- vertical spacing (above and below the image)

Each is defined as three values that map to CSS clamp():

[theme.mat_x]
size = "3vw"      # preferred size, scales with viewport
min = "1rem"      # never smaller than this
max = "2.5rem"    # never larger than this

How CSS clamp() works

clamp(min, preferred, max) picks the preferred value but constrains it within bounds:

  • On a narrow phone screen, 3vw might compute to 10px, which is less than 1rem (~16px). The mat stays at 1rem.
  • On a standard laptop, 3vw might be 28px, comfortably between 1rem and 2.5rem. The mat uses 28px.
  • On a 4K monitor, 3vw could be 60px, exceeding 2.5rem (~40px). The mat caps at 2.5rem.

This produces spacing that feels proportional on every screen without becoming too tight on phones or too wide on large displays.

The generated CSS:

:root {
    --mat-x: clamp(1rem, 3vw, 2.5rem);
    --mat-y: clamp(2rem, 6vw, 5rem);
    --thumbnail-gap: 1rem;
    --grid-padding: 2rem;
}

Adjusting mat size

To make the image presentation tighter (less surrounding space):

[theme.mat_x]
size = "1.5vw"
min = "0.5rem"
max = "1.5rem"

[theme.mat_y]
size = "3vw"
min = "1rem"
max = "2.5rem"

To make it more expansive (gallery-wall feel):

[theme.mat_x]
size = "5vw"
min = "2rem"
max = "4rem"

[theme.mat_y]
size = "8vw"
min = "3rem"
max = "6rem"

You can override individual fields within a mat section. For example, adjusting only the minimum without touching the preferred or maximum values:

[theme.mat_x]
min = "0.5rem"

Grid spacing

Two values control the thumbnail grid layout:

KeyDefaultEffect
thumbnail_gap"1rem"Space between individual thumbnails in the grid
grid_padding"2rem"Padding around the entire grid container
[theme]
thumbnail_gap = "0.5rem"
grid_padding = "1rem"

These accept any valid CSS length value: rem, em, px, vw, etc. Use rem for values that scale with the user's font size preference, or px for fixed spacing.

Tight grid example

For a dense, mosaic-style layout with minimal spacing:

[theme]
thumbnail_gap = "2px"
grid_padding = "0"

Spacious grid example

For a gallery feel with generous breathing room:

[theme]
thumbnail_gap = "1.5rem"
grid_padding = "3rem"

Per-album theming

Because config files cascade through the directory tree, you can give different albums different visual treatments. A travel photography group might use tighter spacing, while a studio portrait gallery uses wider mats:

# content/020-Travel/config.toml
[theme]
thumbnail_gap = "2px"
grid_padding = "0.5rem"

[theme.mat_x]
size = "1vw"
min = "0.25rem"
max = "1rem"
# content/010-Portraits/config.toml
[theme.mat_x]
size = "5vw"
min = "2rem"
max = "4rem"

Colors, fonts, and other settings not specified in these files are inherited from the root config or stock defaults.

Fonts

Simple Gal supports two ways to load fonts: from Google Fonts (the default) or from a local font file. Both methods produce the same CSS custom properties and fallback stack.

Google Fonts (default)

By default, Simple Gal loads the font from Google Fonts via a <link> stylesheet tag. Configure the family name and weight:

[font]
font = "Space Grotesk"
weight = "600"
font_type = "sans"

This generates a <link> tag pointing to:

https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@600&display=swap

The display=swap parameter ensures text remains visible while the font loads.

Choosing a Google Font

Any family available on fonts.google.com works. Set font to the exact family name as it appears on Google Fonts. Examples:

# Clean geometric sans-serif
[font]
font = "Space Grotesk"
weight = "600"
font_type = "sans"

# Elegant serif
[font]
font = "Playfair Display"
weight = "400"
font_type = "serif"

# Minimal sans-serif
[font]
font = "Inter"
weight = "500"
font_type = "sans"

# Monospace
[font]
font = "JetBrains Mono"
weight = "400"
font_type = "sans"

Local fonts

To use a self-hosted font file instead of Google Fonts, add the source key pointing to the font file path relative to the site root:

[font]
font = "My Custom Font"
weight = "400"
font_type = "sans"
source = "fonts/MyFont.woff2"

When source is set:

  • No Google Fonts <link> tag is generated.
  • A @font-face CSS declaration is generated inline.
  • The font file must be placed in your assets directory so it gets copied to the output.

Setting up a local font

  1. Create a fonts/ directory inside your assets directory:

    content/
    └── assets/
        └── fonts/
            └── MyFont.woff2
    
  2. Configure the font in your config.toml:

    [font]
    font = "My Custom Font"
    weight = "400"
    font_type = "serif"
    source = "fonts/MyFont.woff2"
    
  3. The generated CSS will include:

    @font-face {
        font-family: "My Custom Font";
        src: url("/fonts/MyFont.woff2") format("woff2");
        font-weight: 400;
        font-display: swap;
    }
    
    :root {
        --font-family: "My Custom Font", Georgia, "Times New Roman", serif;
        --font-weight: 400;
    }
    

Supported font formats

ExtensionCSS formatNotes
.woff2woff2Recommended. Best compression, wide browser support.
.woffwoffGood compression, universal support.
.ttftruetypeLarger files, universal support.
.otfopentypeSimilar to TTF, supports advanced typographic features.

Use .woff2 when possible for the smallest file size.

Font type and fallback stacks

The font_type setting determines which system fonts are used as fallbacks while the custom font loads or if it fails to load:

font_typeFallback stack
"sans"Helvetica, Verdana, sans-serif
"serif"Georgia, "Times New Roman", serif

The full --font-family CSS variable includes both the configured font name and the fallback stack:

/* font_type = "sans" */
--font-family: "Space Grotesk", Helvetica, Verdana, sans-serif;

/* font_type = "serif" */
--font-family: "Playfair Display", Georgia, "Times New Roman", serif;

Choose the font_type that best matches the character of your chosen font. This ensures the fallback text has a similar feel if the primary font is unavailable.

Font weight

The weight key is a string (not a number) that specifies which weight to load. Common values:

WeightName
"100"Thin
"200"Extra Light
"300"Light
"400"Regular
"500"Medium
"600"Semi Bold
"700"Bold
"800"Extra Bold
"900"Black

The weight applies site-wide. Simple Gal loads a single weight to keep page loads fast.

Per-album font overrides

Because configuration cascades, you can set a different font for specific albums or groups:

# content/010-Portraits/config.toml
[font]
font = "Cormorant Garamond"
weight = "300"
font_type = "serif"

This album uses a different font while inheriting all other settings from the parent config. Albums without a [font] section inherit the font from their parent.

Custom CSS and HTML Snippets

Simple Gal supports three convention files that let you inject custom CSS, analytics, tracking scripts, and other HTML into every page. Drop them into your assets/ directory and they are picked up automatically -- no configuration required.

Convention Files

FileInjection pointTypical use
custom.css<link> after the main stylesheetCSS overrides, layout tweaks, custom fonts
head.htmlEnd of <head>, after all other tagsAnalytics snippets, Open Graph meta tags, additional <link> or <meta> elements
body-end.htmlImmediately before </body>Tracking scripts, chat widgets, cookie banners

All three are optional. Use any combination you need.

How It Works

During the build, Simple Gal copies everything in assets/ to the output root. It then checks whether custom.css, head.html, or body-end.html exist in the output directory and injects references or content into every generated HTML page.

  • custom.css is loaded via a <link rel="stylesheet"> tag that appears after the main inline <style> block. This means your rules override the defaults at equal specificity -- no !important needed.
  • head.html is inserted as raw HTML at the very end of <head>, after the service worker script. Use it for anything that belongs in the document head.
  • body-end.html is inserted as raw HTML right before the closing </body> tag, after all page content.

Examples

Plausible Analytics

Create assets/head.html:

<script defer data-domain="yourdomain.com"
        src="https://plausible.io/js/script.js"></script>

Google Analytics

Create assets/head.html:

<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>

Open Graph Meta Tags

Add these to assets/head.html (you can combine multiple snippets in one file):

<meta property="og:title" content="Jane Doe Photography">
<meta property="og:description" content="Fine art landscape portfolio">
<meta property="og:image" content="https://example.com/og-image.jpg">
<meta property="og:url" content="https://example.com">

CSS Overrides

Create assets/custom.css:

/* Increase thumbnail size on the index page */
.album-grid {
    grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}

/* Wider mat spacing on photo pages */
:root {
    --mat-x: 4rem;
    --mat-y: 3rem;
}

Because custom.css loads after the main styles, these rules take effect without needing !important.

Create assets/body-end.html:

<script defer src="https://cdn.example.com/cookie-consent.js"></script>
<div id="cookie-banner" style="display:none;">
  <!-- your cookie banner markup -->
</div>

Chat Widget

Create assets/body-end.html:

<script>
  (function() {
    var s = document.createElement('script');
    s.src = 'https://chat.example.com/widget.js';
    s.async = true;
    document.body.appendChild(s);
  })();
</script>

Directory Layout

A typical setup with all three convention files:

my-portfolio/
  config.toml
  assets/
    custom.css          # CSS overrides
    head.html           # Analytics, meta tags
    body-end.html       # Tracking scripts
    fonts/              # Local font files (see Assets chapter)
    favicon.svg         # Custom favicon (see Assets chapter)
  01-landscapes/
    photo-01.jpg
    ...

Tips

  • The files are injected as-is with no processing or escaping. Make sure your HTML is valid.
  • custom.css is served as a separate file (not inlined), so the browser can cache it independently.
  • You can reference other files in your assets/ directory from these snippets using absolute paths (e.g., /fonts/my-font.woff2), since the entire assets/ directory is copied to the output root.
  • If you only need to change colors, fonts, or spacing, use config.toml instead. Convention files are for customizations that go beyond what the configuration system provides.

Assets Directory

The assets/ directory holds static files -- favicons, fonts, images, and anything else you want served alongside your portfolio. Its entire contents are copied verbatim to the output root during the build.

Basic Usage

Place an assets/ directory next to your config.toml:

my-portfolio/
  config.toml
  assets/
    favicon.svg
    fonts/
      my-font.woff2
    custom.css
  01-landscapes/
    ...

After building, these files appear at the output root:

dist/
  favicon.svg          # → accessible at /favicon.svg
  fonts/
    my-font.woff2      # → accessible at /fonts/my-font.woff2
  custom.css           # → accessible at /custom.css
  ...

Directory structure within assets/ is preserved.

Changing the Assets Directory

By default, Simple Gal looks for a directory called assets. You can change this in config.toml:

assets_dir = "site-assets"

The path is relative to the content root.

Favicons

Simple Gal ships with a default favicon.png. To use your own, place a favicon file in assets/. The build detects favicon files in this priority order:

PriorityFileMIME type
1favicon.svgimage/svg+xml
2favicon.icoimage/x-icon
3favicon.pngimage/png

The first match is injected as a <link rel="icon"> tag in every page. Since assets are copied to the output root after the default favicon.png is written, placing your own favicon.png in assets/ replaces the default.

For best results, use an SVG favicon. It scales to any size and supports dark mode via CSS prefers-color-scheme media queries inside the SVG.

PWA Icons

Simple Gal generates a Progressive Web App manifest with default icons. To use your own, place these files in assets/:

FileSizePurpose
icon-192.png192x192 pxAndroid home screen icon
icon-512.png512x512 pxAndroid splash screen
apple-touch-icon.png180x180 pxiOS home screen icon

As with favicons, your files in assets/ overwrite the defaults because assets are copied after the built-in icons are written.

Custom Fonts

To use a locally hosted font instead of a Google Font:

  1. Place font files in assets/fonts/:
assets/
  fonts/
    garamond.woff2
    garamond-italic.woff2
  1. Declare the font face in assets/custom.css:
@font-face {
    font-family: 'EB Garamond';
    src: url('/fonts/garamond.woff2') format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: 'EB Garamond';
    src: url('/fonts/garamond-italic.woff2') format('woff2');
    font-weight: 400;
    font-style: italic;
    font-display: swap;
}
  1. Set the font family in config.toml:
[font]
family = "EB Garamond"
source = "local"

Using source = "local" tells Simple Gal not to generate a Google Fonts <link> tag. The @font-face rules in custom.css handle loading instead.

Other Static Files

Any file you place in assets/ is served at the output root. Common uses:

  • robots.txt -- search engine directives
  • _headers or _redirects -- Netlify/Cloudflare Pages configuration
  • CNAME -- GitHub Pages custom domain
  • og-image.jpg -- a shared Open Graph image referenced from head.html

How Copying Works

The build pipeline writes default files (favicon, PWA icons, service worker) to the output directory first, then copies the contents of assets/ on top. This means:

  1. Any file in assets/ with the same name as a default file replaces it.
  2. Files in subdirectories of assets/ are placed in matching subdirectories in the output.
  3. The only exception: manifest.json files are skipped during the copy to avoid conflicts with the generated site.webmanifest.

Advanced Customization

This chapter covers the CSS custom properties and class names available for styling overrides. All overrides go in assets/custom.css (see Custom CSS and HTML Snippets).

CSS Custom Properties

Simple Gal generates CSS custom properties from your config.toml values and injects them into a :root block before the main stylesheet. You can override any of them in custom.css.

Color Properties

PropertyDefaultControls
--color-bg#ffffffPage background
--color-text#1a1a1aPrimary text
--color-text-muted#6b6b6bSecondary text (captions, descriptions, nav groups)
--color-border#e0e0e0Borders, image placeholder background
--color-link#1a1a1aLink text
--color-link-hover#4a4a4aLink hover state
--color-separator#e8e8e8Header border, nav dividers

These are best set via config.toml under [colors], but you can override them in custom.css when you need conditional logic (e.g., dark mode via prefers-color-scheme).

Theme Properties

PropertyDefaultControls
--mat-x2remHorizontal mat (padding) around photos on image pages
--mat-y2remVertical mat (padding) around photos on image pages
--thumbnail-gap0.5remGap between thumbnails in album and index grids
--grid-padding0Outer padding of thumbnail grids

Font Properties

PropertyDefaultControls
--font-familysystem-ui, sans-serifFont stack for all text
--font-weight400Base font weight

Internal Properties

These are defined in the stylesheet and not generated from config, but you can still override them:

PropertyDefaultControls
--header-height3remHeight of the fixed header bar
--font-size-base18pxBase font size
--font-size-small14pxSmall text (captions, metadata)
--font-size-heading1.5remAlbum and page headings
--transition-speed0.2sDuration of hover transitions

Key CSS Classes

These are the main classes in the generated HTML. Target them in custom.css for structural overrides.

Layout

ClassElementDescription
.site-header<header>Fixed top bar with breadcrumb and navigation
.breadcrumb<nav>Breadcrumb trail inside the header
.site-nav<nav>Navigation container (hamburger menu)
.nav-panel<div>Slide-in navigation panel

Index Page

ClassElementDescription
.index-page<main>Index page main container
.index-header<div>Site title and description block
.album-grid<div>Grid of album cards
.album-card<a>Individual album link with thumbnail and title
.album-title<span>Album title text below thumbnail

Album Page

ClassElementDescription
.album-page<main>Album page main container
.album-header<div>Album title and description block
.album-description<div>Album description text
.thumbnail-grid<div>Grid of image thumbnails
.thumb-link<a>Individual thumbnail link

Image Page

ClassElementDescription
body.image-view<body>Body class on image pages (sets overflow: hidden)
.image-page<div>Image page main container
.image-frame<div>Container for the photo itself
.image-caption<div>Short caption below the image
.image-description<div>Long description (scrollable)
.image-nav<div>Navigation dots between images
.nav-prev, .nav-next<a>Invisible click zones for prev/next navigation

Content Pages

ClassElementDescription
.page<main>Content page main container
.page-content<div>Rendered markdown content

Examples

Dark Mode Override

Override colors when the user prefers a dark color scheme:

@media (prefers-color-scheme: dark) {
    :root {
        --color-bg: #1a1a1a;
        --color-text: #e0e0e0;
        --color-text-muted: #999999;
        --color-border: #333333;
        --color-link: #e0e0e0;
        --color-link-hover: #ffffff;
        --color-separator: #333333;
    }
}

Larger Thumbnails

Make album cards bigger on the index page:

.album-grid {
    grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
}

Tighter Thumbnail Grid

Remove gaps for a mosaic look:

:root {
    --thumbnail-gap: 0;
    --grid-padding: 0;
}

More Mat Space

Give photos more breathing room on image pages:

:root {
    --mat-x: 6rem;
    --mat-y: 4rem;
}

Custom Hover Effect

Replace the default subtle zoom with an opacity fade:

.album-card:hover img {
    transform: none;
    opacity: 0.8;
}

.thumb-link:hover img {
    transform: none;
    opacity: 0.85;
}

Hide the Header

Remove the fixed header entirely:

.site-header {
    display: none;
}

main {
    margin-top: 0;
}

Adjust Header Height

Make the header taller and change its font size:

:root {
    --header-height: 4rem;
}

.site-header {
    font-size: 16px;
}

Style Captions

Customize caption appearance on image pages:

.image-caption {
    font-style: italic;
    text-align: center;
    font-size: 13px;
}

Fixed-Column Grid

Use a fixed number of columns instead of auto-fill:

.thumbnail-grid {
    grid-template-columns: repeat(3, 1fr);
}

@media (max-width: 768px) {
    .thumbnail-grid {
        grid-template-columns: repeat(2, 1fr);
    }
}

Square Thumbnails

Change the default 4:5 aspect ratio to 1:1:

.album-card img,
.thumb-link img {
    aspect-ratio: 1 / 1;
}

Tips

  • Override custom properties on :root to change values globally. Override them on specific selectors for scoped changes.
  • custom.css loads after the main stylesheet, so your rules win at equal specificity. You should rarely need !important.
  • Use your browser's developer tools to inspect the generated HTML and find the exact class names and structure for the element you want to customize.
  • View transitions use a 0.2-second fade by default. Override the ::view-transition-old(root) and ::view-transition-new(root) pseudo-elements to change the transition animation.

Responsive Sizes

Visitors view your portfolio on screens ranging from phones to 5K displays. Serving a single image size wastes bandwidth on small screens and looks soft on large ones. Simple Gal solves this by generating multiple sizes of each image and letting the browser pick the best one.

How it works

For each source image, Simple Gal produces an AVIF file at every configured breakpoint width. The generated HTML uses a standard <img> tag with a srcset attribute:

<img src="001-dawn-1400.avif"
     srcset="001-dawn-800.avif 800w,
             001-dawn-1400.avif 1400w,
             001-dawn-2080.avif 2080w"
     sizes="(max-width: 800px) 100vw, 80vw"
     alt="Dawn">

The browser reads the srcset list, considers the viewport width and device pixel ratio, and downloads only the size it needs. A phone on a cellular connection gets the 800px version; a retina desktop gets the 2080px version. You do nothing at runtime -- the browser handles selection automatically.

Configuring sizes

Set the breakpoints in your config.toml:

[images]
sizes = [800, 1400, 2080]

Each value is a pixel width for the longer edge of the image. Simple Gal preserves the original aspect ratio and calculates the shorter edge proportionally.

For example, a 4000x3000 landscape source at size 800 produces an 800x600 AVIF. A 3000x4000 portrait source at size 800 produces an 600x800 AVIF.

Choosing breakpoints

The defaults cover most use cases:

SizeTarget
800Phones, small tablets
1400Laptops, standard desktops
2080Large/retina displays

If your audience skews toward high-end monitors, add a larger size:

[images]
sizes = [800, 1400, 2080, 3200]

If you want faster builds and smaller output at the cost of sharpness on large screens, trim to two sizes:

[images]
sizes = [800, 1600]

More sizes mean more files and longer processing time, but each additional size only affects images large enough to benefit from it.

Small source images

When a source image is smaller than a configured size, that size is skipped. Simple Gal never upscales.

Consider sizes = [800, 1400, 2080] with a 1200x900 source:

  • 800: generated (source is large enough)
  • 1400: skipped (source is only 1200px wide)
  • 2080: skipped

If the source is smaller than every configured size, Simple Gal generates a single AVIF at the original dimensions. The image is still converted to AVIF for the file size benefit, but no scaling occurs.

Output format

All responsive images are encoded as AVIF. There is no option to output JPEG or WebP -- AVIF provides better compression at equivalent visual quality, and browser support is broad enough for a photography portfolio.

The encoding quality is controlled separately. See Quality for details.

Responsive sizes are set at any level of the config chain. A travel album with low-resolution phone photos might use smaller breakpoints:

# content/020-Travel/010-Phone-Snaps/config.toml
[images]
sizes = [400, 800]

This album generates only two sizes while other albums use the root config's three sizes. See Configuration Overview for how the config chain works.

Thumbnails

Thumbnails are the small preview images shown in album grids. Every image gets a thumbnail that is cropped to a consistent aspect ratio so the grid looks clean regardless of the mix of portrait and landscape originals.

How thumbnails are created

The process has two steps:

  1. Resize to fill -- the source image is scaled down using Lanczos3 resampling so that it completely covers the target dimensions, with no empty space.
  2. Center crop -- any overflow is trimmed equally from both sides, keeping the center of the image.

This is the same "cover" behavior you see in CSS object-fit: cover. A landscape photo cropped to a portrait thumbnail loses the left and right edges; a portrait photo cropped to a landscape thumbnail loses the top and bottom.

After cropping, a light unsharp mask (sigma 0.5, threshold 0) is applied to keep thumbnails crisp at small sizes.

Configuration

Two settings control thumbnail geometry:

[thumbnails]
aspect_ratio = [4, 5]
size = 400

Aspect ratio

aspect_ratio is expressed as [width, height]. The first value is the horizontal proportion; the second is the vertical.

# Portrait (taller than wide)
aspect_ratio = [4, 5]

# Square
aspect_ratio = [1, 1]

# Landscape (wider than tall)
aspect_ratio = [3, 2]

Common choices:

RatioShapeNotes
[4, 5]PortraitDefault. Works well for figure photography and vertical compositions.
[1, 1]SquareClean, symmetric grids. Good all-rounder.
[3, 2]LandscapeMatches the 35mm frame. Good for horizontal work.
[16, 9]Wide landscapeCinematic feel, but crops aggressively on portrait originals.
[4, 3]Mild landscapeLess aggressive crop than 16:9.

Size

size is the short edge of the thumbnail in pixels. The long edge is calculated from the aspect ratio.

With aspect_ratio = [4, 5] and size = 400:

  • Short edge (width) = 400px
  • Long edge (height) = 400 * 5/4 = 500px
  • Final thumbnail: 400x500 pixels

With aspect_ratio = [3, 2] and size = 300:

  • Short edge (height) = 300px
  • Long edge (width) = 300 * 3/2 = 450px
  • Final thumbnail: 450x300 pixels

The default of 400px produces sharp thumbnails on standard and retina screens without excessive file sizes.

Each album can override thumbnail settings through its own config.toml. This is useful when a gallery has a different visual character.

# content/010-Landscapes/config.toml
[thumbnails]
aspect_ratio = [3, 2]

This album gets landscape thumbnails while every other album uses the root config's portrait ratio.

You can also override at the group level. All albums under a group inherit the group's settings:

# content/020-Travel/config.toml
[thumbnails]
aspect_ratio = [1, 1]
size = 350

Now every album under 020-Travel uses square 350px thumbnails, unless an individual album overrides again.

Only the keys you specify are overridden. Setting aspect_ratio in a gallery config does not reset size to the default -- it keeps whatever value was inherited from the parent.

Output format

Thumbnails are encoded as AVIF using the same quality setting as responsive images. Each thumbnail is saved as {stem}-thumb.avif alongside the responsive sizes:

processed/010-Landscapes/
├── 001-dawn-800.avif
├── 001-dawn-1400.avif
├── 001-dawn-2080.avif
└── 001-dawn-thumb.avif

Image Quality

Simple Gal encodes all output images -- responsive sizes and thumbnails -- in AVIF format. The quality setting controls the tradeoff between file size and visual fidelity.

Configuration

[images]
quality = 90

The value is an integer from 0 to 100. Higher values produce larger files with fewer compression artifacts. Lower values produce smaller files with more visible degradation.

Choosing a quality value

The default of 90 is a good starting point for fine art photography. At this level, compression artifacts are invisible at normal viewing distances, and the files are roughly half the size of equivalent JPEGs.

QualityUse caseNotes
95-100Archival, print-resolution workMinimal compression. Large files. Diminishing returns above 95.
85-90Portfolio display (default range)Visually lossless for web viewing. Good balance.
70-80Documentation, travel snapshotsNoticeable softening on close inspection. Significantly smaller files.
Below 70Not recommendedVisible artifacts, especially in gradients and fine detail.

For most photography portfolios, values between 85 and 90 are the sweet spot. Going above 90 increases file size substantially with no perceptible improvement on screen.

AVIF vs JPEG

AVIF achieves better compression than JPEG at the same visual quality. As a rough guide:

  • AVIF quality 90 is comparable to JPEG quality 95 in perceived sharpness
  • AVIF files at quality 90 are typically 40-60% smaller than the equivalent JPEG

Simple Gal uses the rav1e encoder, a pure Rust AV1 implementation. This means no system dependencies -- the encoder is built into the binary. The tradeoff is that encoding is slower than hardware-accelerated alternatives, but this is offset by parallel processing across CPU cores.

Quality can be overridden at any level of the config chain. A gallery of phone snapshots might use a lower quality to reduce output size:

# content/030-Snapshots/config.toml
[images]
quality = 75

This gallery uses quality 75 while other galleries inherit the root config's quality 90. The quality setting applies to both responsive images and thumbnails for that gallery.

File size impact

To give a sense of scale, here are approximate file sizes for a single 2080px-wide landscape image at different quality levels:

QualityApproximate size
100800-1200 KB
90200-400 KB
80100-200 KB
7060-120 KB

Actual sizes vary with image content. Images with smooth gradients (skies, studio backdrops) compress better than images with fine detail (foliage, textured surfaces).

Processing Pipeline

Image processing is stage 2 of the Simple Gal build pipeline. It reads the manifest produced by the scan stage, processes every source image into responsive sizes and thumbnails, and writes the results to a temp directory for the generate stage to consume.

What happens during processing

For each image in every album, the processing stage:

  1. Reads the source file from your content directory
  2. Extracts dimensions and any embedded IPTC metadata (title, description)
  3. Generates AVIF files at each configured responsive size (skipping sizes larger than the source)
  4. Generates a single AVIF thumbnail at the configured aspect ratio and size
  5. Records all generated paths and dimensions in an output manifest

The output goes to .simple-gal-temp/processed/, organized by album:

.simple-gal-temp/processed/
├── manifest.json
├── 010-Landscapes/
│   ├── 001-dawn-800.avif
│   ├── 001-dawn-1400.avif
│   ├── 001-dawn-2080.avif
│   ├── 001-dawn-thumb.avif
│   ├── 002-dusk-800.avif
│   ├── 002-dusk-1400.avif
│   ├── 002-dusk-2080.avif
│   └── 002-dusk-thumb.avif
└── 020-Portraits/
    ├── 001-studio-800.avif
    ├── 001-studio-1400.avif
    ├── 001-studio-2080.avif
    └── 001-studio-thumb.avif

The manifest.json contains the full metadata for every album and image, including generated file paths, dimensions, titles, descriptions, and resolved configuration. The generate stage reads this file to produce the final HTML site.

Parallel processing

Simple Gal uses rayon to process images in parallel across CPU cores. By default, it uses all available cores. This makes a significant difference -- AVIF encoding is CPU-intensive, and a portfolio with 200 images can take minutes on a single core but seconds on a modern multi-core machine.

Limiting threads

If you need to constrain CPU usage (for example, on a shared server or while doing other work), set max_processes:

[processing]
max_processes = 4

This caps the number of parallel workers. If you set a value higher than the number of available cores, it is clamped down to the core count. Omit the key entirely to use all cores.

Setting max_processes = 1 disables parallelism and processes images sequentially.

Input formats

Simple Gal accepts the following source image formats:

FormatExtensions
JPEG.jpg, .jpeg
PNG.png
TIFF.tiff, .tif
WebP.webp

All input formats are converted to AVIF on output. The source files are never modified.

Output format

Every generated file is AVIF, encoded with the rav1e encoder. This is a pure Rust AV1 implementation compiled into the Simple Gal binary. There are no system dependencies -- no ImageMagick, no FFmpeg, no shared libraries to install.

The resampling algorithm for all resizing is Lanczos3, which produces sharp results with minimal ringing artifacts.

The temp directory

The .simple-gal-temp/ directory is a build artifact. It holds the processed images and manifest between the process and generate stages. You can safely delete it at any time -- it will be recreated on the next build.

Add it to your .gitignore:

.simple-gal-temp/

Per-album configuration

Each album uses its own resolved configuration from the config chain. This means different albums can have different responsive sizes, quality settings, and thumbnail aspect ratios. The processing stage reads the per-album config from the scan manifest and applies it independently.

See Responsive Sizes, Thumbnails, and Quality for the settings that control image output.

PWA Overview

Every site Simple Gal generates is a Progressive Web App. There is nothing to enable -- it works out of the box.

What this means for your visitors

When someone visits your portfolio on their phone, their browser will offer to install it as an app. Once installed, your portfolio:

  • Lives on their home screen alongside their other apps, with your own icon and name.
  • Opens full-screen without browser chrome -- no address bar, no tabs, just your photos.
  • Loads instantly on repeat visits. A service worker caches your pages and images, so returning visitors see content immediately even on slow connections.
  • Works offline. Pages and images that have been viewed before are available without a network connection. A visitor who browsed your Japan album on hotel wifi can revisit those photos on the plane.

None of this requires your visitors to visit an app store or create an account. The browser handles everything.

How it works (briefly)

Simple Gal generates three things that make this possible:

  1. A service worker (sw.js) -- a small script that runs in the background and manages a local cache of your site's pages and images.
  2. A web manifest (site.webmanifest) -- a file that tells the browser your site's name, icons, and display preferences.
  3. Registration code on every page that connects the two.

The caching strategy is stale-while-revalidate: the service worker serves cached content immediately, then fetches a fresh copy in the background. This means returning visitors always get a fast response, and the cache quietly stays up to date.

Automatic cache management

Each build stamps the cache with a version identifier (simple-gal-v<version>). When you deploy an updated site, the new service worker activates and cleans up old caches automatically. Your visitors get the new content on their next visit without any stale-cache issues.

Zero configuration

The PWA works with stock defaults and no configuration. If you want to customize the app name, icons, or theme color, see Customizing. But it is entirely optional -- the default setup is a fully functional PWA.

Installing as an App

Your visitors can install your portfolio as an app on their device. The process is slightly different on each platform, but in every case it takes a few seconds and requires no app store.

iOS (Safari)

Safari does not show an automatic install prompt. Visitors use the Share menu:

  1. Open your portfolio in Safari.
  2. Tap the Share button (the square with an upward arrow).
  3. Scroll down and tap Add to Home Screen.
  4. Optionally edit the name, then tap Add.

Your portfolio now appears on the home screen with its icon. Tapping it opens the site in standalone mode -- full screen, no Safari UI.

Note: On iOS, the PWA must be installed from Safari. Other browsers (Chrome, Firefox) on iOS do not support Add to Home Screen.

Android (Chrome)

Chrome shows an install banner automatically when it detects a valid PWA. Visitors can also install manually:

  1. Open your portfolio in Chrome.
  2. Tap the three-dot menu in the top-right corner.
  3. Tap Add to Home Screen (or Install app on newer versions).
  4. Confirm by tapping Add.

The portfolio appears in the app drawer and on the home screen, and opens without browser chrome.

Desktop (Chrome, Edge)

Desktop browsers also support PWA installation:

Chrome:

  1. Visit your portfolio.
  2. Click the install icon in the address bar (a monitor with a down arrow), or open the three-dot menu and select Install.

Edge:

  1. Visit your portfolio.
  2. Click the App available icon in the address bar, or open the three-dot menu and select Apps > Install this site as an app.

Once installed, the portfolio opens in its own window without browser tabs or address bar.

What visitors see after installation

Regardless of platform, the installed app:

  • Uses the name from your site_title configuration.
  • Shows your custom icon if you placed one in assets/ (see Customizing), or a default icon otherwise.
  • Opens in standalone display mode -- the full screen is your portfolio, with no browser UI.
  • Loads cached content instantly on launch.

Customizing the PWA

The PWA works without any configuration, but you can control the app name, icons, and theme color.

App name

The PWA uses site_title from your config.toml as the app name. This is the name visitors see when they install the app and on their home screen.

# content/config.toml
site_title = "Sarah Chen Photography"

If no site_title is set, the PWA falls back to the stock default.

Icons

Place icon files in your assets directory to replace the defaults:

content/
└── assets/
    ├── icon-192.png          # 192x192 -- used on Android home screens
    ├── icon-512.png          # 512x512 -- used for splash screens
    └── apple-touch-icon.png  # 180x180 -- used on iOS home screens

All three files are optional. Any icon you do not provide will use the built-in default.

Recommendations:

  • Use square PNG images with no transparency for best results across platforms.
  • The icon-192.png is the most visible -- it is the app icon on most Android devices.
  • The apple-touch-icon.png is what iOS uses on the home screen. If you provide only one custom icon, make it this one and icon-192.png.

Theme color

The theme color controls the browser toolbar tint and splash screen background on Android. It defaults to white (#ffffff).

To change it, place a custom site.webmanifest file in your assets directory:

content/
└── assets/
    └── site.webmanifest

The file should contain valid JSON. Here is a minimal example that changes only the theme color:

{
  "name": "Sarah Chen Photography",
  "short_name": "Sarah Chen",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#1a1a1a",
  "theme_color": "#1a1a1a",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

When you provide a custom site.webmanifest, it replaces the generated one entirely. Make sure to include all required fields.

Root-only limitation

The PWA service worker requires that your site is served from the root of its domain. This means:

URLWorks
https://photos.example.com/Yes
https://example.com/Yes
https://example.com/photos/No

If your portfolio lives under a subdirectory, the service worker will not register correctly and PWA features (offline access, home screen installation) will not work. The site will still function normally as a website -- only the PWA features are affected.

The simplest solution is to use a subdomain (photos.example.com) instead of a subdirectory (example.com/photos/).

Local Usage

Installation

From crates.io:

cargo install simple-gal

From GitHub releases:

Download a pre-built binary for your platform from the releases page. Place it somewhere on your PATH.

Building your site

The default command reads from content/ and writes to dist/:

simple-gal build

Override the input and output directories with flags:

simple-gal build --source photos --output public

This processes all images and generates the complete static site in the output directory.

CLI commands

CommandWhat it does
simple-gal buildRun the full pipeline: scan, process images, generate HTML
simple-gal scanScan the content directory and print the manifest (no image processing or HTML output)
simple-gal processScan and process images (generate responsive sizes and thumbnails) without generating HTML
simple-gal generateScan, process, and generate HTML (same as build)
simple-gal gen-configPrint a fully-commented config.toml with all stock defaults

The individual stage commands (scan, process) are useful for debugging. In normal use, build is all you need.

Generating a starter config

To see every available configuration option with its default value:

simple-gal gen-config > content/config.toml

Edit the generated file to keep only the settings you want to customize. See Configuration Overview for details on how config merging works.

Previewing locally

The output in dist/ is a static site. Any local HTTP server will work for previewing. A few options:

# Python (built into macOS and most Linux)
python3 -m http.server --directory dist 8000

# Node.js
npx serve dist

# PHP
php -S localhost:8000 -t dist

Then open http://localhost:8000 in your browser.

Note: PWA features (service worker, offline mode) require HTTPS in production, but work over localhost during development.

GitHub Actions

The simple-gal-action GitHub Action builds your site in CI without installing anything locally. It downloads the Simple Gal binary, runs the build, and produces a dist/ directory ready for deployment.

Full workflow: build and deploy to GitHub Pages

Create .github/workflows/deploy.yml in your repository:

name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

  # Allow manual trigger from the Actions tab
  workflow_dispatch:

# Required permissions for GitHub Pages deployment
permissions:
  contents: read
  pages: write
  id-token: write

# Prevent concurrent deployments
concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build site
        uses: arthur-debert/simple-gal-action@v1

      - name: Upload Pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: dist

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

This workflow:

  1. Triggers on every push to main (and can be triggered manually).
  2. Checks out your repository.
  3. Runs simple-gal build via the action, producing dist/.
  4. Uploads dist/ as a GitHub Pages artifact.
  5. Deploys to GitHub Pages.

GitHub Pages setup

Before the workflow can deploy, enable GitHub Pages in your repository settings:

  1. Go to Settings > Pages.
  2. Under Source, select GitHub Actions.

That is all. The workflow handles the rest.

Custom domain

To use a custom domain (e.g., photos.example.com):

  1. In your repository settings under Pages > Custom domain, enter your domain.
  2. Add a CNAME file to the root of your content directory containing just the domain name:
photos.example.com
  1. Configure DNS with your domain registrar -- a CNAME record pointing to <username>.github.io.

GitHub will provision an SSL certificate automatically.

Custom source directory

If your content is not in the default content/ directory, pass options to the action:

- name: Build site
  uses: arthur-debert/simple-gal-action@v1
  with:
    source: photos
    output: dist

Caching

Image processing is the slowest step in a build. The action output (dist/) includes processed images, so subsequent builds that use a cache of the output directory can skip reprocessing unchanged images. GitHub's actions/cache can help here if build times become a concern.

Hosting

Simple Gal produces a self-contained static site in dist/. There is no server-side processing, no database, no runtime. Any service that can serve files over HTTP will work.

GitHub Pages

The easiest option if your source is already on GitHub. See GitHub Actions for a complete workflow that builds and deploys automatically.

For manual deployment, push the contents of dist/ to a gh-pages branch:

# Build locally
simple-gal build

# Deploy to gh-pages branch
npx gh-pages -d dist

Then set the Pages source to the gh-pages branch in your repository settings.

Netlify

Connect your GitHub repository and configure the build:

SettingValue
Build command(leave blank -- use a GitHub Action to build, or install simple-gal in a build plugin)
Publish directorydist

The simplest approach is to build with GitHub Actions and deploy the dist/ directory. Alternatively, commit the built dist/ directory and point Netlify at it directly.

For drag-and-drop deployment without any repository, use the Netlify CLI:

simple-gal build
npx netlify-cli deploy --dir dist --prod

Vercel

Similar to Netlify. Connect your repository and set the output directory to dist.

For manual deployment:

simple-gal build
npx vercel --prod dist

Vercel will serve the static files without any framework configuration.

Amazon S3 + CloudFront

For AWS hosting:

# Build the site
simple-gal build

# Sync to S3
aws s3 sync dist/ s3://your-bucket-name --delete

# Invalidate CloudFront cache (if using a distribution)
aws cloudfront create-invalidation \
  --distribution-id YOUR_DIST_ID \
  --paths "/*"

Configure the S3 bucket for static website hosting and point CloudFront at it for HTTPS and caching.

Nginx

Serve dist/ directly. A minimal configuration:

server {
    listen 80;
    server_name photos.example.com;
    root /var/www/photos;

    location / {
        try_files $uri $uri/index.html =404;
    }
}

Copy the built site to the server:

simple-gal build
rsync -avz dist/ user@server:/var/www/photos/

Apache

Enable mod_dir (usually on by default) and point the document root at the output directory:

<VirtualHost *:80>
    ServerName photos.example.com
    DocumentRoot /var/www/photos

    <Directory /var/www/photos>
        Options -Indexes
        AllowOverride None
        Require all granted
    </Directory>
</VirtualHost>

General advice

  • HTTPS is required for PWA features. The service worker will not register over plain HTTP (except on localhost). Most hosting services provide free SSL certificates.
  • No special server rules needed. Simple Gal generates clean index.html files in each directory, so standard static file serving works without URL rewriting.
  • Cache headers are optional. The service worker handles caching on the client side. If you want to set server-side cache headers, long cache times on image files (/images/**) are safe since filenames include content hashes.

The Forever Stack

Simple Gal is designed to produce gallery sites that work 20 years from now. This page explains what that claim rests on, what could break it, and what we give up to make it credible.

The claim

If you run simple-gal build today and put the output on a file server, someone should be able to open that site in a browser in 2045 and see your photographs exactly as you intended. No software updates required. No server migration. No database restoration. Just files served over HTTP.

Why we believe it

The output of Simple Gal consists of:

  • HTML -- the foundational document format of the web, backward-compatible since the 1990s. Simple Gal uses basic, well-established elements. No custom elements, no Web Components, no framework-specific markup.
  • CSS -- inline styles and a single stylesheet using properties that have been stable for over a decade. No CSS-in-JS, no preprocessor, no build step.
  • AVIF images -- the output image format. AVIF is based on the AV1 video codec and backed by the Alliance for Open Media (Google, Apple, Mozilla, Microsoft, Netflix, Amazon). It is an ISO standard (ISO/IEC 23000-22). Browser support is universal in modern browsers.
  • ~30 lines of vanilla JavaScript -- for keyboard navigation and swipe gestures only. Click-based navigation is pure HTML anchor tags and CSS. If JavaScript stopped running entirely, you could still browse every photo by clicking.

There are no dependencies in the output. No node_modules, no CDN links, no third-party scripts, no API calls. The site is self-contained: a directory of files that reference only each other.

The tool itself

The generator is a single Rust binary with no runtime dependencies. It reads files from disk and writes files to disk. To use it, you need exactly one thing: the ability to run a compiled program.

Pre-built binaries are provided for major platforms. Even if the Rust toolchain disappeared tomorrow, the existing binaries would continue to work on any compatible OS. And since the input is just a directory of images and text files, you could always rebuild the output with a different tool if needed -- there is no lock-in to a proprietary data format.

What could break it

We should be honest about the assumptions:

Browsers drop AVIF support. This is the most format-specific risk. However, AVIF is an ISO standard backed by every major browser vendor, and the web has never dropped support for an image format once it reached universal adoption (GIF, JPEG, PNG, and WebP are all still supported decades after introduction). We consider this extremely unlikely.

HTML fundamentally changes. The <img>, <a>, and <div> elements that Simple Gal relies on have been stable since the mid-1990s. Browsers maintain backward compatibility with content from that era. A change that broke basic HTML would break a significant fraction of the existing web. This is not a realistic concern.

The file server goes away. Simple Gal generates static files, but someone still needs to serve them. If your hosting provider shuts down, you need to move the files. The good news is that static file hosting is the simplest, cheapest, most widely available form of web hosting. Moving a directory of files to a new server is a solved problem.

You lose the source files. Simple Gal does not replace your backup strategy. The generated site contains processed (resized, compressed) images, not your originals. If you lose the content directory, you lose the ability to rebuild at different settings. Back up your source files.

What we give up

The Forever Stack is a deliberate tradeoff: longevity and simplicity over features. Here is what Simple Gal does not do, and will not do:

  • No comments. Commenting systems require a server, a database, and moderation. They are a maintenance liability with a finite lifespan.
  • No search. Client-side search requires a JavaScript index that grows with the site. Server-side search requires infrastructure. Neither fits the model.
  • No infinite scroll. Infinite scroll depends on JavaScript for loading and layout. Page-based navigation is pure HTML and works without scripting.
  • No analytics (built in). Tracking visitors requires either JavaScript or server logs. Simple Gal does not inject tracking code. You can add your own via the assets/head.html injection point if you want it.
  • No image lazy-loading via JavaScript. The site uses native browser loading="lazy" attributes, which are HTML-only and require no scripting.

Each of these omissions removes a dependency, a maintenance burden, or a failure mode. The site does less, but what it does, it will keep doing.

The tradeoff in practice

If you need comments, search, e-commerce, or client proofing, Simple Gal is not the right tool. Use something designed for those features.

If you want a portfolio that shows your photographs in a clean, fast, controllable way -- and you want it to still work without intervention in 2035, 2040, 2045 -- then the constraints are the point. Every feature we didn't add is a thing that can't break.