diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 8004e10..732828b 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -4,6 +4,7 @@ import CommentReplyController from './controllers/comment_reply_controller.js'; import CopyTextController from './controllers/copy_text_controller.js'; import UserHighlightTooltipController from './controllers/user_highlight_tooltip_controller.js'; import NostrShareMenuController from './controllers/nostr_share_menu_controller.js'; +import ColorSchemeController from './controllers/color_scheme_controller.js'; const app = startStimulusApp(); if (typeof app.debug === 'boolean') { app.debug = false; @@ -35,3 +36,8 @@ try { } catch { /* already registered by the bundle */ } +try { + app.register('color-scheme', ColorSchemeController); +} catch { + /* already registered by the bundle */ +} diff --git a/assets/controllers/color_scheme_controller.js b/assets/controllers/color_scheme_controller.js new file mode 100644 index 0000000..d72aeec --- /dev/null +++ b/assets/controllers/color_scheme_controller.js @@ -0,0 +1,67 @@ +import { Controller } from '@hotwired/stimulus'; + +const STORAGE_KEY = 'unfold-color-scheme'; + +export default class extends Controller { + static targets = ['moon', 'sun']; + + connect() { + this.link = document.getElementById('theme-magazine-stylesheet'); + this._syncFromDom(); + this._refreshIcons(); + } + + toggle() { + const cur = document.documentElement.getAttribute('data-color-scheme') || 'light'; + const next = cur === 'dark' ? 'light' : 'dark'; + this.apply(next, true); + } + + /** + * @param {'light'|'dark'} scheme + * @param {boolean} persist + */ + apply(scheme, persist) { + const siteDefault = document.documentElement.getAttribute('data-color-scheme-default') || 'dark'; + if (scheme !== 'dark' && scheme !== 'light') { + scheme = siteDefault; + } + const darkHref = this.link?.getAttribute('data-href-dark'); + if (scheme === 'dark' && !darkHref) { + scheme = 'light'; + } + document.documentElement.setAttribute('data-color-scheme', scheme); + if (persist) { + try { + localStorage.setItem(STORAGE_KEY, scheme); + } catch (_) { + /* private mode */ + } + } + if (this.link) { + const light = this.link.getAttribute('data-href-light'); + this.link.setAttribute('href', scheme === 'dark' && darkHref ? darkHref : light); + } + this._refreshIcons(); + } + + _syncFromDom() { + /* Link href was set by inline script; icons follow current scheme. */ + this._refreshIcons(); + } + + _refreshIcons() { + const dark = document.documentElement.getAttribute('data-color-scheme') === 'dark'; + if (this.hasMoonTarget) { + this.moonTarget.hidden = dark; + } + if (this.hasSunTarget) { + this.sunTarget.hidden = !dark; + } + const btn = this.element.querySelector('.color-scheme-toggle'); + if (btn) { + btn.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode'); + btn.setAttribute('title', dark ? 'Light mode' : 'Dark mode'); + } + } +} diff --git a/assets/styles/layout.css b/assets/styles/layout.css index ccb88e3..502799a 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -1250,6 +1250,45 @@ footer { font-size: 0.95rem; } +.site-footer__color-scheme { + margin: 0.75rem 0 0; + display: flex; + justify-content: center; + align-items: center; +} + +.color-scheme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 9999px; + background: var(--color-bg-light); + color: var(--color-text-mid); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.color-scheme-toggle:hover { + background: var(--color-bg); + color: var(--color-primary); + border-color: var(--color-primary); +} + +.color-scheme-toggle:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.color-scheme-toggle svg { + width: 1.1rem; + height: 1.1rem; + flex-shrink: 0; +} + @media (min-width: 900px) { .site-footer { flex-direction: row; @@ -1271,6 +1310,10 @@ footer { .site-footer__legal { text-align: right; } + + .site-footer__main .site-footer__color-scheme { + justify-content: flex-end; + } } footer .footer-links { diff --git a/assets/theme/default/og-image.jpg b/assets/theme/default/og-image.jpg new file mode 100644 index 0000000..5dc6168 Binary files /dev/null and b/assets/theme/default/og-image.jpg differ diff --git a/assets/theme/default/theme-dark.css b/assets/theme/default/theme-dark.css new file mode 100644 index 0000000..96341ed --- /dev/null +++ b/assets/theme/default/theme-dark.css @@ -0,0 +1,62 @@ +/* + * imwald / magazine dark color scheme (second stylesheet). + * Loaded only when data-color-scheme="dark" and config theme_stylesheet_dark is set. + * Override tokens here; keep components using var(--color-…). + */ +html[data-color-scheme="dark"] { + /* Backgrounds */ + --color-bg: #1c1a18; + --color-bg-light: #262320; + --color-bg-primary: #2f2b27; + /* Text */ + --color-text: #e8e1d9; + --color-text-mid: #b7ada3; + --color-text-contrast: #1c1a18; + /* Accents (muted greens) */ + --color-primary: #8faf8f; + --color-secondary: #a8c3a8; + --color-primary-strong: #6f9a6f; + --color-border: #3a3632; + --color-border-soft: #2a2724; + --color-text-light: var(--color-text-mid); + --color-footer-bg: #1c1a18; + --color-footer-text: var(--color-text); + --color-footer-link: var(--color-primary); + --color-highlight-mark-fg: #1c1a18; + --color-link: #8faf8f; + --color-link-hover: #a8c3a8; + --color-link-visited: #7e9f7e; + --color-focus-ring: #8faf8f; + --color-shadow: color-mix(in srgb, #000 28%, transparent); + --brand-color: #e8e1d9; + --accent-color: var(--color-secondary); + /* Reading pane / headline stack: clearly lifted from --color-bg (88/12 was visually identical). */ + --article-reading-pane-bg: color-mix(in srgb, var(--color-bg) 28%, var(--color-bg-light) 72%); + --article-reading-prose-color: color-mix(in srgb, var(--color-text-mid) 38%, var(--color-text) 62%); +} + +html[data-color-scheme="dark"] a:visited { + color: var(--color-link-visited); +} + +html[data-color-scheme="dark"] .article-main p, +html[data-color-scheme="dark"] .article-main ul, +html[data-color-scheme="dark"] .article-main ol, +html[data-color-scheme="dark"] .article-main li { + font-weight: 450; +} + +html[data-color-scheme="dark"] .home-aside-highlights__item-inner { + border-left: 2px solid var(--color-primary); + padding-left: 0.5rem; + background: color-mix(in srgb, var(--color-bg-light) 55%, transparent); +} + +html[data-color-scheme="dark"] .home-aside-highlights__quote { + color: #d8d0c8; +} + +html[data-color-scheme="dark"] .home-aside-highlights__item-inner:hover, +html[data-color-scheme="dark"] .home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) { + background: #2f2b27; +} diff --git a/assets/theme/local/og-image.jpg b/assets/theme/local/og-image.jpg index 24df4f8..5dc6168 100644 Binary files a/assets/theme/local/og-image.jpg and b/assets/theme/local/og-image.jpg differ diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 71fac17..1b3d6db 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -8,6 +8,8 @@ twig: website_theme: '%theme%' website_theme_color: '%theme_color%' website_bg_color: '%theme_bg_color%' + website_theme_stylesheet_light: '%theme_stylesheet_light%' + website_theme_stylesheet_dark: '%theme_stylesheet_dark%' magazine_community_articles: '%community_articles%' when@test: diff --git a/config/unfold.yaml b/config/unfold.yaml index cfd4a9d..8eea8c6 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -1,4 +1,6 @@ # Site identity and theme (see assets/theme/local/ to override default assets). +# Magazine color tokens: theme_stylesheet_light + optional theme_stylesheet_dark (logical asset names). +# Use theme_stylesheet_dark: '' to disable the footer scheme toggle and load only the light sheet. parameters: # Per-relay WebSocket I/O (seconds) in NostrClient; also default_socket_timeout during app:prewarm. @@ -39,9 +41,15 @@ parameters: # - 'wss://nos.lol' # - 'wss://relay.ditto.pub' + # Magazine identity for data-theme=… (CSS hooks). Unrelated to light/dark color scheme. theme: 'imwald' theme_color: '#8c2f1c' theme_bg_color: '#f1ebe4' + # Per–color-scheme stylesheets: logical asset names under assets/theme/{local,default}/ (see asset_mapper paths). + # imwald: light = editorial cream tokens; dark = second sheet (warm charcoal). Set theme_stylesheet_dark to '' + # to ship only one magazine theme and hide the footer sun/moon control. + theme_stylesheet_light: 'theme.css' + theme_stylesheet_dark: 'theme-dark.css' npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' # Kind 30040 magazine root #d (NIP-33). Exposed as `d_tag` for backward compatibility. diff --git a/scripts/build-og-image.sh b/scripts/build-og-image.sh index 11e323e..c01f769 100755 --- a/scripts/build-og-image.sh +++ b/scripts/build-og-image.sh @@ -1,9 +1,7 @@ #!/usr/bin/env bash -# Regenerate assets/theme/local/og-image.jpg (1200×630, standard OG; JPEG for smaller file than PNG24). -# Layout: ~45% left = painting full-bleed (no card frame); ~55% right = warm panel + type. -# Soft gradient seam (no hard divider); headline + subheading + description from config/unfold.yaml. -# Bottom-right: default theme mark (favicon) at reduced opacity. -# Requires: ImageMagick (convert), fold. Run from repository root: +# Regenerate default Open Graph JPEG (1200×630): dark editorial panel + left painting. +# Writes assets/theme/default/og-image.jpg (committed default) and assets/theme/local/og-image.jpg (local override). +# Requires: ImageMagick (convert), fold. From repository root: # ./scripts/build-og-image.sh set -euo pipefail @@ -13,20 +11,17 @@ cd "$ROOT" YAML="${ROOT}/config/unfold.yaml" LOGO="${ROOT}/assets/laeserin_logo.png" DEFAULT_MARK="${ROOT}/assets/theme/default/icons/favicon-96x96.png" -OUT="${ROOT}/assets/theme/local/og-image.jpg" +OUT_DEFAULT="${ROOT}/assets/theme/default/og-image.jpg" +OUT_LOCAL="${ROOT}/assets/theme/local/og-image.jpg" FONT_TITLE="/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" FONT_SUB="/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" FONT_BODY="/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" W=1200 H=630 -# Safe inset for *type* and corner mark (previews crop edges); painting still bleeds left. PAD=48 -# Left column width (45%). LW=$((W * 45 / 100)) -# Text starts after seam blend region. TEXT_X=618 -# Vertical start for headline (shifted down for balance; sub/body computed from headline lines). TITLE_Y=108 if [[ ! -f "$LOGO" ]]; then @@ -51,7 +46,7 @@ if [[ -z "$NAME" || -z "$DESC" ]]; then exit 1 fi -mkdir -p "$(dirname "$OUT")" +mkdir -p "$(dirname "$OUT_DEFAULT")" "$(dirname "$OUT_LOCAL")" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT @@ -62,60 +57,54 @@ DESC_MULTILINE=$(printf '%s\n' "${_OG_LINES[@]}") mapfile -t _HL_LINES < <(fold -s -w 22 <<<"$HEADLINE") HEAD_MULTILINE=$(printf '%s\n' "${_HL_LINES[@]}") -# Right panel: deeper warm linen (reads better on dark-mode surfaces than very pale beige). -CANVAS='#d8cdc0' +# Dark editorial right panel (warm charcoal, not pure black). +CANVAS='#1c1a18' convert -size "${W}x${H}" xc:"$CANVAS" "$TMP/canvas.png" -# Left: full-bleed cover crop; richer reds / saturation, slightly darker mids for depth. +# Left: painting — darker, slightly desaturated so it reads as “lamplight” beside the panel. convert "$LOGO" -fuzz 8% -trim +repage \ -resize "${LW}x${H}^" -gravity center -extent "${LW}x${H}" \ - -gamma 0.96 -modulate 93,138,104 -unsharp 0x0.6+0.52+0.014 \ + -gamma 1.05 -modulate 78,92,98 -unsharp 0x0.5+0.45+0.012 \ "$TMP/left.png" convert "$TMP/canvas.png" "$TMP/left.png" -geometry +0+0 -compose over -composite "$TMP/base.png" -# Full-width wash: transparent at the far left → gentle panel tone on the right. -# Horizontal blur removes a visible “stripe” at the old painting/canvas boundary (LW). -# Text is drawn after this step so it stays crisp on top. -# Lighter wash than before so bodice reds survive; tint anchors toward the darker canvas. -convert -size "${W}x${H}" 'gradient:rgba(216,205,192,0)-rgba(175,158,142,0.26)' "$TMP/seam_raw.png" -# Blur mostly horizontally (rotate trick) so the ramp has no sharp column. -convert "$TMP/seam_raw.png" -rotate 90 -blur 0x42 -rotate -90 "$TMP/seam_blur.png" +# Seam: transparent at left → soft lift into panel (#262320). +convert -size "${W}x${H}" 'gradient:rgba(28,26,24,0)-rgba(38,35,32,0.55)' "$TMP/seam_raw.png" +convert "$TMP/seam_raw.png" -rotate 90 -blur 0x38 -rotate -90 "$TMP/seam_blur.png" convert "$TMP/base.png" "$TMP/seam_blur.png" -compose over -composite "$TMP/blended.png" -# Stack subheading / body under multi-line headline. HL_COUNT=${#_HL_LINES[@]} LINE_H=58 SUB_Y=$((TITLE_Y + HL_COUNT * LINE_H + 20)) BODY_Y=$((SUB_Y + 42)) -# Type colours tuned for the darker panel. +# Type: warm off-white headline, sage sub, muted body (matches theme-dark.css tokens). convert "$TMP/blended.png" \ - -font "$FONT_TITLE" -pointsize 52 -fill '#14100e' -annotate +${TEXT_X}+${TITLE_Y} "$HEAD_MULTILINE" \ - -font "$FONT_SUB" -pointsize 27 -fill '#6e2218' -annotate +${TEXT_X}+${SUB_Y} "$SUBHEAD" \ - -font "$FONT_BODY" -pointsize 23 -fill '#2a221c' -interline-spacing 6 \ + -font "$FONT_TITLE" -pointsize 52 -fill '#e8e1d9' -annotate +${TEXT_X}+${TITLE_Y} "$HEAD_MULTILINE" \ + -font "$FONT_SUB" -pointsize 27 -fill '#8faf8f' -annotate +${TEXT_X}+${SUB_Y} "$SUBHEAD" \ + -font "$FONT_BODY" -pointsize 23 -fill '#b7ada3' -interline-spacing 6 \ -annotate +${TEXT_X}+${BODY_Y} "$DESC_MULTILINE" \ PNG24:"$TMP/with_type.png" -# Default newsroom mark — tinted, low opacity so it belongs in the corner. if [[ -f "$DEFAULT_MARK" ]]; then - # Circular mark (soft vs a square stamp on the OG card). convert "$DEFAULT_MARK" -resize 88x88 \ \( -size 88x88 xc:none -fill white -draw "circle 44,44 44,0" \) \ -compose DstIn -composite -alpha set \ - -modulate 105,85,95 \ - -channel A -evaluate multiply 0.48 +channel \ + -modulate 110,90,98 \ + -channel A -evaluate multiply 0.42 +channel \ "$TMP/mark.png" convert "$TMP/with_type.png" \( "$TMP/mark.png" \) -gravity SouthEast -geometry +${PAD}+${PAD} -compose over -composite "$TMP/marked.png" else cp "$TMP/with_type.png" "$TMP/marked.png" fi -# Global: a touch darker + more saturated so the whole card holds up on dark UI chrome. -# JPEG (not PNG24): social previews are fine with lossy compression; much smaller on-disk. -convert "$TMP/marked.png" -modulate 96,118,102 -unsharp 0x0.55+0.48+0.012 \ - -quality 86 -sampling-factor 4:2:0 -strip "$OUT" +# Slight depth; keep warmth (not neon). +convert "$TMP/marked.png" -modulate 98,105,100 -unsharp 0x0.5+0.42+0.01 \ + -quality 86 -sampling-factor 4:2:0 -strip "$OUT_DEFAULT" + +cp "$OUT_DEFAULT" "$OUT_LOCAL" rm -f "${ROOT}/assets/theme/local/og-image.png" -echo "Wrote $OUT ($(wc -c <"$OUT") bytes)" +echo "Wrote $OUT_DEFAULT and $OUT_LOCAL ($(wc -c <"$OUT_DEFAULT") bytes each)" diff --git a/templates/base.html.twig b/templates/base.html.twig index c08887f..96ed545 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -1,6 +1,28 @@ +{% set _theme_dark_cfg = website_theme_stylesheet_dark|default('')|trim %} +{% set _color_scheme_site_default = _theme_dark_cfg != '' ? 'dark' : 'light' %} - + + {# Apply saved color scheme before first paint; site default is dark when a dark magazine sheet is configured. #} + {% block title %}{{ website_name }}{% endblock %} @@ -26,7 +48,39 @@ {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} {% block stylesheets %} - + {% set _theme_light = website_theme_stylesheet_light|default('theme.css') %} + {% set _theme_dark = _theme_dark_cfg %} + + {% if _theme_dark != '' %} + + {% endif %} {% endblock %} diff --git a/templates/components/Footer.html.twig b/templates/components/Footer.html.twig index bb58eb4..99956a5 100644 --- a/templates/components/Footer.html.twig +++ b/templates/components/Footer.html.twig @@ -1,4 +1,9 @@ -