feat: portfolio-led redesign with Shootings umbrella, original logo and motion layer

This commit is contained in:
desfoto automation
2026-09-19 17:11:25 +02:00
parent 2ccb39a69b
commit 447b909d05
177 changed files with 1853 additions and 773 deletions

View File

@@ -7,7 +7,17 @@ no runtime dependencies. `src/` holds Python builders and the editorial content,
`scripts/build-site.py` renders everything into the committed `site/` directory, `scripts/build-site.py` renders everything into the committed `site/` directory,
and nginx serves `site/` from a container behind the shared VPS Traefik instance. and nginx serves `site/` from a container behind the shared VPS Traefik instance.
`dennyschulz.de` is a **separate** site and must never be modified or linked from here. `dennyschulz.de` is a **separate** site: nothing here may modify it, proxy it or
hotlink its assets. Linking out to it from the footer is an explicit operator
request and is the only permitted reference (see "Editing rules").
## Structure
- `/` — hero, two pillars (**Shootings** = photo, **Videoprojekte** = video), portfolio
gallery, music video, mobiles Studio, process, facts.
- `/shootings/` — the umbrella for all photographic work: Businessfotografie,
Portrait & Model, Familien & Paare, Minishootings, plus the mobiles Studio.
`/fotografie` and `/fotografie/` are 301-redirected here by `nginx.conf`.
- `/video/`, `/social-media/`, `/projekte/` (music + free work + pro bono), `/ueber/`.
## Commands ## Commands
- `python3 scripts/fetch-assets.py` — download pool images, Google Fonts subsets and - `python3 scripts/fetch-assets.py` — download pool images, Google Fonts subsets and
@@ -27,10 +37,35 @@ and nginx serves `site/` from a container behind the shared VPS Traefik instance
- Never invent facts. Prices, testimonials, client names, awards and biography dates - Never invent facts. Prices, testimonials, client names, awards and biography dates
are intentionally absent; do not add them without verified information. Legal data are intentionally absent; do not add them without verified information. Legal data
in `src/content.py:LEGAL` mirrors the operator's own Impressum (`§ 5 DDG`). in `src/content.py:LEGAL` mirrors the operator's own Impressum (`§ 5 DDG`).
The portfolio must reflect the operator's real work (musicians, live shows, free
and artistic series, business shoots) — never invent trips, locations or jobs
(an earlier build captioned an image as a "Japanreise"; that was invented and must
not return).
- Keep the design language: paper/ink/rust/teal tokens from `src/theme.py`, rounded - Keep the design language: paper/ink/rust/teal tokens from `src/theme.py`, rounded
cards, hairline dividers, Fraunces for display and Manrope for text. cards, hairline dividers, Fraunces for display and Manrope for text.
- Images come from the operator's own pool through `src/images.json`. Only add an - Images come from the operator's own pool through `src/images.json`. Only add an
entry if the operator holds the rights; always give a meaningful German `alt`. entry if the operator holds the rights; always give a meaningful German `alt`.
`"source": "legacy"` marks images fetched from the operator's own site
(`dennyschulz.de/legacy/<file>`) — they are written into `assets-src/images/` and
never hotlinked at runtime. `alt` texts must come from the operator's own captions.
- The operator's original logo lives in `assets-src/brand/logo.png`; the build derives
`logo-160.png`, the favicons, the apple-touch icon and `favicon.svg` from it. Never
redraw or guess a replacement mark.
- The footer's "Netzwerk" column links to `https://www.dennyschulz.de/` and
`https://dennyapp.de/` because the operator asked for it; these are the only
external links allowed and they must keep `target="_blank" rel="noopener"`.
Nothing else may reference `dennyschulz.de` (no assets, no fetches, no rewriting).
- Animations are progressive enhancement: they live in CSS plus the guarded
`site.js` block, every page carries the `<noscript>` reveal fallback, and the
`prefers-reduced-motion` block must stay the **last** rule in `src/theme.py` so it
wins over the motion definitions.
- Never hide the element you observe: Chromium folds a target's own `clip-path` into
the `IntersectionObserver` geometry, so a closed mask never reports as visible and
the content stays hidden forever. `.reveal--mask` therefore clips its children.
- Never nest `<a>` inside `<a>` (or any other invalid nesting). The parser closes the
outer anchor and the surrounding grid falls apart — that is what happened to the
two homepage pillars. Cards with their own links use the stretched-link pattern
(`.pillar__go::after`); `tests/test_site.py` guards this for every page.
- Two-space indentation, `from __future__ import annotations`, type hints on public - Two-space indentation, `from __future__ import annotations`, type hints on public
functions, no new third-party Python packages (Pillow is the only dependency). functions, no new third-party Python packages (Pillow is the only dependency).

View File

@@ -65,6 +65,13 @@ server {
location = /datenschutz.html { return 301 /datenschutz/; } location = /datenschutz.html { return 301 /datenschutz/; }
location = /kontakt.html { return 301 /kontakt/; } location = /kontakt.html { return 301 /kontakt/; }
# Shootings is the umbrella term for the photographic work; the old
# /fotografie/ URL stays reachable and points to the new page.
# (/fotografie/index.html is already canonicalised to /fotografie/ above.)
location = /fotografie { return 301 /shootings/; }
location = /fotografie/ { return 301 /shootings/; }
location = /fotografie.html { return 301 /shootings/; }
location ^~ /.well-known/ { location ^~ /.well-known/ {
allow all; allow all;
autoindex off; autoindex off;

View File

@@ -12,7 +12,9 @@ Steps
from __future__ import annotations from __future__ import annotations
import base64
import datetime as dt import datetime as dt
import io
import json import json
import shutil import shutil
import sys import sys
@@ -27,6 +29,7 @@ SITE = ROOT / "site"
IMG_OUT = SITE / "assets" / "img" IMG_OUT = SITE / "assets" / "img"
FONT_OUT = SITE / "assets" / "fonts" FONT_OUT = SITE / "assets" / "fonts"
TTF = ASSETS / "fonts-ttf" TTF = ASSETS / "fonts-ttf"
LOGO_SRC = ASSETS / "brand" / "logo.png"
sys.path.insert(0, str(SRC)) sys.path.insert(0, str(SRC))
@@ -35,7 +38,7 @@ import pages # noqa: E402
from content import LEGAL, SITE as SITE_META, VIDEO # noqa: E402 from content import LEGAL, SITE as SITE_META, VIDEO # noqa: E402
from theme import CSS, TOKENS # noqa: E402 from theme import CSS, TOKENS # noqa: E402
WIDTHS = (480, 768, 1200, 1600, 2000) WIDTHS = (480, 768, 1200, 1600)
WEBP_QUALITY = 84 WEBP_QUALITY = 84
INK = (23, 20, 15) INK = (23, 20, 15)
@@ -44,16 +47,16 @@ ACCENT = (192, 67, 28)
# OpenGraph image per route, built from the page's own key visual. # OpenGraph image per route, built from the page's own key visual.
ROUTE_OG = { ROUTE_OG = {
"/": ("hero-studio", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."), "/": ("music-vocal-blue", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."),
"/fotografie/": ("editorial-earring", "og-fotografie.jpg", "Fotografie aus Neumünster"), "/shootings/": ("business-portrait-studio", "og-shootings.jpg", "Shootings aus Neumünster"),
"/businessfotografie/": ("business-portrait-studio", "og-businessfotografie.jpg", "Businessfotografie"), "/businessfotografie/": ("business-portrait-studio", "og-businessfotografie.jpg", "Businessfotografie"),
"/portrait-und-model/": ("editorial-colour", "og-portrait.jpg", "Portrait & Model"), "/portrait-und-model/": ("editorial-colour", "og-portrait.jpg", "Portrait & Model"),
"/familien-und-paare/": ("people-joy", "og-familien.jpg", "Familien & Paare"), "/familien-und-paare/": ("people-joy", "og-familien.jpg", "Familien & Paare"),
"/minishootings/": ("portrait-natural", "og-minishootings.jpg", "Minishootings"), "/minishootings/": ("portrait-natural", "og-minishootings.jpg", "Minishootings"),
"/video/": ("studio-lens", "og-video.jpg", "Videoprojekte"), "/video/": ("music-guitar-silhouette", "og-video.jpg", "Videoprojekte"),
"/social-media/": ("event-motion", "og-social-media.jpg", "Social-Media-Inhalte"), "/social-media/": ("event-motion", "og-social-media.jpg", "Social-Media-Inhalte"),
"/projekte/": ("travel-norway-pano", "og-projekte.jpg", "Eigene Projekte"), "/projekte/": ("free-mountain-view", "og-projekte.jpg", "Eigene Projekte"),
"/ueber/": ("business-portrait-dark", "og-ueber.jpg", "Über desfoto"), "/ueber/": ("studio-setup", "og-ueber.jpg", "Über desfoto"),
"/kontakt/": ("event-venue", "og-kontakt.jpg", "Kontakt"), "/kontakt/": ("event-venue", "og-kontakt.jpg", "Kontakt"),
"/impressum/": ("gear-cases", "og-impressum.jpg", "Impressum"), "/impressum/": ("gear-cases", "og-impressum.jpg", "Impressum"),
"/datenschutz/": ("event-venue", "og-datenschutz.jpg", "Datenschutz"), "/datenschutz/": ("event-venue", "og-datenschutz.jpg", "Datenschutz"),
@@ -159,59 +162,38 @@ def _font(name: str, size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(TTF / name), size) return ImageFont.truetype(str(TTF / name), size)
def draw_mark(size: int, background: bool = True) -> Image.Image: def load_logo() -> Image.Image:
"""The desfoto aperture mark, drawn deterministically with PIL.""" """The operator's own logo (as published on dennyschulz.de)."""
scale = 8 if not LOGO_SRC.exists():
s = size * scale raise SystemExit(f"missing brand asset {LOGO_SRC} - run scripts/fetch-assets.py")
img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) return Image.open(LOGO_SRC).convert("RGBA")
d = ImageDraw.Draw(img)
if background:
d.rounded_rectangle((0, 0, s - 1, s - 1), radius=int(s * 0.22), fill=INK + (255,))
cx = cy = s / 2
ring_w = max(2, int(s * 0.055))
d.ellipse(
(cx - s * 0.27, cy - s * 0.27, cx + s * 0.27, cy + s * 0.27),
outline=PAPER + (255,),
width=ring_w,
)
d.ellipse(
(cx - s * 0.105, cy - s * 0.105, cx + s * 0.105, cy + s * 0.105),
outline=PAPER + (255,),
width=ring_w,
)
blade_w = max(2, int(s * 0.058))
import math
for angle in (270, 30, 150):
rad = math.radians(angle)
x1 = cx + math.cos(rad) * s * 0.27
y1 = cy + math.sin(rad) * s * 0.27
x2 = cx + math.cos(rad) * s * 0.45
y2 = cy + math.sin(rad) * s * 0.45
d.line((x1, y1, x2, y2), fill=ACCENT + (255,), width=blade_w)
for (px, py) in ((x1, y1), (x2, y2)):
r = blade_w / 2
d.ellipse((px - r, py - r, px + r, py + r), fill=ACCENT + (255,))
return img.resize((size, size), Image.Resampling.LANCZOS)
def build_brand_assets(table: dict[str, dict]) -> None: def build_brand_assets(table: dict[str, dict]) -> None:
logo = load_logo()
# 1:1 copy of the operator's original logo for the header/footer mark.
logo.save(IMG_OUT / "logo-160.png", "PNG", optimize=True)
logo.resize((180, 180), Image.Resampling.LANCZOS).save(
IMG_OUT / "apple-touch-icon.png", "PNG", optimize=True
)
logo.resize((32, 32), Image.Resampling.LANCZOS).save(
IMG_OUT / "favicon-32.png", "PNG", optimize=True
)
logo.resize((16, 16), Image.Resampling.LANCZOS).save(
IMG_OUT / "favicon-16.png", "PNG", optimize=True
)
# Self-contained SVG favicon; the operator's own site uses the same trick.
buffer = io.BytesIO()
logo.save(buffer, "PNG", optimize=True)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
(SITE / "favicon.svg").write_text( (SITE / "favicon.svg").write_text(
"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="desfoto"> '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-label="desfoto">\n'
<rect width="64" height="64" rx="14" fill="#17140F"/> f' <image width="160" height="160" href="data:image/png;base64,{encoded}"/>\n'
<circle cx="32" cy="32" r="17" fill="none" stroke="#F6F2EA" stroke-width="3.4"/> "</svg>\n",
<circle cx="32" cy="32" r="6.6" fill="none" stroke="#F6F2EA" stroke-width="2.6"/>
<g stroke="#C0431C" stroke-width="3.6" stroke-linecap="round">
<path d="M32 15V6"/><path d="M46.7 40.5 54.4 45"/><path d="M17.3 40.5 9.6 45"/>
</g>
</svg>
""",
encoding="utf-8", encoding="utf-8",
) )
draw_mark(180).save(IMG_OUT / "apple-touch-icon.png") print(" [brand] logo-160.png, favicon.svg, apple-touch-icon.png, favicon-32.png")
draw_mark(64).save(IMG_OUT / "favicon-32.png")
draw_mark(64).resize((16, 16), Image.Resampling.LANCZOS).save(IMG_OUT / "favicon-16.png")
print(" [brand] favicon.svg, apple-touch-icon.png, favicon-32.png")
og_done = set() og_done = set()
for slug, filename, headline in ROUTE_OG.values(): for slug, filename, headline in ROUTE_OG.values():
@@ -219,6 +201,11 @@ def build_brand_assets(table: dict[str, dict]) -> None:
continue continue
og_done.add(filename) og_done.add(filename)
build_og(table, slug, filename, headline) build_og(table, slug, filename, headline)
# A renamed route would otherwise leave its old card behind forever.
for stale in sorted(IMG_OUT.glob("og-*.jpg")):
if stale.name not in og_done:
stale.unlink()
print(f" [brand] removed stale {stale.name}")
def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) -> None: def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) -> None:
@@ -249,8 +236,10 @@ def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) ->
brand_font = _font("fraunces-600.ttf", 92) brand_font = _font("fraunces-600.ttf", 92)
head_font = _font("fraunces-400.ttf", 54) head_font = _font("fraunces-400.ttf", 54)
small_font = _font("manrope-600.ttf", 26) small_font = _font("manrope-600.ttf", 26)
d.text((72, 74), "desfoto", font=brand_font, fill=PAPER) mark = load_logo().resize((102, 102), Image.Resampling.LANCZOS)
d.text((76, 186), "FOTOGRAFIE & VIDEO · NEUMÜNSTER", font=small_font, fill=(230, 161, 132)) source.paste(mark, (76, 62), mark)
d.text((198, 72), "desfoto", font=brand_font, fill=PAPER)
d.text((202, 186), "FOTOGRAFIE & VIDEO · NEUMÜNSTER", font=small_font, fill=(230, 161, 132))
lines = wrap_text(headline, head_font, target_w - 150) lines = wrap_text(headline, head_font, target_w - 150)
y = target_h - 90 - len(lines) * 64 y = target_h - 90 - len(lines) * 64
for line in lines: for line in lines:
@@ -421,22 +410,108 @@ def build_script() -> None:
}); });
} }
/* Soft reveal on scroll, disabled for reduced motion */ /* Motion layer: reveals, scroll progress, parallax and pointer spotlights.
Everything degrades to a static page and is disabled for reduced motion. */
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* Anything inside [data-stagger] reveals one item after another. */
Array.prototype.forEach.call(document.querySelectorAll('[data-stagger]'), function (group) {
var step = parseFloat(group.getAttribute('data-stagger')) || 0.08;
Array.prototype.forEach.call(group.children, function (child, index) {
if (!child.classList.contains('reveal')) {
child.classList.add('reveal');
}
child.style.setProperty('--d', (index * step).toFixed(2) + 's');
});
});
var items = document.querySelectorAll('.reveal'); var items = document.querySelectorAll('.reveal');
var revealAll = function () {
Array.prototype.forEach.call(items, function (item) { item.classList.add('is-in'); });
};
if (reduce || !('IntersectionObserver' in window)) { if (reduce || !('IntersectionObserver' in window)) {
items.forEach(function (item) { item.classList.add('is-in'); }); revealAll();
} else { } else {
var observer = new IntersectionObserver(function (entries) { try {
entries.forEach(function (entry) { var fired = 0;
if (entry.isIntersecting) { var observer = new IntersectionObserver(function (entries) {
entry.target.classList.add('is-in'); entries.forEach(function (entry) {
observer.unobserve(entry.target); if (entry.isIntersecting) {
fired = 1;
entry.target.classList.add('is-in');
observer.unobserve(entry.target);
}
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.06 });
Array.prototype.forEach.call(items, function (item) { observer.observe(item); });
/* Safety net: if the browser never reports a box that is on screen, drop the
effect entirely instead of leaving content hidden. */
window.setTimeout(function () {
if (fired) { return; }
var onScreen = false;
Array.prototype.forEach.call(items, function (item) {
var box = item.getBoundingClientRect();
if (box.top < window.innerHeight && box.bottom > 0) { onScreen = true; }
});
if (onScreen) {
observer.disconnect();
revealAll();
} }
}); }, 1600);
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 }); } catch (error) {
items.forEach(function (item) { observer.observe(item); }); revealAll();
}
} }
if (reduce) {
return;
}
var progress = document.querySelector('[data-progress]');
var parallax = Array.prototype.slice.call(document.querySelectorAll('[data-parallax]'));
var ticking = false;
function onScroll() {
var doc = document.documentElement;
var max = doc.scrollHeight - window.innerHeight;
var ratio = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
if (progress) {
progress.style.setProperty('--scroll', ratio.toFixed(4));
}
if (parallax.length) {
var view = window.innerHeight;
parallax.forEach(function (element) {
var rect = element.getBoundingClientRect();
if (rect.bottom < -240 || rect.top > view + 240) {
return;
}
var speed = parseFloat(element.getAttribute('data-parallax')) || 0.12;
var offset = (rect.top + rect.height / 2 - view / 2) * speed;
element.style.setProperty('--py', offset.toFixed(1) + 'px');
});
}
ticking = false;
}
function requestScroll() {
if (!ticking) {
ticking = true;
window.requestAnimationFrame(onScroll);
}
}
window.addEventListener('scroll', requestScroll, { passive: true });
window.addEventListener('resize', requestScroll);
onScroll();
/* Pointer-following highlight on the media cards. */
Array.prototype.forEach.call(document.querySelectorAll('.spot'), function (card) {
card.addEventListener('pointermove', function (event) {
var rect = card.getBoundingClientRect();
card.style.setProperty('--mx', ((event.clientX - rect.left) / rect.width * 100).toFixed(1) + '%');
card.style.setProperty('--my', ((event.clientY - rect.top) / rect.height * 100).toFixed(1) + '%');
});
});
}()); }());
""", """,
encoding="utf-8", encoding="utf-8",

View File

@@ -7,6 +7,10 @@ Sources
We pull the pre-derived `web` variant (long edge 2560 px) instead of the We pull the pre-derived `web` variant (long edge 2560 px) instead of the
20-60 MB camera originals. Only assets we are allowed to publish as the 20-60 MB camera originals. Only assets we are allowed to publish as the
rights holder are listed in src/images.json. rights holder are listed in src/images.json.
* Own portfolio files of the existing site (www.dennyschulz.de/legacy/<file>).
These carry the concert, band and free-work photographs that are missing from
the pool; they are fetched as-is and downscaled by the build. Also used for
the operator's original logo (www.dennyschulz.de/logo.png).
* Google Fonts (open licensed, OFL) - downloaded once, then self-hosted. * Google Fonts (open licensed, OFL) - downloaded once, then self-hosted.
Licences are written to docs/. Licences are written to docs/.
* YouTube poster frame for the own music video "Thjodroerir - Skogamor". * YouTube poster frame for the own music video "Thjodroerir - Skogamor".
@@ -26,10 +30,13 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "assets-src" SRC = ROOT / "assets-src"
IMG_OUT = SRC / "images" IMG_OUT = SRC / "images"
BRAND_OUT = SRC / "brand"
FONT_OUT = SRC / "fonts" FONT_OUT = SRC / "fonts"
TTF_OUT = SRC / "fonts-ttf" TTF_OUT = SRC / "fonts-ttf"
DOCS = ROOT / "docs" DOCS = ROOT / "docs"
LOGO_URL = "https://www.dennyschulz.de/logo.png"
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
# Old Safari advertises truetype-only support, so Google serves real .ttf # Old Safari advertises truetype-only support, so Google serves real .ttf
# files (the MSIE 6 UA returns EOT, which PIL/FreeType cannot read). # files (the MSIE 6 UA returns EOT, which PIL/FreeType cannot read).
@@ -81,22 +88,26 @@ def try_fetch(url: str, *, ua: str = UA) -> bytes | None:
def fetch_images() -> None: def fetch_images() -> None:
manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8")) manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8"))
base = manifest["pool_base"].rstrip("/") base = manifest["pool_base"].rstrip("/")
legacy_base = manifest["legacy_base"].rstrip("/")
suffixes = (".webp", ".src", ".jpg", ".jpeg", ".png")
ok = skipped = 0 ok = skipped = 0
for entry in manifest["images"]: for entry in manifest["images"]:
slug, raw = entry["slug"], entry["raw"] slug, raw = entry["slug"], entry["raw"]
rel = raw.replace("\\", "/") rel = raw.replace("\\", "/")
target = IMG_OUT / f"{slug}.webp" if any((IMG_OUT / f"{slug}{suffix}").exists() for suffix in suffixes):
source = IMG_OUT / f"{slug}.src"
if target.exists() or source.exists():
skipped += 1 skipped += 1
continue continue
data = try_fetch(f"{base}/_derived/web/{rel}.webp") if entry.get("source") == "legacy":
if data is None: data = fetch(f"{legacy_base}/{rel}")
print(f" [!] web variant missing, falling back to original: {rel}") (IMG_OUT / f"{slug}.jpg").write_bytes(data)
data = fetch(f"{base}/{rel}")
source.write_bytes(data)
else: else:
target.write_bytes(data) data = try_fetch(f"{base}/_derived/web/{rel}.webp")
if data is None:
print(f" [!] web variant missing, falling back to original: {rel}")
data = fetch(f"{base}/{rel}")
(IMG_OUT / f"{slug}.src").write_bytes(data)
else:
(IMG_OUT / f"{slug}.webp").write_bytes(data)
ok += 1 ok += 1
print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)") print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)")
print(f"images: {ok} fetched, {skipped} already present") print(f"images: {ok} fetched, {skipped} already present")
@@ -110,6 +121,18 @@ def fetch_images() -> None:
print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)") print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)")
def fetch_brand() -> None:
"""The operator's original logo (used for the brand mark and all icons)."""
BRAND_OUT.mkdir(parents=True, exist_ok=True)
target = BRAND_OUT / "logo.png"
if target.exists():
print(f"brand: logo already present ({target.stat().st_size / 1024:.0f} KiB)")
return
data = fetch(LOGO_URL)
target.write_bytes(data)
print(f" [ok] logo ({len(data) / 1024:.0f} KiB)")
def fetch_fonts() -> None: def fetch_fonts() -> None:
FONT_OUT.mkdir(parents=True, exist_ok=True) FONT_OUT.mkdir(parents=True, exist_ok=True)
for family, spec in FAMILIES.items(): for family, spec in FAMILIES.items():
@@ -160,8 +183,10 @@ def fetch_fonts() -> None:
def main() -> int: def main() -> int:
for path in (IMG_OUT, FONT_OUT, DOCS): for path in (IMG_OUT, BRAND_OUT, FONT_OUT, DOCS):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
print("== brand ==")
fetch_brand()
print("== images ==") print("== images ==")
fetch_images() fetch_images()
print("== fonts ==") print("== fonts ==")

View File

@@ -27,6 +27,7 @@
<link rel="manifest" href="/site.webmanifest"> <link rel="manifest" href="/site.webmanifest">
<link rel="stylesheet" href="/assets/fonts.css"> <link rel="stylesheet" href="/assets/fonts.css">
<link rel="stylesheet" href="/assets/site.css"> <link rel="stylesheet" href="/assets/site.css">
<noscript><style>.reveal,.reveal--mask{opacity:1;transform:none;clip-path:none}.reveal--mask>*{clip-path:none}.hero__title .w>span,.hero__sub,.hero__actions,.hero__facts{opacity:1;transform:none}</style></noscript>
</head> </head>
@@ -35,20 +36,15 @@
<header class="masthead"> <header class="masthead">
<div class="shell masthead__inner"> <div class="shell masthead__inner">
<a class="brand" href="/" aria-label="desfoto Startseite"> <a class="brand" href="/" aria-label="desfoto Startseite">
<svg class="brand__mark" viewBox="0 0 40 40" role="img" aria-label="desfoto Bildmarke"> <span class="brand__mark"><img src="/assets/img/logo-160.png" width="160" height="160" alt="" decoding="async"></span>
<circle class="ring" cx="20" cy="20" r="13"/>
<circle class="ring" cx="20" cy="20" r="5.2" stroke-width="2"/>
<path class="blade" d="M20 7 20 1.6"/>
<path class="blade" d="M31.3 26.5 36 29.2"/>
<path class="blade" d="M8.7 26.5 4 29.2"/>
</svg>
<span class="brand__text"><span>desfoto</span><small>Fotografie &amp; Video</small></span> <span class="brand__text"><span>desfoto</span><small>Fotografie &amp; Video</small></span>
</a> </a>
<button class="nav__toggle" type="button" aria-expanded="false" aria-controls="site-nav" data-nav-toggle> <button class="nav__toggle" type="button" aria-expanded="false" aria-controls="site-nav" data-nav-toggle>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg> Menü <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg> Menü
</button> </button>
<nav class="nav" id="site-nav" aria-label="Hauptnavigation"><div class="nav__item"><a class="nav__link" href="/fotografie/">Fotografie<svg class="nav__caret" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6l5 5 5-5"/></svg></a><div class="nav__sub"><a href="/businessfotografie/">Businessfotografie</a><a href="/portrait-und-model/">Portrait &amp; Model</a><a href="/familien-und-paare/">Familien &amp; Paare</a><a href="/minishootings/">Minishootings</a></div></div><a class="nav__link" href="/video/">Video</a><a class="nav__link" href="/social-media/">Social Media</a><a class="nav__link" href="/projekte/">Projekte</a><a class="nav__link" href="/ueber/">Über</a><a class="btn btn--solid" href="/kontakt/">Projekt anfragen</a></nav> <nav class="nav" id="site-nav" aria-label="Hauptnavigation"><div class="nav__item"><a class="nav__link" href="/shootings/">Shootings<svg class="nav__caret" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6l5 5 5-5"/></svg></a><div class="nav__sub"><a href="/businessfotografie/">Businessfotografie</a><a href="/portrait-und-model/">Portrait &amp; Model</a><a href="/familien-und-paare/">Familien &amp; Paare</a><a href="/minishootings/">Minishootings</a></div></div><a class="nav__link" href="/video/">Video</a><a class="nav__link" href="/projekte/">Projekte</a><a class="nav__link" href="/ueber/">Über</a><a class="btn btn--solid" href="/kontakt/">Projekt anfragen</a></nav>
</div> </div>
<span class="progress" aria-hidden="true"><span class="progress__bar" data-progress></span></span>
</header> </header>
<main id="main"><section class="section"> <main id="main"><section class="section">
<div class="shell center-page"> <div class="shell center-page">
@@ -70,20 +66,15 @@
<div class="footer__grid"> <div class="footer__grid">
<div class="footer__brand"> <div class="footer__brand">
<span class="brand"> <span class="brand">
<svg class="brand__mark" viewBox="0 0 40 40" role="img" aria-label="desfoto Bildmarke"> <span class="brand__mark"><img src="/assets/img/logo-160.png" width="160" height="160" alt="" decoding="async"></span>
<circle class="ring" cx="20" cy="20" r="13"/>
<circle class="ring" cx="20" cy="20" r="5.2" stroke-width="2"/>
<path class="blade" d="M20 7 20 1.6"/>
<path class="blade" d="M31.3 26.5 36 29.2"/>
<path class="blade" d="M8.7 26.5 4 29.2"/>
</svg>
<span class="brand__text"><span>desfoto</span><small>Fotografie &amp; Video</small></span> <span class="brand__text"><span>desfoto</span><small>Fotografie &amp; Video</small></span>
</span> </span>
<p class="footer__claim">Fotografie und Video aus Neumünster. Ein Angebot von Denny Schulz Fotografie.</p> <p class="footer__claim">Fotografie und Video aus Neumünster. Ein Angebot von Denny Schulz Fotografie.</p>
</div> </div>
<div> <div>
<h2>Fotografie</h2> <h2>Shootings</h2>
<div class="footer__links"> <div class="footer__links">
<a href="/shootings/">Überblick Shootings</a>
<a href="/businessfotografie/">Businessfotografie</a> <a href="/businessfotografie/">Businessfotografie</a>
<a href="/portrait-und-model/">Portrait &amp; Model</a> <a href="/portrait-und-model/">Portrait &amp; Model</a>
<a href="/familien-und-paare/">Familien &amp; Paare</a> <a href="/familien-und-paare/">Familien &amp; Paare</a>
@@ -91,7 +82,7 @@
</div> </div>
</div> </div>
<div> <div>
<h2>Weitere Leistungen</h2> <h2>Video &amp; mehr</h2>
<div class="footer__links"> <div class="footer__links">
<a href="/video/">Videoprojekte</a> <a href="/video/">Videoprojekte</a>
<a href="/social-media/">Social Media</a> <a href="/social-media/">Social Media</a>
@@ -108,6 +99,10 @@
<span class="muted">Neumünster · Schleswig-Holstein</span> <span class="muted">Neumünster · Schleswig-Holstein</span>
</div> </div>
</div> </div>
<div>
<h2>Netzwerk</h2>
<div class="footer__links"><a class="footer__ext" href="https://www.dennyschulz.de/" target="_blank" rel="noopener">dennyschulz.de <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-7 7M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4"/></svg></a><small>Hochzeiten, Portraits und Business — die fotografische Arbeit unter eigenem Namen.</small><a class="footer__ext" href="https://dennyapp.de/" target="_blank" rel="noopener">dennyapp.de <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-7 7M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4"/></svg></a><small>Websites und Web-Apps aus Neumünster — Design, Entwicklung und eigene Produkte.</small></div>
</div>
</div> </div>
<div class="footer__bottom"> <div class="footer__bottom">
<nav aria-label="Rechtliches"> <nav aria-label="Rechtliches">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 B

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Some files were not shown because too many files have changed in this diff Show More