summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 10:15:42 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 10:15:42 +0300
commitb84647c749dabdc011f62d8e8213743fd03f565b (patch)
tree05c076cd2033ef99ce0f0f8b058ef0736b8642ec
parent6945a0ae9afb18313636c09f34959ee5b225f78d (diff)
Add large 2x2 feature tiles and a CSS-grid overview
Allow a single photo to be blown up into a large "feature" tile that spans a 2x2 block of the album overview, controlled by a new THUMB_FEATURE_PERCENT (0-100, default 10; 0 disables). Each tile rolls for a feature first, then for a subdivision, otherwise stays a normal square. To pack mixed-size tiles (normal 1x1, subdivided 1x1, feature 2x2) without gaps, the overview is now a real CSS grid with grid-auto-flow: dense, so smaller tiles backfill the holes a 2x2 feature would leave. Tile spacing moved from per-image padding to the grid gap. Feature tiles reuse the img.thumb class and its dramatic hover. THUMB_FEATURE_PERCENT is wired through the same layers as THUMB_SUBDIVIDE_PERCENT: config defaults, 0..100 validation, --print-config, the --feature CLI flag, the usage text, and the shuriken.json / --dry-run metadata, with docs and tests updated. Generated HTML and CSS pass the W3C Nu HTML checker and CSS validator. Setting both percentages to 0 reproduces the previous all-1x1 grid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rwxr-xr-xbin/shuriken64
-rw-r--r--docs/configuration.md6
-rw-r--r--docs/generation.md4
-rw-r--r--docs/usage.md1
-rw-r--r--share/templates/default/header.tmpl71
-rw-r--r--share/templates/default/previewpage.tmpl2
-rw-r--r--src/lib/album-metadata.source.sh5
-rw-r--r--src/lib/album-render.source.sh50
-rw-r--r--src/lib/bootstrap.source.sh1
-rw-r--r--src/lib/config.print.source.sh1
-rw-r--r--src/lib/config.source.sh4
-rw-r--r--src/lib/config.validate.source.sh1
-rw-r--r--src/shuriken.default.conf5
-rwxr-xr-xsrc/shuriken.sh2
-rwxr-xr-xtests/cli.sh9
15 files changed, 169 insertions, 57 deletions
diff --git a/bin/shuriken b/bin/shuriken
index 7727cde..fb2e4df 100755
--- a/bin/shuriken
+++ b/bin/shuriken
@@ -38,6 +38,7 @@ declare -ra CLI_CONFIG_OVERRIDE_TARGETS=(
THUMBHEIGHT
MAXPREVIEWS
THUMB_SUBDIVIDE_PERCENT
+ THUMB_FEATURE_PERCENT
IMAGE_JOBS
RANDOM_SEED
SHUFFLE
@@ -59,6 +60,7 @@ declare -Ar CLI_OPTION_SPEC=(
[--thumbheight]='kind=value config=THUMBHEIGHT'
[--maxpreviews]='kind=value config=MAXPREVIEWS'
[--subdivide]='kind=value config=THUMB_SUBDIVIDE_PERCENT'
+ [--feature]='kind=value config=THUMB_FEATURE_PERCENT'
[--image-jobs]='kind=value config=IMAGE_JOBS'
[--random-seed]='kind=value config=RANDOM_SEED'
[--shuffle]='kind=flag value=yes config=SHUFFLE'
@@ -154,6 +156,7 @@ usage() {
--thumbheight VALUE
--maxpreviews N
--subdivide PERCENT
+ --feature PERCENT
--image-jobs N
--random-seed VALUE
--splash
@@ -2203,6 +2206,7 @@ _collect_generation_metadata() {
_GENERATION_METADATA["settings_thumbheight"]="$THUMBHEIGHT"
_GENERATION_METADATA["settings_maxpreviews"]="$MAXPREVIEWS"
_GENERATION_METADATA["settings_subdivide_percent"]="$THUMB_SUBDIVIDE_PERCENT"
+ _GENERATION_METADATA["settings_feature_percent"]="$THUMB_FEATURE_PERCENT"
_GENERATION_METADATA["settings_image_jobs"]="$IMAGE_JOBS"
_GENERATION_METADATA["settings_random_seed"]="$RANDOM_SEED"
_GENERATION_METADATA["settings_shuffle"]="$SHUFFLE"
@@ -2260,6 +2264,8 @@ _generation_metadata_json() {
"$(_json_string "${_GENERATION_METADATA["settings_maxpreviews"]}")"
printf ' "subdivide_percent": %s,\n' \
"$(_json_string "${_GENERATION_METADATA["settings_subdivide_percent"]}")"
+ printf ' "feature_percent": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_feature_percent"]}")"
printf ' "image_jobs": %s,\n' \
"$(_json_string "${_GENERATION_METADATA["settings_image_jobs"]}")"
printf ' "random_seed": %s,\n' \
@@ -2342,6 +2348,7 @@ collect_dry_run_plan() {
plan_ref["thumbheight"]="$THUMBHEIGHT"
plan_ref["maxpreviews"]="$MAXPREVIEWS"
plan_ref["subdivide_percent"]="$THUMB_SUBDIVIDE_PERCENT"
+ plan_ref["feature_percent"]="$THUMB_FEATURE_PERCENT"
plan_ref["image_jobs"]="$IMAGE_JOBS"
plan_ref["random_seed"]="$RANDOM_SEED"
plan_ref["shuffle"]="$SHUFFLE"
@@ -2373,6 +2380,7 @@ print_dry_run_plan() {
printf 'Thumb height: %s\n' "${plan_ref["thumbheight"]}"
printf 'Max previews per page: %s\n' "${plan_ref["maxpreviews"]}"
printf 'Subdivide percent: %s\n' "${plan_ref["subdivide_percent"]}"
+ printf 'Feature percent: %s\n' "${plan_ref["feature_percent"]}"
printf 'Image jobs: %s\n' "${plan_ref["image_jobs"]}"
printf 'Random seed: %s\n' "${plan_ref["random_seed"]}"
printf 'Shuffle: %s\n' "${plan_ref["shuffle"]}"
@@ -2649,11 +2657,13 @@ append_preview_grid() {
done
}
-# Decide the layout for the next tile, printing "<layout> <photo-count>". A tile
-# is subdivided with THUMB_SUBDIVIDE_PERCENT probability, but only into a layout
-# that fits the photos still remaining on the page; otherwise it is a single
-# square thumbnail. Randomness reuses the seeded random_index, so builds are
-# reproducible when RANDOM_SEED is set (and varied otherwise).
+# Decide the layout for the next tile, printing "<layout> <photo-count>". Each
+# tile rolls first for a "feature" (one photo blown up to a 2x2 hero tile) with
+# THUMB_FEATURE_PERCENT probability, then for a subdivision with
+# THUMB_SUBDIVIDE_PERCENT probability (only into a layout that fits the photos
+# still remaining on the page); otherwise it is a single square thumbnail. The
+# feature and subdivide rolls use independent seeded random_index namespaces, so
+# builds stay reproducible when RANDOM_SEED is set (and varied otherwise).
tile_layout_for() {
local -ri remaining="$1"; shift
local -r context="$1"; shift
@@ -2661,6 +2671,14 @@ tile_layout_for() {
local -a counts=(2)
local -i roll choice
+ # A feature tile always fits (it consumes a single photo), so roll for it
+ # first. THUMB_FEATURE_PERCENT == 0 disables it (the roll can never be < 0).
+ roll=$(random_index "feature:$context" 100)
+ if (( roll < THUMB_FEATURE_PERCENT )); then
+ printf 'feature 1\n'
+ return
+ fi
+
# A single tile when subdivision is disabled, too few photos remain to fill
# even the smallest subdivided layout (two_wide needs 2), or the roll misses.
if (( remaining < 2 || THUMB_SUBDIVIDE_PERCENT == 0 )); then
@@ -2688,9 +2706,11 @@ tile_layout_for() {
}
# Render one tile's markup (no trailing newline). A "single" tile is the plain
-# square thumbnail, byte-identical to the previous per-thumbnail output; any
-# other layout is a subdivided tile. The tile's photos are the trailing args and
-# their preview numbers run from start_preview upward.
+# square thumbnail, byte-identical to the previous per-thumbnail output. A
+# "feature" tile is the same single thumbnail but with the 'feature' anchor class
+# that makes CSS span it across a 2x2 block. Any other layout is a subdivided
+# tile. The tile's photos are the trailing args and their preview numbers run
+# from start_preview upward.
build_tile_block() {
local -r thumbs_dir="$1"; shift
local -r backhref="$1"; shift
@@ -2700,13 +2720,21 @@ build_tile_block() {
local -a photos=("$@")
local animation_class
- if [ "$layout" = single ]; then
- animation_class=$(random_animation_css_class slow "${photos[0]}")
- build_preview_thumbnail \
- "$thumbs_dir" "$backhref" "$page_num" "$start_preview" \
- "${photos[0]}" "$animation_class"
- return
- fi
+ case "$layout" in
+ single|feature)
+ animation_class=$(random_animation_css_class slow "${photos[0]}")
+ # 'single' -> no anchor class (legacy output); 'feature' -> the
+ # 'feature' anchor class CSS spans across a 2x2 grid block.
+ local anchor_class=''
+ if [ "$layout" = feature ]; then
+ anchor_class='feature'
+ fi
+ build_preview_thumbnail \
+ "$thumbs_dir" "$backhref" "$page_num" "$start_preview" \
+ "${photos[0]}" "$animation_class" thumb "$anchor_class"
+ return
+ ;;
+ esac
build_subdivided_tile \
"$thumbs_dir" "$backhref" "$page_num" "$layout" "$start_preview" \
"${photos[@]}"
@@ -4846,8 +4874,10 @@ apply_config_defaults() {
SPLASH_PAGE="${SPLASH_PAGE:-yes}"
STATS_PAGE="${STATS_PAGE:-no}"
# Optional with a default (unlike the required THUMBHEIGHT): the percent
- # chance a preview tile is subdivided into smaller thumbnails. 0 disables it.
+ # chance a preview tile is subdivided into smaller thumbnails, and the
+ # percent chance it becomes a large 2x2 "feature" tile. 0 disables either.
THUMB_SUBDIVIDE_PERCENT="${THUMB_SUBDIVIDE_PERCENT:-30}"
+ THUMB_FEATURE_PERCENT="${THUMB_FEATURE_PERCENT:-10}"
SYNC_DELETE="${SYNC_DELETE:-yes}"
TARBALL_INCLUDE="${TARBALL_INCLUDE:-no}"
TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}"
@@ -4897,6 +4927,7 @@ print_config() {
print_shell_assignment THUMBHEIGHT "$THUMBHEIGHT"
print_shell_assignment MAXPREVIEWS "$MAXPREVIEWS"
print_shell_assignment THUMB_SUBDIVIDE_PERCENT "$THUMB_SUBDIVIDE_PERCENT"
+ print_shell_assignment THUMB_FEATURE_PERCENT "$THUMB_FEATURE_PERCENT"
print_shell_assignment IMAGE_JOBS "$IMAGE_JOBS"
print_shell_assignment IMAGEMAGICK_TIMEOUT "$IMAGEMAGICK_TIMEOUT"
print_shell_assignment RANDOM_SEED "$RANDOM_SEED"
@@ -5486,6 +5517,7 @@ validate_common_config() {
validate_positive_integer_config_var THUMBHEIGHT || return
validate_positive_integer_config_var MAXPREVIEWS || return
validate_percentage_config_var THUMB_SUBDIVIDE_PERCENT || return
+ validate_percentage_config_var THUMB_FEATURE_PERCENT || return
validate_positive_integer_config_var IMAGE_JOBS || return
validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT || return
validate_positive_integer_config_var TAR_TIMEOUT || return
diff --git a/docs/configuration.md b/docs/configuration.md
index 5d61a27..69a8835 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -14,6 +14,7 @@ values for the current run.
| `THUMBHEIGHT` | `300` | Thumbnail height in pixels. Positive integer. |
| `MAXPREVIEWS` | `40` | Maximum previews per page. Positive integer. |
| `THUMB_SUBDIVIDE_PERCENT` | `30` | Percent chance (0-100) that a preview tile is subdivided into smaller thumbnails (2x2 quad, two stacked wide strips, or two squares plus one wide strip on top/bottom). Each sub-thumbnail is its own clickable photo. `0` disables it. |
+| `THUMB_FEATURE_PERCENT` | `10` | Percent chance (0-100) that a preview tile is a large "feature" tile: a single photo spanning a 2x2 block of the overview grid. Rolled before the subdivision chance. `0` disables it. |
| `IMAGE_JOBS` | `3` | Parallel jobs for image processing and HTML template rendering. Positive integer. |
| `IMAGEMAGICK_TIMEOUT` | `60` | Per-ImageMagick-command timeout in seconds. Positive integer. |
| `TAR_TIMEOUT` | `120` | Tarball creation timeout in seconds. Positive integer. |
@@ -54,7 +55,8 @@ The checks (details in `src/lib/config.validate.source.sh`):
`IMAGE_JOBS`, `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`.
* **Positive integers**: `THUMBHEIGHT`, `MAXPREVIEWS`, `IMAGE_JOBS`,
`IMAGEMAGICK_TIMEOUT`, `TAR_TIMEOUT`; `HEIGHT` is an optional positive integer.
-* **Percentage (0-100 integer)**: `THUMB_SUBDIVIDE_PERCENT`.
+* **Percentage (0-100 integer)**: `THUMB_SUBDIVIDE_PERCENT`,
+ `THUMB_FEATURE_PERCENT`.
* **`yes`/`no` settings**: `SHUFFLE`, `SPLASH_PAGE`, `STATS_PAGE`,
`TARBALL_INCLUDE`, `SYNC_DELETE` (where applicable).
* **Readable input**: `INCOMING_DIR` must be a readable directory; `TEMPLATE_DIR`
@@ -77,7 +79,7 @@ Generation stops before writing album output when validation fails.
`CONFIG_SOURCE`, `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`, `FAVICON`,
`SOURCE_URL`, `TITLE`, `HEIGHT`, `THUMBHEIGHT`, `MAXPREVIEWS`,
-`THUMB_SUBDIVIDE_PERCENT`, `IMAGE_JOBS`,
+`THUMB_SUBDIVIDE_PERCENT`, `THUMB_FEATURE_PERCENT`, `IMAGE_JOBS`,
`IMAGEMAGICK_TIMEOUT`, `RANDOM_SEED`, `SHUFFLE`, `SPLASH_PAGE`, `STATS_PAGE`,
`TARBALL_INCLUDE`, `TARBALL_SUFFIX`, `TAR_TIMEOUT`, `TAR_OPTS`, `SYNC_DELETE`,
`SYNC_DESTINATIONS`, `ORIGINAL_BASEPATH`.
diff --git a/docs/generation.md b/docs/generation.md
index b89cafc..4d29840 100644
--- a/docs/generation.md
+++ b/docs/generation.md
@@ -97,8 +97,8 @@ metadata records:
* generated photo, thumbnail, and HTML file counts;
* tarball status (included + file);
* effective settings (title, height, thumbheight, maxpreviews, subdivide
- percent, image jobs, random seed, shuffle, splash page, stats page, original
- basepath) useful for debugging a published album.
+ percent, feature percent, image jobs, random seed, shuffle, splash page, stats
+ page, original basepath) useful for debugging a published album.
## Favicon
diff --git a/docs/usage.md b/docs/usage.md
index f4c8d27..3f85471 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -69,6 +69,7 @@ config variable documented in [configuration.md](configuration.md).
| `--thumbheight VALUE` | `THUMBHEIGHT` |
| `--maxpreviews N` | `MAXPREVIEWS` |
| `--subdivide PERCENT` | `THUMB_SUBDIVIDE_PERCENT` |
+| `--feature PERCENT` | `THUMB_FEATURE_PERCENT` |
| `--image-jobs N` | `IMAGE_JOBS` |
| `--random-seed VALUE` | `RANDOM_SEED` |
| `--shuffle` | `SHUFFLE=yes` |
diff --git a/share/templates/default/header.tmpl b/share/templates/default/header.tmpl
index 50a33a7..cf78513 100644
--- a/share/templates/default/header.tmpl
+++ b/share/templates/default/header.tmpl
@@ -189,21 +189,51 @@ cat <<END
vertical-align: top;
}
- /* A subdivided thumbnail tile: same outer footprint as one square thumb
- (including the 5px image padding), but a 2x2 grid packing several
- smaller thumbnails. Anchor order + the "wide" class (a full-width
- strip spanning both columns) produce the quad / two-wide / squares+
- wide layouts via grid auto-placement. */
+ /* The album overview is a CSS grid of fixed THUMBHEIGHT cells. A normal
+ thumbnail fills one cell, a subdivided tile packs several smaller
+ thumbnails into one cell, and a "feature" tile spans a 2x2 block (a
+ large hero image). grid-auto-flow: dense lets the smaller tiles
+ backfill the holes a 2x2 feature would otherwise leave, keeping the
+ grid tightly packed. Spacing comes from the grid gap, so images carry
+ no padding inside the grid. */
+ div.thumbs-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, ${render_thumbheight_html}px);
+ grid-auto-rows: ${render_thumbheight_html}px;
+ grid-auto-flow: dense;
+ gap: 10px;
+ justify-content: center;
+ margin: 0 auto;
+ }
+
+ div.thumbs-grid img {
+ padding: 0;
+ }
+
+ /* A feature tile: one photo blown up to span a 2x2 block of the grid. It
+ reuses the normal img.thumb class (and its hover) but stretches the
+ image to fill the larger 2x2 area. */
+ div.thumbs-grid a.feature {
+ grid-column: span 2;
+ grid-row: span 2;
+ }
+
+ div.thumbs-grid a.feature img.thumb {
+ width: 100%;
+ height: 100%;
+ }
+
+ /* A subdivided thumbnail tile fills one grid cell with its own 2x2 inner
+ grid packing several smaller thumbnails. Anchor order + the "wide"
+ class (a full-width strip spanning both columns) produce the quad /
+ two-wide / squares+wide layouts via grid auto-placement. */
div.tile {
- display: inline-grid;
+ display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 4px;
- width: ${render_thumbheight_html}px;
- height: ${render_thumbheight_html}px;
- padding: 5px;
- box-sizing: content-box;
- vertical-align: top;
+ width: 100%;
+ height: 100%;
}
div.tile a {
@@ -216,12 +246,12 @@ cat <<END
grid-column: span 2;
}
- /* Raise the hovered sub-thumbnail above its neighbours so its
- scaled/rotated "pop" effect (the shared img.thumb/img.subthumb:hover
- rule below) is painted on top of the adjacent grid cells, not under
- them. The sub-images fit their cells exactly, so there is no overflow
- to clip until a hover scales one up. */
- div.tile a:hover {
+ /* Raise whichever grid item is hovered (a normal/feature anchor, a whole
+ subdivided tile, or a single popping sub-thumbnail) above its
+ neighbours, so its scaled/rotated hover effect is painted on top of
+ the adjacent cells instead of under them. */
+ div.thumbs-grid a:hover,
+ div.thumbs-grid div.tile:hover {
position: relative;
z-index: 2;
}
@@ -240,10 +270,9 @@ cat <<END
margin-top: 12px;
}
- /* Sub-thumbnails get the same dramatic hover as the full square thumbs
- (mirror flip, scale-up, rotate, contrast/saturate boost, and a switch
- to object-fit:contain) so a subdivided tile feels as lively as a
- normal one. */
+ /* Sub-thumbnails and feature tiles get the same dramatic hover as the
+ full square thumbs (mirror flip, scale-up, rotate, contrast/saturate
+ boost, and a switch to object-fit:contain) so every tile feels lively. */
img.thumb:hover,
img.subthumb:hover {
outline: 4px solid #ffffff;
diff --git a/share/templates/default/previewpage.tmpl b/share/templates/default/previewpage.tmpl
index 9f42f84..7a3a72d 100644
--- a/share/templates/default/previewpage.tmpl
+++ b/share/templates/default/previewpage.tmpl
@@ -10,5 +10,7 @@
# and seeded animation classes). The surrounding header/footer still come from
# their own templates, which render_full_preview_page emits around this one.
cat <<END
+<div class="thumbs-grid">
${render_preview_thumbs_html}
+</div>
END
diff --git a/src/lib/album-metadata.source.sh b/src/lib/album-metadata.source.sh
index 3d38cbf..b74bf8f 100644
--- a/src/lib/album-metadata.source.sh
+++ b/src/lib/album-metadata.source.sh
@@ -210,6 +210,7 @@ _collect_generation_metadata() {
_GENERATION_METADATA["settings_thumbheight"]="$THUMBHEIGHT"
_GENERATION_METADATA["settings_maxpreviews"]="$MAXPREVIEWS"
_GENERATION_METADATA["settings_subdivide_percent"]="$THUMB_SUBDIVIDE_PERCENT"
+ _GENERATION_METADATA["settings_feature_percent"]="$THUMB_FEATURE_PERCENT"
_GENERATION_METADATA["settings_image_jobs"]="$IMAGE_JOBS"
_GENERATION_METADATA["settings_random_seed"]="$RANDOM_SEED"
_GENERATION_METADATA["settings_shuffle"]="$SHUFFLE"
@@ -267,6 +268,8 @@ _generation_metadata_json() {
"$(_json_string "${_GENERATION_METADATA["settings_maxpreviews"]}")"
printf ' "subdivide_percent": %s,\n' \
"$(_json_string "${_GENERATION_METADATA["settings_subdivide_percent"]}")"
+ printf ' "feature_percent": %s,\n' \
+ "$(_json_string "${_GENERATION_METADATA["settings_feature_percent"]}")"
printf ' "image_jobs": %s,\n' \
"$(_json_string "${_GENERATION_METADATA["settings_image_jobs"]}")"
printf ' "random_seed": %s,\n' \
@@ -349,6 +352,7 @@ collect_dry_run_plan() {
plan_ref["thumbheight"]="$THUMBHEIGHT"
plan_ref["maxpreviews"]="$MAXPREVIEWS"
plan_ref["subdivide_percent"]="$THUMB_SUBDIVIDE_PERCENT"
+ plan_ref["feature_percent"]="$THUMB_FEATURE_PERCENT"
plan_ref["image_jobs"]="$IMAGE_JOBS"
plan_ref["random_seed"]="$RANDOM_SEED"
plan_ref["shuffle"]="$SHUFFLE"
@@ -380,6 +384,7 @@ print_dry_run_plan() {
printf 'Thumb height: %s\n' "${plan_ref["thumbheight"]}"
printf 'Max previews per page: %s\n' "${plan_ref["maxpreviews"]}"
printf 'Subdivide percent: %s\n' "${plan_ref["subdivide_percent"]}"
+ printf 'Feature percent: %s\n' "${plan_ref["feature_percent"]}"
printf 'Image jobs: %s\n' "${plan_ref["image_jobs"]}"
printf 'Random seed: %s\n' "${plan_ref["random_seed"]}"
printf 'Shuffle: %s\n' "${plan_ref["shuffle"]}"
diff --git a/src/lib/album-render.source.sh b/src/lib/album-render.source.sh
index 63c0c57..d97d05f 100644
--- a/src/lib/album-render.source.sh
+++ b/src/lib/album-render.source.sh
@@ -220,11 +220,13 @@ append_preview_grid() {
done
}
-# Decide the layout for the next tile, printing "<layout> <photo-count>". A tile
-# is subdivided with THUMB_SUBDIVIDE_PERCENT probability, but only into a layout
-# that fits the photos still remaining on the page; otherwise it is a single
-# square thumbnail. Randomness reuses the seeded random_index, so builds are
-# reproducible when RANDOM_SEED is set (and varied otherwise).
+# Decide the layout for the next tile, printing "<layout> <photo-count>". Each
+# tile rolls first for a "feature" (one photo blown up to a 2x2 hero tile) with
+# THUMB_FEATURE_PERCENT probability, then for a subdivision with
+# THUMB_SUBDIVIDE_PERCENT probability (only into a layout that fits the photos
+# still remaining on the page); otherwise it is a single square thumbnail. The
+# feature and subdivide rolls use independent seeded random_index namespaces, so
+# builds stay reproducible when RANDOM_SEED is set (and varied otherwise).
tile_layout_for() {
local -ri remaining="$1"; shift
local -r context="$1"; shift
@@ -232,6 +234,14 @@ tile_layout_for() {
local -a counts=(2)
local -i roll choice
+ # A feature tile always fits (it consumes a single photo), so roll for it
+ # first. THUMB_FEATURE_PERCENT == 0 disables it (the roll can never be < 0).
+ roll=$(random_index "feature:$context" 100)
+ if (( roll < THUMB_FEATURE_PERCENT )); then
+ printf 'feature 1\n'
+ return
+ fi
+
# A single tile when subdivision is disabled, too few photos remain to fill
# even the smallest subdivided layout (two_wide needs 2), or the roll misses.
if (( remaining < 2 || THUMB_SUBDIVIDE_PERCENT == 0 )); then
@@ -259,9 +269,11 @@ tile_layout_for() {
}
# Render one tile's markup (no trailing newline). A "single" tile is the plain
-# square thumbnail, byte-identical to the previous per-thumbnail output; any
-# other layout is a subdivided tile. The tile's photos are the trailing args and
-# their preview numbers run from start_preview upward.
+# square thumbnail, byte-identical to the previous per-thumbnail output. A
+# "feature" tile is the same single thumbnail but with the 'feature' anchor class
+# that makes CSS span it across a 2x2 block. Any other layout is a subdivided
+# tile. The tile's photos are the trailing args and their preview numbers run
+# from start_preview upward.
build_tile_block() {
local -r thumbs_dir="$1"; shift
local -r backhref="$1"; shift
@@ -271,13 +283,21 @@ build_tile_block() {
local -a photos=("$@")
local animation_class
- if [ "$layout" = single ]; then
- animation_class=$(random_animation_css_class slow "${photos[0]}")
- build_preview_thumbnail \
- "$thumbs_dir" "$backhref" "$page_num" "$start_preview" \
- "${photos[0]}" "$animation_class"
- return
- fi
+ case "$layout" in
+ single|feature)
+ animation_class=$(random_animation_css_class slow "${photos[0]}")
+ # 'single' -> no anchor class (legacy output); 'feature' -> the
+ # 'feature' anchor class CSS spans across a 2x2 grid block.
+ local anchor_class=''
+ if [ "$layout" = feature ]; then
+ anchor_class='feature'
+ fi
+ build_preview_thumbnail \
+ "$thumbs_dir" "$backhref" "$page_num" "$start_preview" \
+ "${photos[0]}" "$animation_class" thumb "$anchor_class"
+ return
+ ;;
+ esac
build_subdivided_tile \
"$thumbs_dir" "$backhref" "$page_num" "$layout" "$start_preview" \
"${photos[@]}"
diff --git a/src/lib/bootstrap.source.sh b/src/lib/bootstrap.source.sh
index 7fa5ea3..66193e3 100644
--- a/src/lib/bootstrap.source.sh
+++ b/src/lib/bootstrap.source.sh
@@ -29,6 +29,7 @@ usage() {
--thumbheight VALUE
--maxpreviews N
--subdivide PERCENT
+ --feature PERCENT
--image-jobs N
--random-seed VALUE
--splash
diff --git a/src/lib/config.print.source.sh b/src/lib/config.print.source.sh
index 401d84c..62ff145 100644
--- a/src/lib/config.print.source.sh
+++ b/src/lib/config.print.source.sh
@@ -34,6 +34,7 @@ print_config() {
print_shell_assignment THUMBHEIGHT "$THUMBHEIGHT"
print_shell_assignment MAXPREVIEWS "$MAXPREVIEWS"
print_shell_assignment THUMB_SUBDIVIDE_PERCENT "$THUMB_SUBDIVIDE_PERCENT"
+ print_shell_assignment THUMB_FEATURE_PERCENT "$THUMB_FEATURE_PERCENT"
print_shell_assignment IMAGE_JOBS "$IMAGE_JOBS"
print_shell_assignment IMAGEMAGICK_TIMEOUT "$IMAGEMAGICK_TIMEOUT"
print_shell_assignment RANDOM_SEED "$RANDOM_SEED"
diff --git a/src/lib/config.source.sh b/src/lib/config.source.sh
index 538724f..67ddade 100644
--- a/src/lib/config.source.sh
+++ b/src/lib/config.source.sh
@@ -47,8 +47,10 @@ apply_config_defaults() {
SPLASH_PAGE="${SPLASH_PAGE:-yes}"
STATS_PAGE="${STATS_PAGE:-no}"
# Optional with a default (unlike the required THUMBHEIGHT): the percent
- # chance a preview tile is subdivided into smaller thumbnails. 0 disables it.
+ # chance a preview tile is subdivided into smaller thumbnails, and the
+ # percent chance it becomes a large 2x2 "feature" tile. 0 disables either.
THUMB_SUBDIVIDE_PERCENT="${THUMB_SUBDIVIDE_PERCENT:-30}"
+ THUMB_FEATURE_PERCENT="${THUMB_FEATURE_PERCENT:-10}"
SYNC_DELETE="${SYNC_DELETE:-yes}"
TARBALL_INCLUDE="${TARBALL_INCLUDE:-no}"
TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}"
diff --git a/src/lib/config.validate.source.sh b/src/lib/config.validate.source.sh
index e3c55e7..7b1421b 100644
--- a/src/lib/config.validate.source.sh
+++ b/src/lib/config.validate.source.sh
@@ -287,6 +287,7 @@ validate_common_config() {
validate_positive_integer_config_var THUMBHEIGHT || return
validate_positive_integer_config_var MAXPREVIEWS || return
validate_percentage_config_var THUMB_SUBDIVIDE_PERCENT || return
+ validate_percentage_config_var THUMB_FEATURE_PERCENT || return
validate_positive_integer_config_var IMAGE_JOBS || return
validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT || return
validate_positive_integer_config_var TAR_TIMEOUT || return
diff --git a/src/shuriken.default.conf b/src/shuriken.default.conf
index 737eb26..28da780 100644
--- a/src/shuriken.default.conf
+++ b/src/shuriken.default.conf
@@ -12,6 +12,11 @@ MAXPREVIEWS=40
# strip with the strip on top or bottom). Each sub-thumbnail is still its own
# clickable photo. 0 disables subdivision; 100 always subdivides.
THUMB_SUBDIVIDE_PERCENT=30
+# Percent chance (0-100) that a thumbnail tile is a large "feature" tile: a
+# single photo blown up to span a 2x2 block of the overview grid. Rolled before
+# the subdivision chance. 0 disables feature tiles; 100 makes every tile a
+# feature.
+THUMB_FEATURE_PERCENT=10
# Parallel jobs for image processing and HTML template rendering.
IMAGE_JOBS=3
# Timeout in seconds for each ImageMagick command.
diff --git a/src/shuriken.sh b/src/shuriken.sh
index 11f503a..418dfed 100755
--- a/src/shuriken.sh
+++ b/src/shuriken.sh
@@ -38,6 +38,7 @@ declare -ra CLI_CONFIG_OVERRIDE_TARGETS=(
THUMBHEIGHT
MAXPREVIEWS
THUMB_SUBDIVIDE_PERCENT
+ THUMB_FEATURE_PERCENT
IMAGE_JOBS
RANDOM_SEED
SHUFFLE
@@ -59,6 +60,7 @@ declare -Ar CLI_OPTION_SPEC=(
[--thumbheight]='kind=value config=THUMBHEIGHT'
[--maxpreviews]='kind=value config=MAXPREVIEWS'
[--subdivide]='kind=value config=THUMB_SUBDIVIDE_PERCENT'
+ [--feature]='kind=value config=THUMB_FEATURE_PERCENT'
[--image-jobs]='kind=value config=IMAGE_JOBS'
[--random-seed]='kind=value config=RANDOM_SEED'
[--shuffle]='kind=flag value=yes config=SHUFFLE'
diff --git a/tests/cli.sh b/tests/cli.sh
index 3181746..cc8b925 100755
--- a/tests/cli.sh
+++ b/tests/cli.sh
@@ -140,6 +140,7 @@ assert metadata["settings"]["height"] == "120"
assert metadata["settings"]["thumbheight"] == "30"
assert metadata["settings"]["maxpreviews"] == maxpreviews
assert metadata["settings"]["subdivide_percent"] == "30"
+assert metadata["settings"]["feature_percent"] == "10"
assert metadata["settings"]["image_jobs"] == "3"
assert metadata["settings"]["shuffle"] is False
assert isinstance(metadata["settings"]["splash_page"], bool)
@@ -1414,6 +1415,7 @@ HEIGHT=1200
THUMBHEIGHT=300
MAXPREVIEWS=40
THUMB_SUBDIVIDE_PERCENT=30
+THUMB_FEATURE_PERCENT=10
IMAGE_JOBS=3
IMAGEMAGICK_TIMEOUT=60
RANDOM_SEED=''
@@ -1464,6 +1466,7 @@ HEIGHT=''
THUMBHEIGHT=30
MAXPREVIEWS=40
THUMB_SUBDIVIDE_PERCENT=30
+THUMB_FEATURE_PERCENT=10
IMAGE_JOBS=3
IMAGEMAGICK_TIMEOUT=60
RANDOM_SEED=''
@@ -1607,6 +1610,7 @@ HEIGHT=120
THUMBHEIGHT=30
MAXPREVIEWS=7
THUMB_SUBDIVIDE_PERCENT=30
+THUMB_FEATURE_PERCENT=10
IMAGE_JOBS=3
IMAGEMAGICK_TIMEOUT=60
RANDOM_SEED=''
@@ -1652,6 +1656,7 @@ HEIGHT=120
THUMBHEIGHT=30
MAXPREVIEWS=8
THUMB_SUBDIVIDE_PERCENT=30
+THUMB_FEATURE_PERCENT=10
IMAGE_JOBS=3
IMAGEMAGICK_TIMEOUT=60
RANDOM_SEED=''
@@ -1705,6 +1710,7 @@ test_print_config_applies_cli_overrides_without_writes() {
--thumbheight 45 \
--maxpreviews 9 \
--subdivide 55 \
+ --feature 35 \
--image-jobs 2 \
--random-seed cli-seed \
--shuffle \
@@ -1723,6 +1729,7 @@ HEIGHT=456
THUMBHEIGHT=45
MAXPREVIEWS=9
THUMB_SUBDIVIDE_PERCENT=55
+THUMB_FEATURE_PERCENT=35
IMAGE_JOBS=2
IMAGEMAGICK_TIMEOUT=60
RANDOM_SEED=cli-seed
@@ -1940,6 +1947,7 @@ test_dry_run_reports_cli_overrides_without_writes() {
--thumbheight 45 \
--maxpreviews 2 \
--subdivide 25 \
+ --feature 15 \
--image-jobs 2 \
--random-seed dry-seed \
--shuffle \
@@ -1960,6 +1968,7 @@ test_dry_run_reports_cli_overrides_without_writes() {
test::assert_contains 'Thumb height: 45' "$output"
test::assert_contains 'Max previews per page: 2' "$output"
test::assert_contains 'Subdivide percent: 25' "$output"
+ test::assert_contains 'Feature percent: 15' "$output"
test::assert_contains 'Image jobs: 2' "$output"
test::assert_contains 'Random seed: dry-seed' "$output"
test::assert_contains 'Shuffle: yes' "$output"