heartwood every commit a ring

the page remembers it is an almanac

6d6fb0b6 by Isaac Bythewood · 16 days ago

the page remembers it is an almanac

five rooms now: a sky, a garden, a kitchen, a foraging trail, and
a folklore. each marked by a small old glyph and held in a warm
charcoal ground, breathing and grained like coal. fraunces and
jetbrains mono carry the voices, served from the project itself.
the time-of-day rotation rests; the hour only tints the air.
modified CLAUDE.md
@@ -31,8 +31,8 @@ page that breathes with the seasons.- Production runs via `docker-compose` (Gunicorn, 2 workers) bound to  `127.0.0.1:${PORT}` where `PORT` comes from `.env` (8500 on the deployed host).- Routes: `/` renders the page; `/api/content` returns the same content as JSON  for the client-side auto-refresh. Both accept `?season=` and `?time=`  query-string overrides for previewing other times of day or year.  for the client-side auto-refresh. Both accept `?season=` for previewing other  seasons. There is no `?time=` override anymore.- The site lives at darkfurrow.com.- Target planting zone is 7a (North Carolina) to start, but the structure  should allow for expansion to other zones later.
@@ -48,6 +48,29 @@ page that breathes with the seasons.- The dev environment runs inside a Docker container with port 8000  mapped to the host.## Page structureThe page is one column of clearly-labeled sections so a visitor can scan to thepart they care about. The current sections, in order, are:1. **sky**  - calculated sun/moon/daylight + moon-phase gardening tip + a sky   lore line + a storms lore line. The italic intro is the season's mood for   the current time of day (from `data/moods/`).2. **garden** - planting picks ("in the ground now"), indoor starts when the   season has them, and a couple of weekly chores ("this week").3. **kitchen** - what's "in season" plus one "tonight" highlight.4. **foraging** - what the land is offering, with the closing prose line as   italic lore beneath.5. **folklore** - a short paragraph each from old names, remedies, and bugs.Daily-stable seeded RNG (`seeded_random` keyed by day-of-year) picks whichitems surface per section, so content shifts day to day but is stable acrossrefreshes within a day.The time-of-day rotation that used to swap which categories appeared wasremoved. Time of day still tints the background palette (in `static/almanac.js`)but no longer hides content.## Content the page should surface- What's in season to plant and harvest right now
@@ -56,6 +79,9 @@ page that breathes with the seasons.- Old folk names for storms, stars, and time periods- Practical wisdom that used to be passed down but no longer isThe `data/wisdom/<time>.md` files are no longer rendered (they were tied to theremoved time rotation) but remain on disk in case they come back.## Voice and tone- Poetic but not pretentious
modified almanac.py
@@ -101,17 +101,6 @@ TIMES = [    {'name': 'night',     'start': 21, 'end': 24},]TIME_LABELS = ['night', 'dawn', 'morning', 'afternoon', 'evening']TIME_CONTENT = {    'night':     ['sky/', 'names/', 'remedies/', 'storms/'],    'dawn':      ['planting/', 'foraging/'],    'morning':   ['planting/', 'chores/', 'bugs/'],    'afternoon': ['kitchen/', 'bugs/', 'chores/'],    'evening':   ['kitchen/', 'remedies/', 'foraging/', 'names/', 'storms/'],}def get_time_of_day(date):    h = date.hour    for t in TIMES:
@@ -164,37 +153,6 @@ def parse_list_items(body):    return {'bullets': bullets, 'prose': prose}def highlight_text(text):    fragments = re.split(r'(?<=\.)\s+', text)    result = []    for frag in fragments:        trimmed = frag.strip()        if not trimmed:            continue        plain = re.sub(r'</?strong>', '', trimmed)        words = plain.split()        if len(words) <= 4:            result.append(f'<strong>{trimmed}</strong>')            continue        if '<strong>' in trimmed:            result.append(trimmed)            continue        comma = trimmed.find(',')        if 0 < comma < 30:            result.append(f'<strong>{trimmed[:comma]}</strong>{trimmed[comma:]}')            continue        first = words[0].lower()        count = 3 if first in ('the', 'a', 'an', 'if', 'when', 'it', 'and', 'or', 'but', 'do', 'in') else 2        count = min(count, len(words))        result.append(f'<strong>{" ".join(words[:count])}</strong> {" ".join(words[count:])}')    return ' '.join(result)# --- sky calculations ---def moon_phase(date):
@@ -277,39 +235,22 @@ def written_date(date):    return f'{time}, the {ORDINALS[date.day]} of {MONTHS[date.month - 1]}'def sky_text(now, time):def sky_data_lines(now):    """three short lines: moon, sun, daylight. shown in the sky section."""    from datetime import timedelta    phase = moon_phase(now)    name = moon_name(phase)    illum = round(moon_illumination(phase) * 100)    hours = daylight_hours(now)    from datetime import timedelta    yesterday = now - timedelta(days=1)    gained = (hours - daylight_hours(yesterday)) * 60    gained = (hours - daylight_hours(now - timedelta(days=1))) * 60    sign = '+' if gained > 0 else ''    sunrise = 12 - hours / 2    sunset = 12 + hours / 2    bold_name = f'<strong>{name}</strong>'    lines = []    if time == 'night':        lines.append(f'the moon is {bold_name}, {illum}% lit.')        lines.append('the world is turned away from the sun.')        lines.append(f'<strong>{format_hm(hours)}</strong> of daylight today. {sign}{gained:.1f} minutes from yesterday.')    elif time == 'dawn':        lines.append(f'the sun rises around <strong>{format_clock(sunrise)}</strong>.')        lines.append(f'the moon is {bold_name}, {illum}% lit.')        lines.append(f'<strong>{format_hm(hours)}</strong> of daylight ahead. it sets around {format_clock(sunset)}.')    elif time == 'evening':        lines.append(f'the sun set around <strong>{format_clock(sunset)}</strong>.')        lines.append(f'the moon is {bold_name}, {illum}% lit.')        lines.append(f'there were <strong>{format_hm(hours)}</strong> of daylight today. {sign}{gained:.1f} minutes from yesterday.')    else:        lines.append(f'the moon is {bold_name}, {illum}% lit.')        lines.append(f'the sun rose around <strong>{format_clock(sunrise)}</strong> and sets around <strong>{format_clock(sunset)}</strong>.')        lines.append(f'<strong>{format_hm(hours)}</strong> of daylight today. {sign}{gained:.1f} minutes from yesterday.')    return '<br>'.join(lines)    return [        f'<strong>{name}</strong>, {illum}% lit',        f'sunrise <strong>{format_clock(sunrise)}</strong> \u00b7 sunset <strong>{format_clock(sunset)}</strong>',        f'<strong>{format_hm(hours)}</strong> of daylight ({sign}{gained:.1f} minutes from yesterday)',    ]# --- data loading ---
@@ -326,7 +267,6 @@ def load_seasons(data_dir):    """load season definitions from data/seasons/*.md"""    seasons_dir = os.path.join(data_dir, 'seasons')    seasons = []    seen = {}    for filename in sorted(os.listdir(seasons_dir)):        if not filename.endswith('.md'):
@@ -346,10 +286,7 @@ def load_seasons(data_dir):            'note': note,        }        # primary date range        seasons.append(entry)        if name not in seen:            seen[name] = entry        # winter has a second range (dec)        if 'start-alt' in meta:
@@ -363,6 +300,14 @@ def load_seasons(data_dir):    # sort by start month/day so lookup order is correct    seasons.sort(key=lambda s: (s['start'][0], s['start'][1]))    # build the canonical-order map after sorting so the nav reads    # winter -> early spring -> ... -> late fall instead of alphabetical    seen = {}    for s in seasons:        if s['name'] not in seen:            seen[s['name']] = s    return seasons, seen
@@ -523,132 +468,218 @@ def moon_garden_tip(phase, moon_tips):    return moon_tips[-1][2] if moon_tips else ''# --- section builders ---# each builder returns a dict: {key, title, intro, groups, lore}# - groups: [{label, items: [html, ...]}] rendered as labeled bullet lists# - lore: [html, ...] rendered as short prose paragraphsdef _read_md(path, files):    body = files.get(path)    if not body:        return None    return parse_frontmatter(body)def _section_sky(now, season, data, rng):    files = data['files']    intro = get_weather_mood(season['name'], get_time_of_day(now), data['moods'])    lore = []    tip = moon_garden_tip(moon_phase(now), data['moon_tips'])    if tip:        lore.append(render_md(tip))    for path in (f"sky/{season['name']}.md", f"storms/{season['name']}.md"):        parsed = _read_md(path, files)        if not parsed:            continue        items = parse_list_items(parsed['body'])        candidates = items['bullets'] + items['prose']        if candidates:            pick = pick_items(candidates, 1, rng)[0]            lore.append(render_md(pick))    return {        'key': 'sky',        'title': 'sky',        'intro': intro,        'groups': [{'label': '', 'items': sky_data_lines(now)}],        'lore': lore,    }def _section_garden(season, data, rng):    files = data['files']    groups = []    parsed = _read_md(f"planting/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(4, len(items['bullets'])), rng)            groups.append({                'label': 'in the ground now',                'items': [render_md(p) for p in picks],            })    parsed = _read_md(f"planting/{season['name']}-indoors.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(3, len(items['bullets'])), rng)            groups.append({                'label': 'starting indoors',                'items': [render_md(p) for p in picks],            })    parsed = _read_md(f"chores/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(2, len(items['bullets'])), rng)            groups.append({                'label': 'this week',                'items': [render_md(p) for p in picks],            })    return {'key': 'garden', 'title': 'garden', 'intro': '', 'groups': groups, 'lore': []}def _section_kitchen(season, data, rng):    files = data['files']    groups = []    parsed = _read_md(f"kitchen/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        bullets = list(items['bullets'])        if bullets:            picks = pick_items(bullets, min(4, len(bullets)), rng)            groups.append({                'label': 'in season',                'items': [render_md(p) for p in picks],            })            remaining = [b for b in bullets if b not in picks]            tonight = pick_items(remaining, 1, rng)[0] if remaining else picks[-1]            groups.append({                'label': 'tonight',                'items': [render_md(tonight)],            })    return {'key': 'kitchen', 'title': 'kitchen', 'intro': '', 'groups': groups, 'lore': []}def _section_foraging(season, data, rng):    files = data['files']    groups = []    lore = []    parsed = _read_md(f"foraging/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            picks = pick_items(items['bullets'], min(4, len(items['bullets'])), rng)            groups.append({'label': '', 'items': [render_md(p) for p in picks]})        if items['prose']:            lore.append(render_md(items['prose'][0]))    return {'key': 'foraging', 'title': 'foraging', 'intro': '', 'groups': groups, 'lore': lore}def _section_folklore(season, data, rng):    files = data['files']    lore = []    parsed = _read_md(f"names/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['prose']:            lore.append(render_md(items['prose'][0]))        elif items['bullets']:            picks = pick_items(items['bullets'], min(2, len(items['bullets'])), rng)            lore.append(' '.join(render_md(p) for p in picks))    parsed = _read_md(f"remedies/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        parts = []        if items['bullets']:            parts.append(render_md(pick_items(items['bullets'], 1, rng)[0]))        if items['prose']:            parts.append(render_md(items['prose'][0]))        if parts:            lore.append(' '.join(parts))    parsed = _read_md(f"bugs/{season['name']}.md", files)    if parsed:        items = parse_list_items(parsed['body'])        if items['bullets']:            lore.append(render_md(pick_items(items['bullets'], 1, rng)[0]))    return {'key': 'folklore', 'title': 'folklore', 'intro': '', 'groups': [], 'lore': lore}SECTION_BUILDERS = [_section_sky, _section_garden, _section_kitchen, _section_foraging, _section_folklore]def render_sections_html(sections):    """render the section list to a single HTML string used by both    the server-side template and the JSON API response."""    parts = []    for s in sections:        if not s['groups'] and not s['lore'] and not s.get('intro'):            continue        parts.append(f'<section class="bucket bucket-{s["key"]}">')        parts.append(f'<h2>{s["title"]}</h2>')        if s.get('intro'):            parts.append(f'<p class="bucket-intro">{s["intro"]}</p>')        for g in s['groups']:            if g.get('label'):                parts.append(f'<p class="bucket-label">{g["label"]}</p>')            parts.append('<ul class="bucket-list">')            for item in g['items']:                parts.append(f'<li>{item}</li>')            parts.append('</ul>')        for line in s.get('lore', []):            parts.append(f'<p class="bucket-lore">{line}</p>')        parts.append('</section>')    return ''.join(parts)# --- content assembly ---def assemble_content(now, data, season_override=None, time_override=None):def assemble_content(now, data, season_override=None):    """assemble the full page content for a given moment."""    seasons = data['seasons']    seasons_map = data['seasons_map']    manifest = data['manifest']    files = data['files']    season = get_season_by_name(season_override, seasons_map) if season_override else get_season(now, seasons)    real_time = get_time_of_day(now)    content_time = time_override or real_time    # date line    date_line = written_date(now)    # season header    note_html = render_md_block(season['note'])    nxt = days_until_next_season(now, seasons)    if nxt['days'] <= 7:        note_html += '<p>' + nxt['label'] + ' begins in ' + str(nxt['days']) + (' day.' if nxt['days'] == 1 else ' days.') + '</p>'    # haiku    haiku_lines = get_haiku(season['name'], now, data['haiku'])    haiku_html = ''    if haiku_lines:        haiku_html = ''.join(f'<span class="haiku-line">{line}</span>' for line in haiku_lines)    # moon phase for garden tip    phase = moon_phase(now)    # weather mood    weather_mood = get_weather_mood(season['name'], content_time, data['moods'])    # sky data    sky_data = sky_text(now, content_time)    # footer    footer_text = f"{nxt['days']} days until {nxt['label']} \u00b7 zone 7a \u00b7 north carolina"    # seed the rng for today    rng = seeded_random(day_hash(now))    sections = [build(now, season, data, rng) if build is _section_sky else build(season, data, rng)                for build in SECTION_BUILDERS]    sections_html = render_sections_html(sections)    # filter manifest    allowed_prefixes = TIME_CONTENT.get(content_time, [])    relevant = []    for entry in manifest:        if 'time' in entry:            if entry['time'] == content_time:                relevant.append(entry)        elif 'season' in entry:            if entry['season'] != season['name']:                continue            for prefix in allowed_prefixes:                if entry['path'].startswith(prefix):                    relevant.append(entry)                    break    # parse entries    entries = []    for entry in relevant:        text = files.get(entry['path'])        if text is None:            continue        entries.append(parse_frontmatter(text))    # collect fragments    fragments = []    fragments.append(moon_garden_tip(phase, data['moon_tips']))    # wisdom lines    for entry in entries:        if 'time' not in entry['meta']:            continue        lines = [l.strip() for l in entry['body'].split('\n') if l.strip()]        if not lines:            continue        picks = pick_items(lines, min(2, len(lines)), rng)        fragments.extend(picks)    # seasonal entries    for entry in entries:        if 'time' in entry['meta']:            continue        parsed = parse_list_items(entry['body'])        if parsed['bullets']:            picks = pick_items(parsed['bullets'], 1, rng)            fragments.extend(picks)        if parsed['prose']:            prose_pick = pick_items(parsed['prose'], 1, rng)            fragments.append(prose_pick[0])    # shuffle    for i in range(len(fragments) - 1, 0, -1):        j = int(rng() * (i + 1))        fragments[i], fragments[j] = fragments[j], fragments[i]    # compose into paragraphs    sep = ' <span class="sep">\u2767</span> '    chunk_size = min(4, max(1, math.ceil(len(fragments) / 2)))    narrative_html = ''    for c in range(0, len(fragments), chunk_size):        chunk = fragments[c:c + chunk_size]        html = sep.join(highlight_text(render_md(f)) for f in chunk)        narrative_html += f'<p class="narrative">{html}</p>'    # build nav html    season_nav_html = build_season_nav(season, seasons_map)    time_nav_html = build_time_nav(content_time, real_time)    footer_text = f"{nxt['days']} days until {nxt['label']} \u00b7 zone 7a \u00b7 north carolina"    return {        'date_line': date_line,        'date_line': written_date(now),        'season_name': season['label'],        'season_note': note_html,        'season_key': season['name'],        'time_key': real_time,        'content_time': content_time,        'haiku_html': haiku_html,        'weather_mood': weather_mood,        'sky_data': sky_data,        'narrative_html': narrative_html,        'sections_html': sections_html,        'footer_text': footer_text,        'season_nav_html': season_nav_html,        'time_nav_html': time_nav_html,        'season_nav_html': build_season_nav(season, seasons_map),    }
@@ -660,13 +691,3 @@ def build_season_nav(active_season, seasons_map):        cls = ' class="active" aria-current="true"' if s['name'] == active_season['name'] else ''        html += f'<a data-season="{s["name"]}"{cls}>{s["label"]}</a>'    return htmldef build_time_nav(active_time, natural_time):    html = ''    for t in TIME_LABELS:        cls = ' class="active" aria-current="true"' if t == active_time else ''        html += f'<a data-time="{t}"{cls}>{t}</a>'    if active_time != natural_time:        html += '<a class="return-now" data-time="now">return to now</a>'    return html
modified app.py
@@ -21,7 +21,8 @@ DATA = load_data(DATA_DIR)@app.route('/')def index():    now = datetime.now(ZoneInfo('America/New_York'))    content = assemble_content(now, DATA)    season_override = request.args.get('season')    content = assemble_content(now, DATA, season_override=season_override)    return render_template('index.html', **content)
@@ -29,10 +30,5 @@ def index():def api_content():    now = datetime.now(ZoneInfo('America/New_York'))    season_override = request.args.get('season')    time_override = request.args.get('time')    content = assemble_content(        now, DATA,        season_override=season_override,        time_override=time_override,    )    content = assemble_content(now, DATA, season_override=season_override)    return jsonify(content)
modified static/almanac.js
@@ -8,7 +8,6 @@  var ANIM_WORD_DELAY = 18;  var ANIM_LIST_DELAY = 60;  var ANIM_NAV_DELAY = 80;  // --- daylight cycle ---
@@ -23,11 +22,6 @@    { name: 'night',     start: 21, end: 24 }  ];  var SEASONS_LIST = [    'winter', 'early-spring', 'late-spring', 'early-summer',    'midsummer', 'early-fall', 'late-fall'  ];  function getTimeOfDay(date) {    var h = date.getHours();    for (var i = 0; i < TIMES.length; i++) {
@@ -36,7 +30,6 @@    return 'night';  }  // season detection for auto-refresh  function getSeasonName(date) {    var m = date.getMonth() + 1;    var d = date.getDate();
@@ -59,29 +52,33 @@    return 'winter';  }  // palette per time of day. text stays in the bone/cream range so  // contrast holds; the ember "heading" role drives the accent (drop  // cap, roman numerals, links, mood markers) and stays warm-gold  // even at night so editorial elements don't disappear.  var CYCLES = {    night: {      bg: '#060504', text: '#9a8e80', accent: '#3a5030', heading: '#7a6a58',      bg: '#0a0806', text: '#dcd0b8', accent: '#86a07a', heading: '#d2a070',      glow: 'rgba(30,25,20,0.08)', under1: 'rgba(15,12,10,0.8)',      under2: 'rgba(20,18,15,0.6)', under3: 'rgba(10,10,15,0.5)'    },    dawn: {      bg: '#0f0c09', text: '#c4b5a0', accent: '#7a9a62', heading: '#c0885a',      bg: '#13100b', text: '#e0d4be', accent: '#92a880', heading: '#e3b27a',      glow: 'rgba(180,120,60,0.06)', under1: 'rgba(40,25,15,0.6)',      under2: 'rgba(50,30,18,0.4)', under3: 'rgba(30,20,12,0.5)'    },    morning: {      bg: '#0e0c0a', text: '#d4c8b8', accent: '#7a9a62', heading: '#b07a50',      bg: '#14100c', text: '#e2d8c2', accent: '#92a880', heading: '#d8aa78',      glow: 'rgba(140,100,50,0.04)', under1: 'rgba(26,23,20,0.7)',      under2: 'rgba(42,36,32,0.5)', under3: 'rgba(26,23,20,0.4)'    },    afternoon: {      bg: '#0d0b09', text: '#d0c2b0', accent: '#6a8a55', heading: '#a87048',      bg: '#15110e', text: '#dccfba', accent: '#88a07a', heading: '#d3a06c',      glow: 'rgba(160,100,40,0.05)', under1: 'rgba(35,28,20,0.6)',      under2: 'rgba(30,25,18,0.5)', under3: 'rgba(40,30,20,0.4)'    },    evening: {      bg: '#0b0908', text: '#c8b8a5', accent: '#5a7d4a', heading: '#b87840',      bg: '#120f0b', text: '#dac9b4', accent: '#82987a', heading: '#dfa478',      glow: 'rgba(180,100,40,0.08)', under1: 'rgba(45,25,12,0.7)',      under2: 'rgba(35,18,10,0.6)', under3: 'rgba(25,15,8,0.5)'    }
@@ -143,7 +140,7 @@  function revealWords(root) {    if (!root) return;    var elements = root.querySelectorAll('h2, p, li, blockquote');    var elements = root.querySelectorAll('h1, h2, p, li, blockquote');    var allItems = [];    elements.forEach(function (el) {
@@ -190,10 +187,17 @@      nodes.forEach(function (n) { el.appendChild(n); });    });    var delay = 0;    // cap total cascade so the last item lands within ~1s of the    // first. budget includes both word and LI advances; with ~300    // items the per-item step shrinks below 5ms which still reads    // as a sweep rather than a pop.    var ANIM_TOTAL_MS = 900;    var n = allItems.length;    var step = n > 1 ? ANIM_TOTAL_MS / (n - 1) : 0;    var nextDelay = 0;    allItems.forEach(function (item) {      item.style.animationDelay = delay + 'ms';      delay += item.tagName === 'LI' ? ANIM_LIST_DELAY : ANIM_WORD_DELAY;      item.style.animationDelay = nextDelay + 'ms';      nextDelay += step;    });  }
@@ -201,9 +205,7 @@  // --- main ---  var currentSeasonOverride = null;  var currentTimeOverride = null;  var naturalSeason = null;  var naturalTime = null;  var dom = {};  function cacheDOM() {
@@ -211,40 +213,22 @@    dom.seasonName = document.querySelector('.season-name');    dom.seasonNote = document.querySelector('.season-note');    dom.haikuBlock = document.querySelector('.flow-haiku blockquote');    dom.weatherMoodText = document.querySelector('.weather-mood-text');    dom.skyData = document.querySelector('.sky-data');    dom.footerP = document.querySelector('footer p');    dom.wisdom = document.querySelector('.wisdom');    dom.flowEntries = document.querySelector('.flow-entries');    dom.sections = document.querySelector('.sections');    dom.footerStatus = document.querySelector('.footer-status');    dom.readout = document.querySelector('.readout');    dom.footer = document.querySelector('footer');    dom.seasonsNav = document.querySelector('.seasons-nav');    dom.timesNav = document.querySelector('.times-nav');    dom.navDrawer = document.querySelector('.nav-drawer');    dom.navToggle = document.querySelector('.nav-toggle');    if (dom.navToggle) {      dom.navToggle.addEventListener('click', function () {        var open = dom.navDrawer.classList.toggle('open');        dom.navToggle.textContent = open ? '...close' : '...explore';      });    }  }  function loadContent(seasonOverride, timeOverride) {    var fadeTargets = ['.flow-season', '.flow-sky', '.flow-haiku', '.wisdom', '.flow-entries', 'footer'];  function loadContent(seasonOverride) {    var fadeTargets = ['.flow-season', '.flow-haiku', '.sections', 'footer'];    fadeTargets.forEach(function (sel) {      var el = document.querySelector(sel);      if (el) el.style.opacity = '0';    });    var params = [];    if (seasonOverride) params.push('season=' + encodeURIComponent(seasonOverride));    if (timeOverride) params.push('time=' + encodeURIComponent(timeOverride));    var url = '/api/content' + (params.length ? '?' + params.join('&') : '');    var url = '/api/content' + (seasonOverride ? '?season=' + encodeURIComponent(seasonOverride) : '');    currentSeasonOverride = seasonOverride;    currentTimeOverride = timeOverride;    fetch(url)      .then(function (r) { return r.json(); })
@@ -253,20 +237,15 @@        dom.seasonName.textContent = data.season_name;        dom.seasonNote.innerHTML = data.season_note;        dom.haikuBlock.innerHTML = data.haiku_html;        dom.weatherMoodText.innerHTML = data.weather_mood;        dom.skyData.innerHTML = data.sky_data;        dom.flowEntries.innerHTML = data.narrative_html;        dom.footerP.textContent = data.footer_text;        dom.sections.innerHTML = data.sections_html;        dom.footerStatus.textContent = data.footer_text;        dom.seasonsNav.innerHTML = data.season_nav_html;        dom.timesNav.innerHTML = data.time_nav_html;        // update body attributes for the season color        document.body.setAttribute('data-season', data.season_key);        document.body.setAttribute('data-time', data.time_key);        // daylight cycle follows real clock, season follows content        var realTime = getTimeOfDay(new Date());        applyDaylightCycle(realTime, data.season_key);        // background cycle follows real clock; season tint follows content        applyDaylightCycle(getTimeOfDay(new Date()), data.season_key);        fadeTargets.forEach(function (sel) {          var el = document.querySelector(sel);
@@ -275,11 +254,10 @@        revealWords(dom.readout);        revealWords(dom.footer);        bindNavClicks();      })      .catch(function () {        dom.flowEntries.innerHTML = '<p style="color:var(--ash);font-style:italic;">the pages could not be found. try again in a moment.</p>';        dom.sections.innerHTML = '<p style="color:var(--ash);font-style:italic;">the pages could not be found. try again in a moment.</p>';        fadeTargets.forEach(function (sel) {          var el = document.querySelector(sel);          if (el) el.style.opacity = '1';
@@ -292,22 +270,7 @@      a.addEventListener('click', function () {        var season = a.getAttribute('data-season');        window.scrollTo({ top: 0, behavior: 'smooth' });        if (season === naturalSeason) {          loadContent(null, currentTimeOverride);        } else {          loadContent(season, currentTimeOverride);        }      });    });    dom.timesNav.querySelectorAll('a[data-time]').forEach(function (a) {      a.addEventListener('click', function () {        var time = a.getAttribute('data-time');        if (time === 'now') {          loadContent(currentSeasonOverride, null);        } else {          loadContent(currentSeasonOverride, time);        }        loadContent(season === naturalSeason ? null : season);      });    });  }
@@ -319,34 +282,27 @@  var now = new Date();  naturalSeason = getSeasonName(now);  naturalTime = getTimeOfDay(now);  // apply daylight cycle from server-provided attributes  var initSeason = document.body.getAttribute('data-season') || naturalSeason;  var initTime = document.body.getAttribute('data-time') || naturalTime;  var initTime = document.body.getAttribute('data-time') || getTimeOfDay(now);  applyDaylightCycle(initTime, initSeason);  // reveal the server-rendered content  revealWords(dom.readout);  revealWords(dom.footer);  // bind nav clicks on the server-rendered nav  bindNavClicks();  // auto-refresh when time of day or season changes  // refresh on season rollover; keep palette in sync with the clock  setInterval(function () {    var check = new Date();    var newTime = getTimeOfDay(check);    var newSeason = getSeasonName(check);    if (newTime !== naturalTime || newSeason !== naturalSeason) {      naturalTime = newTime;    if (newSeason !== naturalSeason) {      naturalSeason = newSeason;      if (!currentTimeOverride) {        loadContent(currentSeasonOverride, null);      if (!currentSeasonOverride) {        loadContent(null);        return;      }      // always update the daylight cycle to real time      applyDaylightCycle(newTime, document.body.getAttribute('data-season') || newSeason);    }    applyDaylightCycle(getTimeOfDay(check), document.body.getAttribute('data-season') || newSeason);  }, 60000);  // register service worker for offline access
added static/fonts/fraunces-latin-full-italic.woff2

binary file

added static/fonts/fraunces-latin-full-normal.woff2

binary file

added static/fonts/jetbrains-mono-latin-wght-normal.woff2

binary file

modified static/style.css
@@ -1,20 +1,78 @@/* ================================================================ * dark furrow / stylesheet * editorial almanac. fraunces (variable, with opsz / SOFT / wonk * axes) carries the serif voice; jetbrains mono carries the data * voice. one ember accent. warm charcoal ground. svg grain over * everything for paper-by-firelight texture. * * fonts are self-hosted woff2 from @fontsource. no third-party * cdn calls at runtime. * ================================================================ */@font-face {  font-family: 'Fraunces';  font-style: normal;  font-weight: 100 900;  font-stretch: 25% 151%;  font-display: swap;  src: url('/static/fonts/fraunces-latin-full-normal.woff2') format('woff2-variations');}@font-face {  font-family: 'Fraunces';  font-style: italic;  font-weight: 100 900;  font-stretch: 25% 151%;  font-display: swap;  src: url('/static/fonts/fraunces-latin-full-italic.woff2') format('woff2-variations');}@font-face {  font-family: 'JetBrains Mono';  font-style: normal;  font-weight: 100 800;  font-display: swap;  src: url('/static/fonts/jetbrains-mono-latin-wght-normal.woff2') format('woff2-variations');}:root {  --earth: #0e0c0a;  --bark: #2a2420;  --ash: #a89b8e;  --bone: #d4c8b8;  --parchment: #ece3d5;  --sprout: #7a9a62;  --moss: #5a7d4a;  --ember: #b07a50;  --glow: rgba(140,100,50,0.04);  --under1: rgba(26,23,20,0.7);  --under2: rgba(42,36,32,0.5);  --under3: rgba(26,23,20,0.4);  --type-title: clamp(2.8rem, 4vw + 1rem, 4.2rem);  --type-season: clamp(1.6rem, 2vw + 0.6rem, 2rem);  --type-body: clamp(0.95rem, 0.8vw + 0.6rem, 1.05rem);  /* --- palette ---------------------------------------------------   * warmer than pitch black, leaning toward burnt walnut. one   * ember-gold accent does the heavy lifting; bone is body; ash   * is for italic and secondary text. moss only survives for   * ::selection (it's the most useful "alive" color we have).   * -------------------------------------------------------------- */  --earth:      #16110d;  --earth-2:    #1d1714;  --bark:       #2a2118;  --ash:        #8c8175;  --bone:       #d8cdb9;  --parchment:  #ebe1cc;  --ember:      #d4a574;  --ember-warm: #c8884c;  --moss:       #5a7d4a;  /* atmospheric layers — kept for the daylight cycle in JS */  --glow:   rgba(140, 100, 50, 0.04);  --under1: rgba(26, 23, 20, 0.7);  --under2: rgba(42, 36, 32, 0.5);  --under3: rgba(26, 23, 20, 0.4);  /* --- type ----------------------------------------------------- */  --serif: 'Fraunces', 'Iowan Old Style', Georgia, 'Times New Roman', serif;  --mono:  'JetBrains Mono', 'IBM Plex Mono', ui-monospace, 'SFMono-Regular',           Menlo, Consolas, monospace;  /* fluid type scale — set only the few that vary by viewport */  --type-wordmark:  clamp(3.2rem, 5vw + 1.4rem, 5.4rem);  --type-season:    clamp(2.6rem, 3vw + 1.4rem, 4.2rem);  --type-section:   clamp(1.5rem, 1vw + 1rem, 1.9rem);  --type-body:      clamp(1.0rem, 0.4vw + 0.85rem, 1.12rem);  --type-meta:      0.72rem;  /* layout */  --measure: 36rem;  --rule:    1px solid rgba(140, 110, 80, 0.18);}* {
@@ -24,428 +82,614 @@}html {  font-size: 18px;  font-size: 17px;  scroll-behavior: smooth;  text-rendering: optimizeLegibility;  -webkit-font-smoothing: antialiased;  -moz-osx-font-smoothing: grayscale;}body {  background-color: var(--earth);  color: var(--bone);  font-family: Georgia, 'Times New Roman', serif;  line-height: 1.8;  -webkit-font-smoothing: antialiased;  font-family: var(--serif);  font-variation-settings: 'opsz' 14, 'SOFT' 50, 'wght' 380;  font-feature-settings: 'liga', 'kern', 'onum';  line-height: 1.7;  transition: background-color 2s ease, color 2s ease;  min-height: 100vh;}main {  position: relative;  z-index: 1;}::selection {  background-color: var(--moss);  color: var(--parchment);}/* ===== ATMOSPHERIC LAYERS ======================================= * three fixed layers stacked behind the content: *   - sky-layer: season radial tint (set by JS) *   - time-layer: time-of-day vertical light (set by JS) *   - grain: SVG noise texture for paper-by-firelight feel * ================================================================ */#sky-layer {  position: fixed;  inset: 0;  pointer-events: none;  z-index: -2;  transition: background 4s ease;}#time-layer {  position: fixed;  inset: 0;  height: 100vh;  height: 100dvh;  pointer-events: none;  z-index: -1;  transition: background 4s ease;}/* svg-noise grain. heavier than typical (~13%) because this needs   to read as paper texture and firelight haze, not just polish.   layered with mix-blend-mode overlay so it darkens shadows and   warms highlights at the same time. */.grain {  position: fixed;  inset: 0;  pointer-events: none;  z-index: 9999;  opacity: 0.07;  mix-blend-mode: soft-light;  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240' viewBox='0 0 240 240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.92' numOctaves='3' stitchTiles='stitch' seed='4'/><feColorMatrix values='0 0 0 0 0.85  0 0 0 0 0.72  0 0 0 0 0.55  0 0 0 0.7 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");}/* slow breathing warm glow. anchored above center, drifts alpha   over 28s so the page pulses like a coal that won't quite go out.   sits behind the season radial so it tints rather than dominates. */.glow {  position: fixed;  inset: 0;  pointer-events: none;  z-index: -3;  background: radial-gradient(ellipse 60% 50% at 50% 32%,    rgba(212, 165, 116, 0.12) 0%,    rgba(180, 120, 60, 0.04) 30%,    transparent 65%);  animation: ember-breathe 28s ease-in-out infinite;}@keyframes ember-breathe {  0%, 100% { opacity: 0.4; transform: translateY(0) scale(1); }  50%      { opacity: 0.8; transform: translateY(-10px) scale(1.03); }}/* ===== READOUT ===== *//* ===== READOUT (the page column) ================================ */.readout {  max-width: 34rem;  max-width: var(--measure);  margin: 0 auto;  padding: 4rem 1.5rem 2rem;  padding: 3.5rem 1.6rem 2rem;}/* --- header --- *//* ===== MASTHEAD (almanac coordinate strip) ====================== * a tiny mono ribbon at the top: year roman, zone, latitude. like * a ship's log header. justified across the column. * ================================================================ */.readout-header {.masthead {  display: flex;  justify-content: space-between;  align-items: center;  font-family: var(--mono);  font-size: var(--type-meta);  font-weight: 400;  letter-spacing: 0.18em;  color: var(--ash);  text-transform: uppercase;  padding-bottom: 0.9rem;  margin-bottom: 3rem;  border-bottom: var(--rule);  opacity: 0.7;}.masthead-mark:nth-child(2) {  /* center mark gets a subtle ember tone */  color: var(--ember);  opacity: 0.85;}.readout-header::after {  display: none;/* ===== TITLE BLOCK ============================================== */.title-block {  margin-bottom: 3.5rem;}.readout-header h1 {  font-size: var(--type-title);.wordmark {  font-family: var(--serif);  font-variation-settings: 'opsz' 144, 'SOFT' 100, 'wght' 320, 'WONK' 1;  font-style: normal;  font-weight: 400;  letter-spacing: 0.03em;  font-size: var(--type-wordmark);  line-height: 0.95;  letter-spacing: -0.02em;  color: var(--parchment);  line-height: 1.1;  margin-bottom: 0.4rem;}.subtitle {  color: var(--ash);.wordmark-sub {  font-family: var(--serif);  font-variation-settings: 'opsz' 14, 'SOFT' 80, 'wght' 320;  font-style: italic;  font-size: var(--type-body);  margin-top: 0.3rem;  animation: breathe 8s ease-in-out infinite;  font-size: 1rem;  color: var(--ash);  letter-spacing: 0.01em;  animation: breathe 9s ease-in-out infinite;}/* --- date line --- *//* ===== DATE LINE (poetic) ======================================= */.date-line {  color: var(--ash);  font-size: var(--type-body);  font-family: var(--serif);  font-variation-settings: 'opsz' 14, 'SOFT' 60, 'wght' 320;  font-style: italic;  letter-spacing: 0.02em;  margin-bottom: 1.5rem;  font-size: 0.95rem;  color: var(--ash);  margin-bottom: 2rem;  padding-left: 0.05em;}/* ===== FLOW SECTIONS ===== *//* consistent spacing and typography throughout *//* ===== SEASON BLOCK ============================================= */.flow {  margin-bottom: 2.5rem;  transition: opacity 0.4s ease;.season-block {  margin-bottom: 3.5rem;}/* --- season --- */.season-name {  font-family: var(--serif);  font-variation-settings: 'opsz' 144, 'SOFT' 80, 'wght' 280;  font-weight: 300;  font-size: var(--type-season);  font-weight: 400;  color: var(--sprout);  letter-spacing: 0.02em;  line-height: 1.3;  line-height: 1.0;  letter-spacing: -0.015em;  color: var(--ember);  margin-bottom: 1.4rem;  transition: color 2s ease;}.season-note {  color: var(--ash);  font-family: var(--serif);  font-variation-settings: 'opsz' 18, 'SOFT' 70, 'wght' 380;  font-style: italic;  font-size: var(--type-body);  line-height: 1.8;  margin-top: 0.6rem;  font-size: 1.05rem;  line-height: 1.75;  color: var(--bone);  max-width: 30rem;}.season-note p {  margin: 0;  margin-bottom: 0.6rem;}/* --- sky (weather + celestial + moon tip) --- */.flow-sky p {  font-size: var(--type-body);  line-height: 1.9;.season-note p:last-child {  margin-bottom: 0;}.weather-mood-text {  color: var(--ash);/* drop cap on the very first letter — illuminated almanac feel */.season-note > p:first-child::first-letter {  font-family: var(--serif);  font-variation-settings: 'opsz' 144, 'SOFT' 100, 'wght' 320;  font-style: italic;  margin-bottom: 1rem;  font-size: 3.6em;  line-height: 0.85;  float: left;  padding: 0.18em 0.18em 0 0;  margin-right: 0.04em;  color: var(--ember);}.sky-data {  color: var(--bone);}/* ===== HAIKU ==================================================== *//* --- haiku --- */.haiku-block {  margin: 0 auto 4rem;  text-align: center;  padding: 1.5rem 0;  position: relative;}.flow-haiku {.haiku-block::before,.haiku-block::after {  content: '❦';  display: block;  text-align: center;  color: var(--ember);  opacity: 0.35;  font-size: 0.85rem;  line-height: 1;}.flow-haiku blockquote {.haiku-block::before { margin-bottom: 1.4rem; }.haiku-block::after  { margin-top: 1.4rem; }.haiku-block blockquote {  display: inline-block;  text-align: left;  color: var(--ash);  font-family: var(--serif);  font-variation-settings: 'opsz' 24, 'SOFT' 90, 'wght' 320;  font-style: italic;  font-size: var(--type-body);  line-height: 2.2;  letter-spacing: 0.02em;  font-size: 1.08rem;  line-height: 2.1;  color: var(--parchment);  letter-spacing: 0.01em;}.flow-haiku .haiku-line {.haiku-block .haiku-line {  display: block;  white-space: nowrap;}.flow-haiku .haiku-line:nth-child(2) {  padding-left: 1.2rem;}.flow-haiku .haiku-line:nth-child(3) {  padding-left: 2.4rem;}.haiku-block .haiku-line:nth-child(2) { padding-left: 1.4rem; }.haiku-block .haiku-line:nth-child(3) { padding-left: 2.8rem; }/* --- wisdom (merged into narrative) --- *//* ===== BUCKETS (sections) ======================================= * each section: roman numeral counter, small-caps mono label, then * the content. hairline separator above each (instead of below the * heading) so the rule reads as a chapter break. * ================================================================ */.wisdom {  display: none;.sections {  margin-top: 1rem;}/* ===== NARRATIVE (curated items) ===== */.flow-entries {.bucket {  position: relative;  padding-top: 5rem;  margin-bottom: 4.5rem;  transition: opacity 0.4s ease;}.narrative {  color: var(--bone);  font-size: var(--type-body);  line-height: 2;  margin-bottom: 1.5rem;/* the section glyph: a single ornamental character centered above   the section, varied per section so the page reads as a sequence   of small ceremonies rather than a stack of cards. all from the   Dingbats block so they render reliably in any font; system   symbol fonts fall back to cover anything Fraunces lacks. */.bucket::before {  display: block;  text-align: center;  font-family: 'Apple Symbols', 'Segoe UI Symbol', 'Noto Sans Symbols 2',               'Noto Sans Symbols', var(--serif);  font-style: normal;  font-weight: 400;  font-size: 1.7rem;  line-height: 1;  color: var(--ember);  opacity: 0.65;  margin-bottom: 3.4rem;  letter-spacing: 0;  transition: color 1.4s ease, opacity 1.4s ease;}.narrative:last-child {  margin-bottom: 0;}/* assign a glyph per section, all from U+2700-U+27BF Dingbats so   they universally render as monochrome glyphs (never emoji). */.bucket-sky::before      { content: '✶'; }   /* six-pointed star — celestial */.bucket-garden::before   { content: '❀'; }   /* white florette — flower */.bucket-kitchen::before  { content: '❦'; }   /* floral heart — hearth */.bucket-foraging::before { content: '✿'; }   /* black florette — wild plant */.bucket-folklore::before { content: '✦'; }   /* four-pointed star — story */.sep {  color: var(--ember);  opacity: 0.4;  margin: 0 0.5em;  font-style: normal;.bucket:last-child {  margin-bottom: 2rem;}.bucket h2 {  font-family: var(--mono);  font-weight: 400;  font-size: var(--type-meta);  letter-spacing: 0.36em;  text-transform: lowercase;  color: var(--ash);  margin-bottom: 2rem;  text-align: center;  transition: color 0.4s ease;}/* --- bold highlights for scanning --- */.readout strong {.bucket h2:hover {  color: var(--parchment);  font-weight: 600;}/* ===== NAV DRAWER ===== */.nav-drawer {  margin-top: 2.5rem;}/* --- intro line (italic mood for the SKY section) --- */.nav-toggle {  display: inline-block;  color: var(--ash);  font-size: 0.75rem;.bucket-intro {  font-family: var(--serif);  font-variation-settings: 'opsz' 18, 'SOFT' 70, 'wght' 360;  font-style: italic;  letter-spacing: 0.02em;  opacity: 0.4;  cursor: pointer;  padding: 0.6rem 0;  min-height: 44px;  line-height: 44px;  transition: opacity 0.6s ease, color 0.6s ease;  font-size: 1rem;  line-height: 1.7;  color: var(--ash);  margin-bottom: 1.4rem;  max-width: 30rem;}.nav-toggle:hover {  opacity: 0.7;  color: var(--bone);}.nav-drawer-content {  display: none;/* --- group sub-labels ("in the ground now", "tonight", etc) --- */.bucket-label {  font-family: var(--mono);  font-size: var(--type-meta);  font-weight: 400;  letter-spacing: 0.22em;  text-transform: uppercase;  color: var(--ember-warm);  margin-top: 1.6rem;  margin-bottom: 0.7rem;  opacity: 0.75;}.nav-drawer.open .nav-drawer-content {  display: block;.bucket-label:first-child,.bucket .bucket-intro + .bucket-label {  margin-top: 0;}/* ===== NAV STRIPS ===== *//* --- bullet lists --- */.nav-strip {  display: flex;  flex-wrap: wrap;  gap: 0.2rem 0.5rem;  margin-top: 1rem;  opacity: 0.5;  transition: opacity 0.6s ease;.bucket-list {  list-style: none;  padding: 0;  margin: 0 0 0.4rem 0;}.nav-strip:hover,.nav-strip:focus-within {  opacity: 0.8;.bucket-list li {  font-family: var(--serif);  font-variation-settings: 'opsz' 14, 'SOFT' 50, 'wght' 380;  font-size: var(--type-body);  line-height: 1.65;  color: var(--bone);  padding: 0.18rem 0 0.18rem 1.5rem;  position: relative;}.nav-strip a {  color: var(--ash);  text-decoration: none;  font-size: 0.75rem;  font-style: italic;  letter-spacing: 0.02em;  padding: 0.15rem 0;  transition: color 0.6s ease;  cursor: pointer;  opacity: 0;  animation: word-in 0.4s ease-out forwards;.bucket-list li::before {  content: '·';  position: absolute;  left: 0.4rem;  top: 0.18rem;  color: var(--ember);  opacity: 0.55;  font-size: 1.2em;  line-height: 1.4;}.nav-strip a:hover {  color: var(--bone);}.nav-strip a.active {  color: var(--sprout);  text-decoration: underline;  text-underline-offset: 3px;  text-decoration-skip-ink: auto;}/* --- sky data lines (mono, no bullets — the data IS the point) --- */.nav-strip a.active::after {  text-decoration: none;  display: inline-block;.bucket-sky .bucket-list {  font-family: var(--mono);  font-size: 0.86rem;  line-height: 1.95;  letter-spacing: 0.02em;  margin: 0.4rem 0 1rem;  padding: 0.8rem 1.1rem;  background: rgba(140, 100, 50, 0.04);  border-left: 2px solid var(--ember);}.nav-strip a:focus-visible {  outline: 1px solid var(--ember);  outline-offset: 2px;.bucket-sky .bucket-list li {  font-family: var(--mono);  padding: 0;  color: var(--bone);}.nav-strip a.return-now {.bucket-sky .bucket-list li::before { content: ''; }.bucket-sky .bucket-list li strong {  color: var(--ember);  flex-basis: 100%;  font-weight: 500;}.nav-strip a::after {  content: '\00b7';/* --- lore paragraphs (italic prose under each section) --- */.bucket-lore {  font-family: var(--serif);  font-variation-settings: 'opsz' 18, 'SOFT' 70, 'wght' 380;  font-style: italic;  font-size: 1rem;  line-height: 1.8;  color: var(--ash);  margin-left: 0.4rem;  opacity: 0.4;  margin-top: 1.2rem;  max-width: 32rem;}.nav-strip a:last-child::after,.nav-strip a.return-now::after {  content: '';.bucket-lore:first-of-type {  margin-top: 1.4rem;}.times-nav {  justify-content: flex-start;/* ===== STRONG / EMPHASIS ======================================== */.readout strong {  font-variation-settings: 'opsz' 14, 'SOFT' 50, 'wght' 540;  color: var(--parchment);  font-weight: 540;}.times-nav a.return-now {  text-align: left;  margin-top: 0.3rem;.bucket-lore strong {  font-style: italic;  color: var(--ember);}/* ===== FOOTER ===== *//* ===== FOOTER =================================================== */footer {  max-width: 34rem;  max-width: var(--measure);  margin: 0 auto;  padding: 2.5rem 1.5rem 3rem;  padding: 3rem 1.6rem 4rem;  text-align: center;  border-top: 1px solid var(--bark);  border-top: var(--rule);  transition: opacity 0.4s ease, background-color 2s ease;}footer p {.footer-status {  font-family: var(--mono);  font-size: var(--type-meta);  font-weight: 400;  letter-spacing: 0.22em;  text-transform: uppercase;  color: var(--ash);  font-size: 0.8rem;  font-style: italic;  letter-spacing: 0.03em;  opacity: 0.75;}footer .credit {  margin-top: 1rem;  font-size: 0.7rem;  opacity: 0.4;.seasons-nav {  display: flex;  flex-wrap: wrap;  justify-content: center;  gap: 0.4rem 1.1rem;  margin-top: 1.6rem;  opacity: 0.55;  transition: opacity 0.6s ease;}footer .credit:hover {  opacity: 0.7;.seasons-nav:hover,.seasons-nav:focus-within {  opacity: 0.9;}footer .credit a {  color: var(--bone);.seasons-nav a {  font-family: var(--serif);  font-variation-settings: 'opsz' 14, 'SOFT' 70, 'wght' 380;  font-style: italic;  font-size: 0.88rem;  color: var(--ash);  text-decoration: none;  border-bottom: 1px solid var(--bark);  transition: color 0.6s ease, border-color 0.6s ease;  cursor: pointer;  letter-spacing: 0.005em;  transition: color 0.4s ease, font-variation-settings 0.4s ease;  position: relative;}footer .credit a:hover {  color: var(--parchment);  border-color: var(--ash);.seasons-nav a:hover {  color: var(--bone);  font-variation-settings: 'opsz' 14, 'SOFT' 80, 'wght' 460;}.seasons-nav a.active {  color: var(--ember);  font-variation-settings: 'opsz' 14, 'SOFT' 80, 'wght' 460;}/* ===== SHARED ===== */.word {  opacity: 0;  display: inline-block;  animation: word-in 0.3s ease-out forwards;.seasons-nav a:focus-visible {  outline: 1px solid var(--ember);  outline-offset: 4px;}.credit {  margin-top: 2rem;  font-family: var(--mono);  font-size: 0.66rem;  font-weight: 300;  letter-spacing: 0.18em;  text-transform: uppercase;  color: var(--ash);  opacity: 0.45;  transition: opacity 0.6s ease;}/* ===== LAYERS ===== */.credit:hover { opacity: 0.85; }#sky-layer {  position: fixed;  top: 0;  left: 0;  right: 0;  bottom: 0;  pointer-events: none;  z-index: 9998;  transition: background 4s ease;.credit a {  color: var(--bone);  text-decoration: none;  border-bottom: 1px solid rgba(140, 110, 80, 0.25);  padding-bottom: 1px;  transition: color 0.4s ease, border-color 0.4s ease;}#time-layer {  position: fixed;  top: 0;  left: 0;  right: 0;  height: 100vh;  height: 100dvh;  pointer-events: none;  z-index: 9999;  transition: background 4s ease;.credit a:hover {  color: var(--ember);  border-color: var(--ember);}::selection {  background-color: var(--moss);  color: var(--parchment);}/* ===== ANIMATIONS =============================================== *//* ===== ANIMATIONS ===== */.word {  opacity: 0;  display: inline-block;  animation: word-in 0.4s ease-out forwards;}@keyframes word-in {  from {    opacity: 0;    filter: blur(4px);  }  to {    opacity: 1;    filter: blur(0);  }.bucket-list li {  opacity: 0;  animation: word-in 0.45s ease-out forwards;}@keyframes breathe {  0%, 100% { opacity: 0.7; }  50% { opacity: 1; }@keyframes word-in {  from { opacity: 0; filter: blur(4px); transform: translateY(2px); }  to   { opacity: 1; filter: blur(0);   transform: translateY(0); }}@keyframes sway {  0%, 100% { transform: translateX(0); }  30% { transform: translateX(2px); }  70% { transform: translateX(-1px); }@keyframes breathe {  0%, 100% { opacity: 0.65; }  50%      { opacity: 1; }}@media (prefers-reduced-motion: reduce) {  *, *::before, *::after {    animation-duration: 0.01ms !important;    animation-iteration-count: 1 !important;    transition-duration: 0.01ms !important;  }}/* ===== RESPONSIVE ===== *//* ===== RESPONSIVE =============================================== */@media (max-width: 480px) {  html {    font-size: 16px;@media (max-width: 560px) {  html { font-size: 16px; }  .readout { padding: 2.2rem 1.2rem 1.5rem; }  footer   { padding: 2.4rem 1.2rem 3rem; }  .masthead {    font-size: 0.62rem;    letter-spacing: 0.16em;    margin-bottom: 2.2rem;  }  .title-block { margin-bottom: 2.6rem; }  .season-block { margin-bottom: 2.6rem; }  .haiku-block { margin-bottom: 3rem; }  .bucket {    padding-top: 2.2rem;    margin-bottom: 2.6rem;  }  .readout {    padding: 2.5rem 1.2rem 2rem;  .bucket h2 {    letter-spacing: 0.28em;  }  footer {    padding: 2rem 1.2rem 3rem;  .bucket-sky .bucket-list {    font-size: 0.78rem;    padding: 0.7rem 0.9rem;  }  .haiku-block .haiku-line:nth-child(2) { padding-left: 1rem; }  .haiku-block .haiku-line:nth-child(3) { padding-left: 2rem; }}
modified templates/index.html
@@ -6,7 +6,7 @@  <title>dark furrow / old knowledge preserved</title>  <meta name="description" content="a seasonal almanac of planting, harvest, moon phases, and forgotten rhythms. what to grow, what to cook, what the sky is doing. tended by hand for zone 7a.">  <meta name="author" content="isaac bythewood">  <meta name="theme-color" content="#0e0c0a">  <meta name="theme-color" content="#16110d">  <meta name="color-scheme" content="dark">  <!-- open graph -->
@@ -29,51 +29,49 @@  <meta name="generator" content="hand and soil">  <link rel="canonical" href="https://darkfurrow.com">  <link rel="icon" href="/static/favicon.svg" type="image/svg+xml">  <!-- typography is self-hosted from /static/fonts/ via @font-face in       style.css. no third-party font cdn calls at runtime. -->  <link rel="stylesheet" href="/static/style.css"></head><body data-time="{{ time_key }}" data-season="{{ season_key }}">  <div class="glow" aria-hidden="true"></div>  <div id="sky-layer"></div>  <div id="time-layer"></div>  <div class="grain" aria-hidden="true"></div>  <main>    <article class="readout">      <p class="date-line">{{ date_line }}</p>      <header class="readout-header">        <h1>dark furrow</h1>        <p class="subtitle">old knowledge preserved</p>      <header class="masthead">        <span class="masthead-mark">MMXXVI</span>        <span class="masthead-mark">ZONE&nbsp;VII·A</span>        <span class="masthead-mark">35°N</span>      </header>      <section class="flow flow-season">        <p class="season-name">{{ season_name }}</p>        <div class="season-note">{{ season_note|safe }}</div>      <section class="title-block">        <h1 class="wordmark">dark furrow</h1>        <p class="wordmark-sub">old knowledge preserved</p>      </section>      <section class="flow flow-sky">        <p class="weather-mood-text">{{ weather_mood|safe }}</p>        <p class="sky-data">{{ sky_data|safe }}</p>      <p class="date-line">{{ date_line }}</p>      <section class="season-block">        <p class="season-name">{{ season_name }}</p>        <div class="season-note">{{ season_note|safe }}</div>      </section>      <section class="flow flow-haiku">      <section class="haiku-block">        <blockquote>{{ haiku_html|safe }}</blockquote>      </section>      <section class="wisdom"></section>      <section class="flow flow-entries">{{ narrative_html|safe }}</section>      <div class="nav-drawer">        <a class="nav-toggle">...explore</a>        <div class="nav-drawer-content">          <nav class="nav-strip seasons-nav">{{ season_nav_html|safe }}</nav>          <nav class="nav-strip times-nav">{{ time_nav_html|safe }}</nav>        </div>      </div>      <div class="sections">{{ sections_html|safe }}</div>    </article>    <footer>      <p>{{ footer_text }}</p>      <p class="footer-status">{{ footer_text }}</p>      <nav class="seasons-nav">{{ season_nav_html|safe }}</nav>      <p class="credit">a project by <a href="https://isaacbythewood.com">isaac bythewood</a></p>    </footer>  </main>