heartwood every commit a ring

the page finds its roots in flask

aa5d8c95 by Isaac Bythewood · 1 month ago

the page finds its roots in flask

static site becomes a flask application. all content assembly,
markdown rendering, moon phases, and daylight calculations move
to python. javascript stays only for color, motion, and navigation.
content data extracted from code into markdown files. dependencies
managed by uv with mistune for proper markdown parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
modified .gitignore
@@ -1,2 +1,5 @@.env__pycache__/.venv/.playwright-mcp/*.png
modified CLAUDE.md
@@ -20,13 +20,29 @@ page that breathes with the seasons.## Technical notes- No frameworks unless absolutely necessary. Prefer plain HTML, CSS, and  vanilla JavaScript.- No build steps if possible. Just files that work.- The site will live at darkfurrow.com.- Flask backend with Jinja2 templates. Python handles all content  assembly, calculations, and markdown rendering (via mistune).- Client-side JavaScript is for presentation only: color palette,  animations, navigation interactions, and auto-refresh.- Dependencies managed with uv. See pyproject.toml.- All content data lives in markdown files under data/. No hardcoded  content in Python code.- `make run` starts the Flask dev server on 0.0.0.0:8000.- 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.## Development tools- Playwright MCP is available for browser testing. Use it to take  screenshots and verify visual changes after modifying styles,  templates, or content. Start the dev server first with `make run`.- Clean up screenshot files (*.png) after reviewing them. Delete them  once you have confirmed the result to avoid clutter in the project  directory.- The dev environment runs inside a Docker container with port 8000  mapped to the host.## Content the page should surface- What's in season to plant and harvest right now
modified Dockerfile
@@ -1,11 +1,20 @@FROM python:3.13-alpineCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/RUN addgroup -S -g 1000 app && \    adduser -S -h /app -s /sbin/nologin -u 1000 -G app app && \    chown -R app:app /app    adduser -S -h /app -s /sbin/nologin -u 1000 -G app appWORKDIR /appCOPY site/ /app/site/COPY pyproject.toml uv.lock ./RUN uv sync --frozen --no-devCOPY app.py almanac.py ./COPY data/ ./data/COPY static/ ./static/COPY templates/ ./templates/RUN chown -R app:app /appUSER app
modified Makefile
@@ -1,7 +1,7 @@.PHONY: run pushrun:	python3 -m http.server 8000 --directory site	uv run flask --app app run --host 0.0.0.0 --port 8000 --debugpush:	git push origin master
added almanac.py
@@ -0,0 +1,672 @@"""almanac.pythe engine beneath the soil.reads the clock and the calendar, assembles what belongs to this moment."""import jsonimport mathimport osimport reimport mistuneLAT = 35.78  # north carolina, zone 7a_md = mistune.create_markdown()def render_md(text):    """render markdown to html, stripping outer <p> tags for inline use."""    html = _md(text).strip()    # strip single wrapping <p>...</p> for inline contexts    if html.startswith('<p>') and html.endswith('</p>') and html.count('<p>') == 1:        html = html[3:-4]    return htmldef render_md_block(text):    """render markdown to html, keeping block-level tags."""    return _md(text).strip()# --- seeded randomness ---# same picks all day, different tomorrowdef day_hash(date):    doy = date.timetuple().tm_yday    return date.year * 1000 + doydef _imul(a, b):    """replicate javascript Math.imul: 32-bit integer multiply."""    a &= 0xFFFFFFFF    b &= 0xFFFFFFFF    ah = (a >> 16) & 0xFFFF    al = a & 0xFFFF    bh = (b >> 16) & 0xFFFF    bl = b & 0xFFFF    result = (al * bl) + (((ah * bl + al * bh) & 0xFFFF) << 16)    return result & 0xFFFFFFFFdef _to_signed32(n):    n &= 0xFFFFFFFF    if n >= 0x80000000:        return n - 0x100000000    return ndef seeded_random(seed):    s = _to_signed32(seed)    def next_val():        nonlocal s        s = _to_signed32(s + 0x6D2B79F5)        t = _imul((s ^ ((s & 0xFFFFFFFF) >> 15)), (1 | s) & 0xFFFFFFFF)        t = _to_signed32(t)        t = _to_signed32(t + _to_signed32(_imul(            (t ^ ((t & 0xFFFFFFFF) >> 7)) & 0xFFFFFFFF,            (61 | t) & 0xFFFFFFFF        )))        t = t ^ ((t & 0xFFFFFFFF) >> 14)        return (t & 0xFFFFFFFF) / 4294967296    return next_valdef pick_items(lst, count, rng):    if len(lst) <= count:        return list(lst)    copy = list(lst)    result = []    for _ in range(count):        idx = int(rng() * len(copy))        result.append(copy[idx])        del copy[idx]    return result# --- time ---TIMES = [    {'name': 'night',     'start': 0,  'end': 5},    {'name': 'dawn',      'start': 5,  'end': 8},    {'name': 'morning',   'start': 8,  'end': 12},    {'name': 'afternoon', 'start': 12, 'end': 17},    {'name': 'evening',   'start': 17, 'end': 21},    {'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:        if t['start'] <= h < t['end']:            return t['name']    return 'night'# --- markdown parsing ---def parse_frontmatter(text):    match = re.match(r'^---\n(.*?)\n---\n(.*)$', text, re.DOTALL)    if not match:        return {'meta': {}, 'body': text}    meta = {}    for line in match.group(1).split('\n'):        parts = line.split(':', 1)        if len(parts) == 2:            meta[parts[0].strip()] = parts[1].strip()    return {'meta': meta, 'body': match.group(2).strip()}def parse_list_items(body):    lines = body.split('\n')    bullets = []    prose = []    in_prose = False    current_prose = ''    for line in lines:        trimmed = line.strip()        if trimmed.startswith('- '):            if in_prose and current_prose:                prose.append(current_prose.strip())                current_prose = ''                in_prose = False            bullets.append(trimmed[2:])        elif trimmed == '':            if in_prose and current_prose:                prose.append(current_prose.strip())                current_prose = ''                in_prose = False        else:            in_prose = True            current_prose += (' ' if current_prose else '') + trimmed    if in_prose and current_prose:        prose.append(current_prose.strip())    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):    from datetime import datetime, timezone    known = datetime(2000, 1, 6, 18, 14, 0, tzinfo=timezone.utc)    synodic = 29.53058867    diff = (date.timestamp() - known.timestamp()) / 86400    return ((diff % synodic) + synodic) % synodicdef moon_name(phase):    if phase < 1.85:        return 'new moon'    if phase < 7.38:        return 'waxing crescent'    if phase < 9.23:        return 'first quarter'    if phase < 14.77:        return 'waxing gibbous'    if phase < 16.61:        return 'full moon'    if phase < 22.15:        return 'waning gibbous'    if phase < 23.99:        return 'last quarter'    if phase < 27.68:        return 'waning crescent'    return 'new moon'def moon_illumination(phase):    return (1 - math.cos(2 * math.pi * phase / 29.53058867)) / 2def daylight_hours(date, lat=LAT):    doy = date.timetuple().tm_yday    import calendar    year_len = 366 if calendar.isleap(date.year) else 365    decl = 23.45 * math.sin((2 * math.pi / year_len) * (doy - 81))    cos_h = -math.tan(math.radians(lat)) * math.tan(math.radians(decl))    cos_h = max(-1, min(1, cos_h))    return (2 * math.degrees(math.acos(cos_h))) / 15def format_hm(hours):    h = int(hours)    m = round((hours - h) * 60)    return f'{h}h {m}m'def format_clock(hours):    h = int(hours)    m = round((hours - h) * 60)    if m == 60:        h += 1        m = 0    suffix = 'pm' if h >= 12 else 'am'    display = h - 12 if h > 12 else (12 if h == 0 else h)    return f'{display}:{m:02d} {suffix}'MONTHS = [    'january', 'february', 'march', 'april', 'may', 'june',    'july', 'august', 'september', 'october', 'november', 'december',]ORDINALS = [    '', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth',    'seventh', 'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth',    'thirteenth', 'fourteenth', 'fifteenth', 'sixteenth', 'seventeenth',    'eighteenth', 'nineteenth', 'twentieth', 'twenty-first', 'twenty-second',    'twenty-third', 'twenty-fourth', 'twenty-fifth', 'twenty-sixth',    'twenty-seventh', 'twenty-eighth', 'twenty-ninth', 'thirtieth',    'thirty-first',]def written_date(date):    time = get_time_of_day(date)    return f'{time}, the {ORDINALS[date.day]} of {MONTHS[date.month - 1]}'def sky_text(now, time):    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    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)# --- data loading ---# all content lives in markdown files under data/def _parse_date(s):    """parse 'm/d' into (month, day) tuple."""    parts = s.split('/')    return (int(parts[0]), int(parts[1]))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'):            continue        with open(os.path.join(seasons_dir, filename)) as f:            parsed = parse_frontmatter(f.read())        meta = parsed['meta']        name = meta['name']        note = parsed['body'].strip()        entry = {            'name': name,            'label': meta['label'],            'start': _parse_date(meta['start']),            'end': _parse_date(meta['end']),            '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:            seasons.append({                'name': name,                'label': meta['label'],                'start': _parse_date(meta['start-alt']),                'end': _parse_date(meta['end-alt']),                'note': note,            })    # sort by start month/day so lookup order is correct    seasons.sort(key=lambda s: (s['start'][0], s['start'][1]))    return seasons, seendef load_haiku(data_dir):    """load haiku from data/haiku/*.md"""    haiku_dir = os.path.join(data_dir, 'haiku')    haiku = {}    for filename in os.listdir(haiku_dir):        if not filename.endswith('.md'):            continue        with open(os.path.join(haiku_dir, filename)) as f:            parsed = parse_frontmatter(f.read())        season = parsed['meta']['season']        poems = []        for block in parsed['body'].split('---'):            lines = [l.strip() for l in block.strip().split('\n') if l.strip()]            if len(lines) == 3:                poems.append(lines)        haiku[season] = poems    return haikudef load_moods(data_dir):    """load weather moods from data/moods/*.md"""    moods_dir = os.path.join(data_dir, 'moods')    moods = {}    for filename in os.listdir(moods_dir):        if not filename.endswith('.md'):            continue        with open(os.path.join(moods_dir, filename)) as f:            parsed = parse_frontmatter(f.read())        season = parsed['meta']['season']        season_moods = {}        for line in parsed['body'].split('\n'):            line = line.strip()            if line.startswith('- '):                line = line[2:]                colon = line.index(':')                time_name = line[:colon].strip()                mood_text = line[colon + 1:].strip()                season_moods[time_name] = mood_text        moods[season] = season_moods    return moodsdef load_moon_tips(data_dir):    """load moon gardening tips from data/moon-tips.md"""    path = os.path.join(data_dir, 'moon-tips.md')    with open(path) as f:        parsed = parse_frontmatter(f.read())    tips = []    for line in parsed['body'].split('\n'):        line = line.strip()        if not line.startswith('- '):            continue        line = line[2:]        colon = line.index(':')        range_str = line[:colon].strip()        tip_text = line[colon + 1:].strip()        lo, hi = range_str.split('-')        tips.append((float(lo), float(hi), tip_text))    return tipsdef load_data(data_dir):    """load manifest, markdown files, and all structured content."""    manifest_path = os.path.join(data_dir, 'manifest.json')    with open(manifest_path) as f:        manifest = json.load(f)    files = {}    for entry in manifest:        path = os.path.join(data_dir, entry['path'])        try:            with open(path) as f:                files[entry['path']] = f.read()        except FileNotFoundError:            pass    seasons, seasons_map = load_seasons(data_dir)    haiku = load_haiku(data_dir)    moods = load_moods(data_dir)    moon_tips = load_moon_tips(data_dir)    return {        'manifest': manifest,        'files': files,        'seasons': seasons,        'seasons_map': seasons_map,        'haiku': haiku,        'moods': moods,        'moon_tips': moon_tips,    }# --- season lookup ---def get_season(date, seasons):    m = date.month    d = date.day    for s in seasons:        after_start = m > s['start'][0] or (m == s['start'][0] and d >= s['start'][1])        before_end = m < s['end'][0] or (m == s['end'][0] and d <= s['end'][1])        if after_start and before_end:            return s    return seasons[0]def get_season_by_name(name, seasons_map):    return seasons_map.get(name, list(seasons_map.values())[0])def days_until_next_season(date, seasons):    from datetime import datetime    current = get_season(date, seasons)    for s in seasons:        s_date = datetime(date.year, s['start'][0], s['start'][1])        if s_date.date() > date.date() and s['name'] != current['name']:            diff = (s_date.date() - date.date()).days            return {'days': diff, 'label': s['label']}    first = seasons[0]    next_date = datetime(date.year + 1, first['start'][0], first['start'][1])    return {'days': (next_date.date() - date.date()).days, 'label': first['label']}# --- haiku lookup ---def get_haiku(season_name, date, haiku):    poems = haiku.get(season_name)    if not poems:        return None    doy = date.timetuple().tm_yday    return poems[doy % len(poems)]# --- mood lookup ---def get_weather_mood(season_name, time, moods):    season_moods = moods.get(season_name, {})    text = season_moods.get(time, '')    return render_md(text) if text else ''# --- moon garden tip lookup ---def moon_garden_tip(phase, moon_tips):    for lo, hi, tip in moon_tips:        if lo <= phase < hi:            return tip    return moon_tips[-1][2] if moon_tips else ''# --- content assembly ---def assemble_content(now, data, season_override=None, time_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))    # 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)    return {        'date_line': date_line,        '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,        'footer_text': footer_text,        'season_nav_html': season_nav_html,        'time_nav_html': time_nav_html,    }# --- navigation ---def build_season_nav(active_season, seasons_map):    html = ''    for name, s in seasons_map.items():        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
added app.py
@@ -0,0 +1,37 @@"""app.pythe root that holds the page together."""import osfrom datetime import datetimefrom flask import Flask, jsonify, render_template, requestfrom almanac import assemble_content, load_dataapp = Flask(__name__)DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')DATA = load_data(DATA_DIR)@app.route('/')def index():    now = datetime.now()    content = assemble_content(now, DATA)    return render_template('index.html', **content)@app.route('/api/content')def api_content():    now = datetime.now()    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,    )    return jsonify(content)
renamed site/data/bugs/early-fall.md → data/bugs/early-fall.md
renamed site/data/bugs/early-spring.md → data/bugs/early-spring.md
renamed site/data/bugs/early-summer.md → data/bugs/early-summer.md
renamed site/data/bugs/late-fall.md → data/bugs/late-fall.md
renamed site/data/bugs/late-spring.md → data/bugs/late-spring.md
renamed site/data/bugs/midsummer.md → data/bugs/midsummer.md
renamed site/data/bugs/winter.md → data/bugs/winter.md
renamed site/data/chores/early-fall.md → data/chores/early-fall.md
renamed site/data/chores/early-spring.md → data/chores/early-spring.md
renamed site/data/chores/early-summer.md → data/chores/early-summer.md
renamed site/data/chores/late-fall.md → data/chores/late-fall.md
renamed site/data/chores/late-spring.md → data/chores/late-spring.md
renamed site/data/chores/midsummer.md → data/chores/midsummer.md
renamed site/data/chores/winter.md → data/chores/winter.md
renamed site/data/foraging/early-fall.md → data/foraging/early-fall.md
renamed site/data/foraging/early-spring.md → data/foraging/early-spring.md
renamed site/data/foraging/early-summer.md → data/foraging/early-summer.md
renamed site/data/foraging/late-fall.md → data/foraging/late-fall.md
renamed site/data/foraging/late-spring.md → data/foraging/late-spring.md
renamed site/data/foraging/midsummer.md → data/foraging/midsummer.md
renamed site/data/foraging/winter.md → data/foraging/winter.md
added data/haiku/early-fall.md
@@ -0,0 +1,19 @@---season: early-fall---the first cool morningi can see my breath againthe garden exhales---one red leaf fallingthrough the still september airlanding in my palm---the garlic goes inan act of faith in the darksee you in the spring
added data/haiku/early-spring.md
@@ -0,0 +1,19 @@---season: early-spring---the old dark furrowfills with rain and waits for warmthsomething stirs below---cold mud on my handsthe first row planted beforethe sparrows arrive---fog lifts from the creekrevealing green where there wasnothing just last week
added data/haiku/early-summer.md
@@ -0,0 +1,19 @@---season: early-summer---cicadas tuningtheir one long note in the oaksthe heat has arrived---the tomato vineclimbs past the stake i gave itreaching for the sun---lightning bugs at duskeach one a small question askedthen answered in dark
added data/haiku/late-fall.md
@@ -0,0 +1,19 @@---season: late-fall---bare branch on bare branchthe crow arrives without soundthe sky turns away---the last harvest donei clean the blade and hang iton its proper nail---wind through empty stalksplaying the garden like someforgotten instrument
added data/haiku/late-spring.md
@@ -0,0 +1,19 @@---season: late-spring---petals on the paththe bees have already foundwhat i just planted---warm rain after dawnthe lettuce grows so quicklyi cannot keep up---the frogs resume theirevening argument likeold familiar friends
added data/haiku/midsummer.md
@@ -0,0 +1,19 @@---season: midsummer---the garden gives morethan i can carry insidethe table overflows---shade beneath the oakthe only cool place to sitthe dog already knows---thunder in the westthe corn stands perfectly stillwaiting for the rain
added data/haiku/winter.md
@@ -0,0 +1,19 @@---season: winter---the winter stillnessa woodpecker searching throughthe hollow silence---bare frozen furrowshold the memory of seedbeneath the long dark---woodsmoke ascendingthrough the grey unmoving skythe kettle whistles
renamed site/data/kitchen/early-fall.md → data/kitchen/early-fall.md
renamed site/data/kitchen/early-spring.md → data/kitchen/early-spring.md
renamed site/data/kitchen/early-summer.md → data/kitchen/early-summer.md
renamed site/data/kitchen/late-fall.md → data/kitchen/late-fall.md
renamed site/data/kitchen/late-spring.md → data/kitchen/late-spring.md
renamed site/data/kitchen/midsummer.md → data/kitchen/midsummer.md
renamed site/data/kitchen/winter.md → data/kitchen/winter.md
renamed site/data/manifest.json → data/manifest.json
added data/moods/early-fall.md
@@ -0,0 +1,9 @@---season: early-fall---- dawn: cool at last. real cool. the first morning you can see your breath.- morning: crisp and blue. the clearest skies of the year.- afternoon: warm but not punishing. the light has that golden slant to it.- evening: cool enough for a fire. the smell of leaves starting.- night: cold and clear. you can see more stars now that the haze is gone.
added data/moods/early-spring.md
@@ -0,0 +1,9 @@---season: early-spring---- dawn: cold still, but softer than last month. fog in the low places.- morning: cool and damp. the kind of morning that smells like turned earth.- afternoon: mild with a chance of anything. spring weather changes its mind.- evening: cooling fast once the sun drops. a jacket you almost left behind.- night: still cold enough to frost. the season is not settled yet.
added data/moods/early-summer.md
@@ -0,0 +1,9 @@---season: early-summer---- dawn: warm and humid before the sun is even up. it will be a hot one.- morning: hazy. the heat is already building. do what you need to do early.- afternoon: the full weight of it. cicadas. shade is the only mercy.- evening: still warm but the light softens. lightning bugs in the grass.- night: heavy air. the kind of night that does not cool down.
added data/moods/late-fall.md
@@ -0,0 +1,9 @@---season: late-fall---- dawn: raw and grey. the trees are bare. wind with nothing to stop it.- morning: cold rain or cold sun, both are possible. dress for both.- afternoon: short. the light is leaving by four. make use of what remains.- evening: dark already. the wind sounds different through bare branches.- night: the kind of cold that makes the house feel smaller and warmer.
added data/moods/late-spring.md
@@ -0,0 +1,9 @@---season: late-spring---- dawn: warm already. dew on everything. the birds are unreasonably loud.- morning: bright and easy. the kind of morning that makes you go outside.- afternoon: warm, sometimes hot. thunderstorms build in the west by three.- evening: long golden light. warm enough to sit outside and do nothing.- night: mild, finally. windows open. the frogs are singing.
added data/moods/midsummer.md
@@ -0,0 +1,9 @@---season: midsummer---- dawn: the air is thick before sunrise. everything is already sweating.- morning: blazing. the garden wilts by ten. water it or lose it.- afternoon: oppressive. thunderheads pile up. the storms when they come are violent and brief.- evening: a little relief if the storms came through. if not, you wait.- night: warm and loud with insects. sleep with the windows open or don't sleep.
added data/moods/winter.md
@@ -0,0 +1,9 @@---season: winter---- dawn: the cold is sharpest now. frost on everything. the air tastes like iron.- morning: grey and still. the kind of cold that settles in the bones and stays.- afternoon: thin winter light, already angling toward evening. the air is dry and quiet.- evening: the dark comes early and completely. woodsmoke from somewhere.- night: clear and bitter, or clouded and raw. either way, stay in.
added data/moon-tips.md
@@ -0,0 +1,12 @@---section: moon gardening---- 0-3.7: the moon is dark. this is a time for **rest and planning**. prepare beds, amend soil, but do not plant. the old farmers said nothing wants to start in the dark.- 3.7-7.4: the moon is waxing. **plant leafy things**: lettuce, spinach, cabbage, herbs that grow above ground. the light is growing and pulls the energy upward.- 7.4-11.1: the moon is in its first quarter. **plant things that fruit**: tomatoes, peppers, beans, squash. the increasing light favors strong stems and heavy fruit.- 11.1-14.8: the moon is nearly full. **transplant, fertilize, graft**. the light is strongest and the sap is rising. a good time to move plants and feed the soil.- 14.8-18.5: the moon is full. **plant root crops**: carrots, potatoes, beets, onions, garlic. the energy is pulling downward now. bulbs and perennials go in well under a full moon.- 18.5-22.1: the moon is waning. **harvest what is ready**, cut herbs for drying, prune what needs shaping. the energy is drawing inward. what you cut now heals faster.- 22.1-25.8: the moon is in its last quarter. **pull weeds, turn compost**, cultivate the soil. this is a killing time, good for destroying what you do not want. the weeds will not come back as fast.- 25.8-29.6: the moon is a thin crescent, almost gone. **do not plant**. rest, clean tools, plan the next cycle. the old almanacs left these days blank on purpose.
renamed site/data/names/early-fall.md → data/names/early-fall.md
renamed site/data/names/early-spring.md → data/names/early-spring.md
renamed site/data/names/early-summer.md → data/names/early-summer.md
renamed site/data/names/late-fall.md → data/names/late-fall.md
renamed site/data/names/late-spring.md → data/names/late-spring.md
renamed site/data/names/midsummer.md → data/names/midsummer.md
renamed site/data/names/winter.md → data/names/winter.md
renamed site/data/planting/early-fall.md → data/planting/early-fall.md
renamed site/data/planting/early-spring-indoors.md → data/planting/early-spring-indoors.md
renamed site/data/planting/early-spring.md → data/planting/early-spring.md
renamed site/data/planting/early-summer.md → data/planting/early-summer.md
renamed site/data/planting/late-fall.md → data/planting/late-fall.md
renamed site/data/planting/late-spring.md → data/planting/late-spring.md
renamed site/data/planting/midsummer.md → data/planting/midsummer.md
renamed site/data/planting/winter.md → data/planting/winter.md
renamed site/data/remedies/early-fall.md → data/remedies/early-fall.md
renamed site/data/remedies/early-spring.md → data/remedies/early-spring.md
renamed site/data/remedies/early-summer.md → data/remedies/early-summer.md
renamed site/data/remedies/late-fall.md → data/remedies/late-fall.md
renamed site/data/remedies/late-spring.md → data/remedies/late-spring.md
renamed site/data/remedies/midsummer.md → data/remedies/midsummer.md
renamed site/data/remedies/winter.md → data/remedies/winter.md
added data/seasons/early-fall.md
@@ -0,0 +1,10 @@---name: early-falllabel: early fallstart: 9/1end: 10/31---the light is leaving but slowly.mornings are cool again. the garden exhales.
added data/seasons/early-spring.md
@@ -0,0 +1,10 @@---name: early-springlabel: early springstart: 3/1end: 4/15---the soil warms. the light returns longer each day.what was sleeping is not sleeping anymore.
added data/seasons/early-summer.md
@@ -0,0 +1,10 @@---name: early-summerlabel: early summerstart: 6/1end: 6/30---the days are longest. the heat is building.the garden is a job now, not a hope.
added data/seasons/late-fall.md
@@ -0,0 +1,10 @@---name: late-falllabel: late fallstart: 11/1end: 11/30---the trees are bare or nearly.the first frost has come or is coming tonight.
added data/seasons/late-spring.md
@@ -0,0 +1,10 @@---name: late-springlabel: late springstart: 4/16end: 5/31---the frost is gone or nearly gone.everything is rushing now. the green is loud.
added data/seasons/midsummer.md
@@ -0,0 +1,10 @@---name: midsummerlabel: midsummerstart: 7/1end: 8/31---the full weight of summer.everything is ripe or ripening or done.
added data/seasons/winter.md
@@ -0,0 +1,12 @@---name: winterlabel: winterstart: 1/1end: 2/28start-alt: 12/1end-alt: 12/31---the ground is still. the light is short.rest is not emptiness, it is preparation.
renamed site/data/sky/early-fall.md → data/sky/early-fall.md
renamed site/data/sky/early-spring.md → data/sky/early-spring.md
renamed site/data/sky/early-summer.md → data/sky/early-summer.md
renamed site/data/sky/late-fall.md → data/sky/late-fall.md
renamed site/data/sky/late-spring.md → data/sky/late-spring.md
renamed site/data/sky/midsummer.md → data/sky/midsummer.md
renamed site/data/sky/winter.md → data/sky/winter.md
renamed site/data/storms/early-fall.md → data/storms/early-fall.md
renamed site/data/storms/early-spring.md → data/storms/early-spring.md
renamed site/data/storms/early-summer.md → data/storms/early-summer.md
renamed site/data/storms/late-fall.md → data/storms/late-fall.md
renamed site/data/storms/late-spring.md → data/storms/late-spring.md
renamed site/data/storms/midsummer.md → data/storms/midsummer.md
renamed site/data/storms/winter.md → data/storms/winter.md
renamed site/data/wisdom/afternoon.md → data/wisdom/afternoon.md
renamed site/data/wisdom/dawn.md → data/wisdom/dawn.md
renamed site/data/wisdom/evening.md → data/wisdom/evening.md
renamed site/data/wisdom/morning.md → data/wisdom/morning.md
renamed site/data/wisdom/night.md → data/wisdom/night.md
modified docker-compose.yml
@@ -5,5 +5,5 @@ services:    env_file: .env    ports:      - "127.0.0.1:${PORT}:${PORT}"    command: python3 -m http.server ${PORT} --directory site    command: uv run gunicorn -b 0.0.0.0:${PORT} -w 2 app:app    restart: unless-stopped
added pyproject.toml
@@ -0,0 +1,9 @@[project]name = "darkfurrow"version = "0.1.0"requires-python = ">=3.13"dependencies = [    "flask>=3.1",    "gunicorn>=23.0",    "mistune>=3.1",]
deleted site/almanac.js
@@ -1,932 +0,0 @@// almanac.js//// the engine beneath the soil.// reads the clock and the calendar, fetches what is relevant,// and renders the page that belongs to this moment.(function () {  'use strict';  var LAT = 35.78; // north carolina, zone 7a  var ANIM_WORD_DELAY = 18;  var ANIM_LIST_DELAY = 60;  var ANIM_NAV_DELAY = 80;  // --- seeded randomness ---  // same picks all day, different tomorrow  function dayHash(date) {    var doy = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 86400000);    return date.getFullYear() * 1000 + doy;  }  function seededRandom(seed) {    var s = seed | 0;    return function () {      s = (s + 0x6D2B79F5) | 0;      var t = Math.imul(s ^ (s >>> 15), 1 | s);      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;    };  }  function pickItems(list, count, rng) {    if (list.length <= count) return list.slice();    var copy = list.slice();    var result = [];    for (var i = 0; i < count; i++) {      var idx = Math.floor(rng() * copy.length);      result.push(copy[idx]);      copy.splice(idx, 1);    }    return result;  }  function parseListItems(body) {    var lines = body.split('\n');    var bullets = [];    var prose = [];    var inProse = false;    var currentProse = '';    for (var i = 0; i < lines.length; i++) {      var trimmed = lines[i].trim();      if (trimmed.match(/^- /)) {        if (inProse && currentProse) {          prose.push(currentProse.trim());          currentProse = '';          inProse = false;        }        bullets.push(trimmed.slice(2));      } else if (trimmed === '') {        if (inProse && currentProse) {          prose.push(currentProse.trim());          currentProse = '';          inProse = false;        }      } else {        inProse = true;        currentProse += (currentProse ? ' ' : '') + trimmed;      }    }    if (inProse && currentProse) {      prose.push(currentProse.trim());    }    return { bullets: bullets, prose: prose };  }  function highlightText(text) {    // split into sentence fragments on ". " boundaries    var fragments = text.split(/(?<=\.)\s+/);    return fragments.map(function (frag) {      var trimmed = frag.trim();      if (!trimmed) return '';      var words = trimmed.split(/\s+/);      // short fragments (4 words or fewer): bold the whole thing      if (words.length <= 4) {        return '<strong>' + trimmed + '</strong>';      }      // if there's an early comma, bold up to it      var comma = trimmed.indexOf(',');      if (comma > 0 && comma < 30) {        return '<strong>' + trimmed.slice(0, comma) + '</strong>' + trimmed.slice(comma);      }      // otherwise bold the first 2-3 words (3 if first word is an article/conjunction)      var count = /^(the|a|an|if|when|it|and|or|but|do|in)$/i.test(words[0]) ? 3 : 2;      count = Math.min(count, words.length);      return '<strong>' + words.slice(0, count).join(' ') + '</strong> ' + words.slice(count).join(' ');    }).join(' ');  }  // --- time and season ---  var SEASONS = [    { name: 'winter',       start: [1, 1],   end: [2, 28],  label: 'winter',       note: 'the ground is still. the light is short.\nrest is not emptiness, it is preparation.' },    { name: 'early-spring', start: [3, 1],   end: [4, 15],  label: 'early spring',  note: 'the soil warms. the light returns longer each day.\nwhat was sleeping is not sleeping anymore.' },    { name: 'late-spring',  start: [4, 16],  end: [5, 31],  label: 'late spring',   note: 'the frost is gone or nearly gone.\neverything is rushing now. the green is loud.' },    { name: 'early-summer', start: [6, 1],   end: [6, 30],  label: 'early summer',  note: 'the days are longest. the heat is building.\nthe garden is a job now, not a hope.' },    { name: 'midsummer',    start: [7, 1],   end: [8, 31],  label: 'midsummer',     note: 'the full weight of summer.\neverything is ripe or ripening or done.' },    { name: 'early-fall',   start: [9, 1],   end: [10, 31], label: 'early fall',    note: 'the light is leaving but slowly.\nmornings are cool again. the garden exhales.' },    { name: 'late-fall',    start: [11, 1],  end: [11, 30],  label: 'late fall',     note: 'the trees are bare or nearly.\nthe first frost has come or is coming tonight.' },    { name: 'winter',       start: [12, 1],  end: [12, 31], label: 'winter',        note: 'the ground is still. the light is short.\nrest is not emptiness, it is preparation.' }  ];  var TIMES = [    { name: 'night',     start: 0,  end: 5  },    { name: 'dawn',      start: 5,  end: 8  },    { name: 'morning',   start: 8,  end: 12 },    { name: 'afternoon', start: 12, end: 17 },    { name: 'evening',   start: 17, end: 21 },    { name: 'night',     start: 21, end: 24 }  ];  function getSeason(date) {    var m = date.getMonth() + 1;    var d = date.getDate();    for (var i = 0; i < SEASONS.length; i++) {      var s = SEASONS[i];      var afterStart = m > s.start[0] || (m === s.start[0] && d >= s.start[1]);      var beforeEnd = m < s.end[0] || (m === s.end[0] && d <= s.end[1]);      if (afterStart && beforeEnd) return s;    }    return SEASONS[0];  }  function getTimeOfDay(date) {    var h = date.getHours();    for (var i = 0; i < TIMES.length; i++) {      if (h >= TIMES[i].start && h < TIMES[i].end) return TIMES[i].name;    }    return 'night';  }  // which content categories belong to each time of day  var TIME_CONTENT = {    night:     ['sky/', 'names/', 'remedies/', 'storms/'],    dawn:      ['planting/', 'foraging/'],    morning:   ['planting/', 'chores/', 'bugs/'],    afternoon: ['kitchen/', 'bugs/', 'chores/'],    evening:   ['kitchen/', 'remedies/', 'foraging/', 'names/', 'storms/']  };  var TIME_LABELS = ['night', 'dawn', 'morning', 'afternoon', 'evening'];  // --- haiku ---  // real poems. the old masters wrote about exactly this.  var HAIKU = {    'winter': [      { lines: ['the winter stillness\na woodpecker searching through\nthe hollow silence'] },      { lines: ['bare frozen furrows\nhold the memory of seed\nbeneath the long dark'] },      { lines: ['woodsmoke ascending\nthrough the grey unmoving sky\nthe kettle whistles'] }    ],    'early-spring': [      { lines: ['the old dark furrow\nfills with rain and waits for warmth\nsomething stirs below'] },      { lines: ['cold mud on my hands\nthe first row planted before\nthe sparrows arrive'] },      { lines: ['fog lifts from the creek\nrevealing green where there was\nnothing just last week'] }    ],    'late-spring': [      { lines: ['petals on the path\nthe bees have already found\nwhat i just planted'] },      { lines: ['warm rain after dawn\nthe lettuce grows so quickly\ni cannot keep up'] },      { lines: ['the frogs resume their\nevening argument like\nold familiar friends'] }    ],    'early-summer': [      { lines: ['cicadas tuning\ntheir one long note in the oaks\nthe heat has arrived'] },      { lines: ['the tomato vine\nclimbs past the stake i gave it\nreaching for the sun'] },      { lines: ['lightning bugs at dusk\neach one a small question asked\nthen answered in dark'] }    ],    'midsummer': [      { lines: ['the garden gives more\nthan i can carry inside\nthe table overflows'] },      { lines: ['shade beneath the oak\nthe only cool place to sit\nthe dog already knows'] },      { lines: ['thunder in the west\nthe corn stands perfectly still\nwaiting for the rain'] }    ],    'early-fall': [      { lines: ['the first cool morning\ni can see my breath again\nthe garden exhales'] },      { lines: ['one red leaf falling\nthrough the still september air\nlanding in my palm'] },      { lines: ['the garlic goes in\nan act of faith in the dark\nsee you in the spring'] }    ],    'late-fall': [      { lines: ['bare branch on bare branch\nthe crow arrives without sound\nthe sky turns away'] },      { lines: ['the last harvest done\ni clean the blade and hang it\non its proper nail'] },      { lines: ['wind through empty stalks\nplaying the garden like some\nforgotten instrument'] }    ]  };  function getHaiku(season) {    var poems = HAIKU[season.name];    if (!poems) return null;    var doy = Math.floor((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);    return poems[doy % poems.length];  }  // --- weather moods ---  // not forecasts. the character of the air in this season, at this hour.  var WEATHER_MOODS = {    'winter': {      dawn: 'the cold is sharpest now. frost on everything. the air tastes like iron.',      morning: 'grey and still. the kind of cold that settles in the bones and stays.',      afternoon: 'thin winter light, already angling toward evening. the air is dry and quiet.',      evening: 'the dark comes early and completely. woodsmoke from somewhere.',      night: 'clear and bitter, or clouded and raw. either way, stay in.'    },    'early-spring': {      dawn: 'cold still, but softer than last month. fog in the low places.',      morning: 'cool and damp. the kind of morning that smells like turned earth.',      afternoon: 'mild with a chance of anything. spring weather changes its mind.',      evening: 'cooling fast once the sun drops. a jacket you almost left behind.',      night: 'still cold enough to frost. the season is not settled yet.'    },    'late-spring': {      dawn: 'warm already. dew on everything. the birds are unreasonably loud.',      morning: 'bright and easy. the kind of morning that makes you go outside.',      afternoon: 'warm, sometimes hot. thunderstorms build in the west by three.',      evening: 'long golden light. warm enough to sit outside and do nothing.',      night: 'mild, finally. windows open. the frogs are singing.'    },    'early-summer': {      dawn: 'warm and humid before the sun is even up. it will be a hot one.',      morning: 'hazy. the heat is already building. do what you need to do early.',      afternoon: 'the full weight of it. cicadas. shade is the only mercy.',      evening: 'still warm but the light softens. lightning bugs in the grass.',      night: 'heavy air. the kind of night that does not cool down.'    },    'midsummer': {      dawn: 'the air is thick before sunrise. everything is already sweating.',      morning: 'blazing. the garden wilts by ten. water it or lose it.',      afternoon: 'oppressive. thunderheads pile up. the storms when they come are violent and brief.',      evening: 'a little relief if the storms came through. if not, you wait.',      night: 'warm and loud with insects. sleep with the windows open or don\'t sleep.'    },    'early-fall': {      dawn: 'cool at last. real cool. the first morning you can see your breath.',      morning: 'crisp and blue. the clearest skies of the year.',      afternoon: 'warm but not punishing. the light has that golden slant to it.',      evening: 'cool enough for a fire. the smell of leaves starting.',      night: 'cold and clear. you can see more stars now that the haze is gone.'    },    'late-fall': {      dawn: 'raw and grey. the trees are bare. wind with nothing to stop it.',      morning: 'cold rain or cold sun, both are possible. dress for both.',      afternoon: 'short. the light is leaving by four. make use of what remains.',      evening: 'dark already. the wind sounds different through bare branches.',      night: 'the kind of cold that makes the house feel smaller and warmer.'    }  };  function getWeatherMood(season, time) {    var moods = WEATHER_MOODS[season.name];    if (!moods) return '';    return moods[time] || '';  }  // --- daylight cycle ---  // shifts the palette based on time of day  var CYCLES = {    night: {      bg: '#060504',      text: '#9a8e80',      accent: '#3a5030',      heading: '#7a6a58',      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',      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',      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',      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',      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)'    }  };  // season palettes — the color of the earth shifts with the year  var SEASON_COLORS = {    'winter':       { bg: '#07080b', tint: '40,50,80' },    'early-spring': { bg: '#0a0c08', tint: '50,70,35' },    'late-spring':  { bg: '#0b0d07', tint: '55,80,30' },    'early-summer': { bg: '#0d0b07', tint: '80,65,20' },    'midsummer':    { bg: '#0e0a06', tint: '90,60,15' },    'early-fall':   { bg: '#0d0906', tint: '85,45,20' },    'late-fall':    { bg: '#0b0908', tint: '65,40,30' }  };  function applyDaylightCycle(time, season) {    var c = CYCLES[time] || CYCLES.morning;    var s = SEASON_COLORS[season.name] || SEASON_COLORS['early-spring'];    var r = document.documentElement;    r.style.setProperty('--earth', s.bg);    r.style.setProperty('--bone', c.text);    r.style.setProperty('--sprout', c.accent);    r.style.setProperty('--ember', c.heading);    r.style.setProperty('--glow', c.glow);    r.style.setProperty('--under1', c.under1);    r.style.setProperty('--under2', c.under2);    r.style.setProperty('--under3', c.under3);    document.body.setAttribute('data-time', time);    document.body.setAttribute('data-season', season.name);    // sky layer: season color wash    var sky = document.getElementById('sky-layer');    if (sky) {      sky.style.background =        'radial-gradient(ellipse at 30% 15%, rgba(' + s.tint + ',0.25) 0%, transparent 55%), ' +        'radial-gradient(ellipse at 70% 80%, rgba(' + s.tint + ',0.12) 0%, transparent 55%), ' +        'radial-gradient(ellipse at 50% 50%, rgba(' + s.tint + ',0.08) 0%, transparent 70%)';    }    // time layer: light source    var timeEl = document.getElementById('time-layer');    if (timeEl) {      timeEl.style.background = TIME_LIGHTS[time] || TIME_LIGHTS.morning;    }  }  var TIME_LIGHTS = {    night:      'linear-gradient(to bottom, rgba(100,120,180,0.15) 0%, rgba(100,120,180,0.05) 30%, transparent 70%)',    dawn:      'linear-gradient(to bottom, rgba(220,150,70,0.22) 0%, rgba(200,120,50,0.06) 40%, transparent 80%)',    morning:      'linear-gradient(to bottom, rgba(240,210,140,0.16) 0%, rgba(240,210,140,0.04) 40%, transparent 80%)',    afternoon:      'linear-gradient(to bottom, rgba(240,200,110,0.18) 0%, rgba(240,200,110,0.05) 40%, transparent 80%)',    evening:      'linear-gradient(to bottom, rgba(200,90,30,0.24) 0%, rgba(180,70,20,0.06) 40%, transparent 80%)'  };  // --- sky calculations ---  function moonPhase(date) {    var known = new Date(Date.UTC(2000, 0, 6, 18, 14, 0));    var synodic = 29.53058867;    var diff = (date.getTime() - known.getTime()) / 86400000;    return ((diff % synodic) + synodic) % synodic;  }  function moonName(phase) {    if (phase < 1.85) return 'new moon';    if (phase < 7.38) return 'waxing crescent';    if (phase < 9.23) return 'first quarter';    if (phase < 14.77) return 'waxing gibbous';    if (phase < 16.61) return 'full moon';    if (phase < 22.15) return 'waning gibbous';    if (phase < 23.99) return 'last quarter';    if (phase < 27.68) return 'waning crescent';    return 'new moon';  }  function moonIllumination(phase) {    return (1 - Math.cos(2 * Math.PI * phase / 29.53058867)) / 2;  }  function daylight(date, lat) {    var doy = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 86400000);    var yearLen = new Date(date.getFullYear(), 1, 29).getDate() === 29 ? 366 : 365;    var decl = 23.45 * Math.sin((2 * Math.PI / yearLen) * (doy - 81));    var cosH = -Math.tan(lat * Math.PI / 180) * Math.tan(decl * Math.PI / 180);    cosH = Math.max(-1, Math.min(1, cosH));    return (2 * Math.acos(cosH) * 180 / Math.PI) / 15;  }  function formatHM(hours) {    var h = Math.floor(hours);    var m = Math.round((hours - h) * 60);    return h + 'h ' + m + 'm';  }  function formatClock(hours) {    var h = Math.floor(hours);    var m = Math.round((hours - h) * 60);    if (m === 60) { h += 1; m = 0; }    var suffix = h >= 12 ? 'pm' : 'am';    var display = h > 12 ? h - 12 : (h === 0 ? 12 : h);    return display + ':' + (m < 10 ? '0' : '') + m + ' ' + suffix;  }  var MONTHS = ['january','february','march','april','may','june',    'july','august','september','october','november','december'];  var ORDINALS = ['','first','second','third','fourth','fifth','sixth',    'seventh','eighth','ninth','tenth','eleventh','twelfth','thirteenth',    'fourteenth','fifteenth','sixteenth','seventeenth','eighteenth',    'nineteenth','twentieth','twenty-first','twenty-second','twenty-third',    'twenty-fourth','twenty-fifth','twenty-sixth','twenty-seventh',    'twenty-eighth','twenty-ninth','thirtieth','thirty-first'];  var TIME_WORDS = {    night: 'night',    dawn: 'dawn',    morning: 'morning',    afternoon: 'afternoon',    evening: 'evening'  };  function writtenDate(date) {    var time = getTimeOfDay(date);    return TIME_WORDS[time] + ', the ' + ORDINALS[date.getDate()] + ' of ' + MONTHS[date.getMonth()];  }  function daysUntilNextSeason(date) {    var current = getSeason(date);    for (var i = 0; i < SEASONS.length; i++) {      var s = SEASONS[i];      var sDate = new Date(date.getFullYear(), s.start[0] - 1, s.start[1]);      if (sDate > date && s.name !== current.name) {        var diff = Math.ceil((sDate - date) / 86400000);        return { days: diff, label: s.label };      }    }    var first = SEASONS[0];    var next = new Date(date.getFullYear() + 1, first.start[0] - 1, first.start[1]);    return { days: Math.ceil((next - date) / 86400000), label: first.label };  }  function skyText(now, time) {    var phase = moonPhase(now);    var name = moonName(phase);    var illum = Math.round(moonIllumination(phase) * 100);    var hours = daylight(now, LAT);    var yesterday = new Date(now);    yesterday.setDate(yesterday.getDate() - 1);    var gained = ((hours - daylight(yesterday, LAT)) * 60).toFixed(1);    var sign = gained > 0 ? '+' : '';    var sunrise = 12 - hours / 2;    var sunset = 12 + hours / 2;    var boldName = '<strong>' + name + '</strong>';    var lines = [];    if (time === 'night') {      lines.push('the moon is ' + boldName + ', ' + illum + '% lit.');      lines.push('the world is turned away from the sun.');      lines.push('<strong>' + formatHM(hours) + '</strong> of daylight today. ' + sign + gained + ' minutes from yesterday.');    } else if (time === 'dawn') {      lines.push('the sun rises around <strong>' + formatClock(sunrise) + '</strong>.');      lines.push('the moon is ' + boldName + ', ' + illum + '% lit.');      lines.push('<strong>' + formatHM(hours) + '</strong> of daylight ahead. it sets around ' + formatClock(sunset) + '.');    } else if (time === 'evening') {      lines.push('the sun set around <strong>' + formatClock(sunset) + '</strong>.');      lines.push('the moon is ' + boldName + ', ' + illum + '% lit.');      lines.push('there were <strong>' + formatHM(hours) + '</strong> of daylight today. ' + sign + gained + ' minutes from yesterday.');    } else {      lines.push('the moon is ' + boldName + ', ' + illum + '% lit.');      lines.push('the sun rose around <strong>' + formatClock(sunrise) + '</strong> and sets around <strong>' + formatClock(sunset) + '</strong>.');      lines.push('<strong>' + formatHM(hours) + '</strong> of daylight today. ' + sign + gained + ' minutes from yesterday.');    }    return lines.join('<br>');  }  // --- moon gardening ---  function moonGardenTip(phase) {    if (phase < 3.7)      return 'the moon is dark. this is a time for <strong>rest and planning</strong>. prepare beds, amend soil, but do not plant. the old farmers said nothing wants to start in the dark.';    if (phase < 7.4)      return 'the moon is waxing. <strong>plant leafy things</strong>: lettuce, spinach, cabbage, herbs that grow above ground. the light is growing and pulls the energy upward.';    if (phase < 11.1)      return 'the moon is in its first quarter. <strong>plant things that fruit</strong>: tomatoes, peppers, beans, squash. the increasing light favors strong stems and heavy fruit.';    if (phase < 14.8)      return 'the moon is nearly full. <strong>transplant, fertilize, graft</strong>. the light is strongest and the sap is rising. a good time to move plants and feed the soil.';    if (phase < 18.5)      return 'the moon is full. <strong>plant root crops</strong>: carrots, potatoes, beets, onions, garlic. the energy is pulling downward now. bulbs and perennials go in well under a full moon.';    if (phase < 22.1)      return 'the moon is waning. <strong>harvest what is ready</strong>, cut herbs for drying, prune what needs shaping. the energy is drawing inward. what you cut now heals faster.';    if (phase < 25.8)      return 'the moon is in its last quarter. <strong>pull weeds, turn compost</strong>, cultivate the soil. this is a killing time, good for destroying what you do not want. the weeds will not come back as fast.';    return 'the moon is a thin crescent, almost gone. <strong>do not plant</strong>. rest, clean tools, plan the next cycle. the old almanacs left these days blank on purpose.';  }  // --- markdown parsing ---  function parseFrontmatter(text) {    var match = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);    if (!match) return { meta: {}, body: text };    var meta = {};    match[1].split('\n').forEach(function (line) {      var parts = line.split(/:\s*/);      if (parts.length >= 2) meta[parts[0].trim()] = parts.slice(1).join(':').trim();    });    return { meta: meta, body: match[2].trim() };  }  function renderMarkdown(body) {    var lines = body.split('\n');    var html = '';    var inList = false;    var inPara = false;    lines.forEach(function (line) {      var trimmed = line.trim();      if (trimmed.match(/^- /)) {        if (inPara) { html += '</p>'; inPara = false; }        if (!inList) { html += '<ul>'; inList = true; }        html += '<li>' + trimmed.slice(2) + '</li>';      } else if (trimmed === '') {        if (inList) { html += '</ul>'; inList = false; }        if (inPara) { html += '</p>'; inPara = false; }      } else {        if (inList) { html += '</ul>'; inList = false; }        if (!inPara) { html += '<p>'; inPara = true; }        else { html += '<br>'; }        html += trimmed;      }    });    if (inList) html += '</ul>';    if (inPara) html += '</p>';    return html;  }  // --- word reveal ---  function revealWords(root) {    if (!root) return;    var elements = root.querySelectorAll('h2, p, li, blockquote');    var allItems = [];    elements.forEach(function (el) {      if (el.tagName === 'LI') {        allItems.push(el);        return;      }      var nodes = [];      el.childNodes.forEach(function (node) {        if (node.nodeType === 3) {          node.textContent.split(/(\s+)/).forEach(function (w) {            if (/^\s*$/.test(w)) {              nodes.push(document.createTextNode(w));            } else {              var span = document.createElement('span');              span.className = 'word';              span.textContent = w;              nodes.push(span);              allItems.push(span);            }          });        } else if (node.nodeType === 1) {          // for child elements like <strong>, split their text into word spans too          var wrapper = document.createElement(node.tagName.toLowerCase());          // copy attributes          for (var a = 0; a < node.attributes.length; a++) {            wrapper.setAttribute(node.attributes[a].name, node.attributes[a].value);          }          var innerText = node.textContent || '';          innerText.split(/(\s+)/).forEach(function (w) {            if (/^\s*$/.test(w)) {              wrapper.appendChild(document.createTextNode(w));            } else {              var span = document.createElement('span');              span.className = 'word';              span.textContent = w;              wrapper.appendChild(span);              allItems.push(span);            }          });          nodes.push(wrapper);        }      });      el.textContent = '';      nodes.forEach(function (n) { el.appendChild(n); });    });    var delay = 0;    allItems.forEach(function (item) {      item.style.animationDelay = delay + 'ms';      delay += item.tagName === 'LI' ? ANIM_LIST_DELAY : ANIM_WORD_DELAY;    });  }  // --- main ---  var currentSeason = null;  var currentTimeOverride = null;  var cachedManifest = null;  var naturalSeason = null;  var naturalTime = null;  // cached DOM references  var dom = {};  function cacheDOM() {    dom.dateLine = document.querySelector('.date-line');    dom.seasonName = document.querySelector('.season-name');    dom.seasonNote = document.querySelector('.season-note');    dom.haikuBlock = document.querySelector('.flow-haiku blockquote');    dom.moonGardenTip = null;    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.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 now = new Date();    var season = seasonOverride || getSeason(now);    var realTime = getTimeOfDay(now);    var contentTime = timeOverride || realTime;    currentSeason = season;    currentTimeOverride = timeOverride;    // daylight cycle follows the real clock, not the content override    applyDaylightCycle(realTime, season);    // date line    dom.dateLine.textContent = writtenDate(now);    // season header    dom.seasonName.textContent = season.label;    var noteHTML = season.note.replace(/\n/g, '<br>');    var next = daysUntilNextSeason(now);    if (next.days <= 7) {      noteHTML += '<br>' + next.label + ' begins in ' + next.days + (next.days === 1 ? ' day.' : ' days.');    }    dom.seasonNote.innerHTML = noteHTML;    // haiku    var haiku = getHaiku(season);    if (haiku) {      var haikuLines = haiku.lines[0].split('\n');      dom.haikuBlock.innerHTML = haikuLines.map(function (l) {        return '<span class="haiku-line">' + l + '</span>';      }).join('');    }    // moon gardening tip (rendered in narrative section below)    var phase = moonPhase(now);    // weather mood    dom.weatherMoodText.textContent = getWeatherMood(season, contentTime);    // sky data (now with bold highlights)    dom.skyData.innerHTML = skyText(now, contentTime);    // footer countdown    dom.footerP.textContent =      next.days + ' days until ' + next.label + ' \u00b7 zone 7a \u00b7 north carolina';    // fade out content areas, then rebuild    var fadeTargets = ['.flow-season', '.flow-sky', '.flow-haiku', '.wisdom', '.flow-entries', 'footer'];    fadeTargets.forEach(function (sel) {      var el = document.querySelector(sel);      if (el) el.style.opacity = '0';    });    // seed the random number generator for today    var rng = seededRandom(dayHash(now));    // fetch or use cached manifest    var p = cachedManifest      ? Promise.resolve(cachedManifest)      : fetch('/data/manifest.json').then(function (r) { return r.json(); });    p.then(function (manifest) {        cachedManifest = manifest;        var allowedPrefixes = TIME_CONTENT[contentTime] || [];        var relevant = manifest.filter(function (entry) {          // wisdom entries: filter by time          if (entry.time) return entry.time === contentTime;          // season entries: filter by season AND time-based category          if (entry.season && entry.season !== season.name) return false;          for (var i = 0; i < allowedPrefixes.length; i++) {            if (entry.path.indexOf(allowedPrefixes[i]) === 0) return true;          }          return false;        });        return Promise.all(relevant.map(function (entry) {          return fetch('/data/' + entry.path)            .then(function (r) { return r.text(); })            .then(function (text) { return parseFrontmatter(text); })            .catch(function () { return null; });        })).then(function (results) {          return results.filter(function (r) { return r !== null; });        });      })      .then(function (entries) {        dom.wisdom.innerHTML = '';        dom.flowEntries.innerHTML = '';        // collect all fragments into one flowing narrative        var fragments = [];        // moon garden tip mixed in with the rest        fragments.push(moonGardenTip(phase));        // wisdom lines        entries.forEach(function (entry) {          if (!entry.meta.time) return;          var lines = entry.body.split('\n').map(function (l) { return l.trim(); }).filter(function (l) { return l.length > 0; });          if (lines.length === 0) return;          var picks = pickItems(lines, Math.min(2, lines.length), rng);          picks.forEach(function (line) { fragments.push(line); });        });        // seasonal entries: pick 1 item from each category        entries.forEach(function (entry) {          if (entry.meta.time) return;          var parsed = parseListItems(entry.body);          if (parsed.bullets.length > 0) {            var picks = pickItems(parsed.bullets, 1, rng);            picks.forEach(function (item) { fragments.push(item); });          }          if (parsed.prose.length > 0) {            var prosePick = pickItems(parsed.prose, 1, rng);            fragments.push(prosePick[0]);          }        });        // shuffle all fragments together for a natural mixed read        for (var i = fragments.length - 1; i > 0; i--) {          var j = Math.floor(rng() * (i + 1));          var tmp = fragments[i];          fragments[i] = fragments[j];          fragments[j] = tmp;        }        // compose into flowing paragraphs, ~3-4 fragments each        var SEP = ' <span class="sep">\u2767</span> ';        var chunkSize = Math.min(4, Math.ceil(fragments.length / 2));        for (var c = 0; c < fragments.length; c += chunkSize) {          var chunk = fragments.slice(c, c + chunkSize);          var html = chunk.map(function (f) { return highlightText(f); }).join(SEP);          var p = document.createElement('p');          p.className = 'narrative';          p.innerHTML = html;          dom.flowEntries.appendChild(p);        }        // fade in and reveal        fadeTargets.forEach(function (sel) {          var el = document.querySelector(sel);          if (el) el.style.opacity = '1';        });        revealWords(dom.readout);        revealWords(dom.footer);        // update navs        updateNav(season);        updateTimeNav(contentTime);      })      .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>';        fadeTargets.forEach(function (sel) {          var el = document.querySelector(sel);          if (el) el.style.opacity = '1';        });      });  }  // unique season names in order, for the nav  var seasonOrder = [];  var seasonMap = {};  SEASONS.forEach(function (s) {    if (!seasonMap[s.name]) {      seasonMap[s.name] = s;      seasonOrder.push(s);    }  });  function updateNav(activeSeason) {    dom.seasonsNav.innerHTML = '';    var delay = 0;    seasonOrder.forEach(function (s) {      var a = document.createElement('a');      a.textContent = s.label;      if (s.name === activeSeason.name) {        a.className = 'active';        a.setAttribute('aria-current', 'true');      }      a.style.animationDelay = delay + 'ms';      delay += ANIM_NAV_DELAY;      a.addEventListener('click', function () {        window.scrollTo({ top: 0, behavior: 'smooth' });        loadContent(s, currentTimeOverride);      });      dom.seasonsNav.appendChild(a);    });    if (activeSeason.name !== naturalSeason.name) {      var ret = document.createElement('a');      ret.textContent = 'return to now';      ret.className = 'return-now';      ret.style.animationDelay = delay + 'ms';      ret.addEventListener('click', function () {        window.scrollTo({ top: 0, behavior: 'smooth' });        loadContent(null, currentTimeOverride);      });      dom.seasonsNav.appendChild(ret);    }  }  function updateTimeNav(activeTime) {    if (!dom.timesNav) return;    dom.timesNav.innerHTML = '';    var delay = 0;    TIME_LABELS.forEach(function (t) {      var a = document.createElement('a');      a.textContent = t;      if (t === activeTime) {        a.className = 'active';        a.setAttribute('aria-current', 'true');      }      a.style.animationDelay = delay + 'ms';      delay += ANIM_NAV_DELAY;      a.addEventListener('click', function () {        loadContent(currentSeason === naturalSeason ? null : currentSeason, t);      });      dom.timesNav.appendChild(a);    });    if (currentTimeOverride && currentTimeOverride !== naturalTime) {      var ret = document.createElement('a');      ret.textContent = 'return to now';      ret.className = 'return-now';      ret.style.animationDelay = delay + 'ms';      ret.addEventListener('click', function () {        loadContent(currentSeason === naturalSeason ? null : currentSeason, null);      });      dom.timesNav.appendChild(ret);    }  }  // initial render  cacheDOM();  var now = new Date();  naturalSeason = getSeason(now);  naturalTime = getTimeOfDay(now);  loadContent(null);  // auto-refresh when time of day or season changes  setInterval(function () {    var check = new Date();    var newTime = getTimeOfDay(check);    var newSeason = getSeason(check);    if (newTime !== naturalTime || newSeason.name !== naturalSeason.name) {      naturalTime = newTime;      naturalSeason = newSeason;      if (!currentTimeOverride) {        loadContent(currentSeason === naturalSeason ? null : currentSeason, null);      }    }  }, 60000);  // register service worker for offline access  if ('serviceWorker' in navigator) {    navigator.serviceWorker.register('/sw.js');  }})();
added static/almanac.js
@@ -0,0 +1,357 @@// almanac.js//// the surface of the page. handles color, motion, and navigation.// all content is assembled on the server now.(function () {  'use strict';  var ANIM_WORD_DELAY = 18;  var ANIM_LIST_DELAY = 60;  var ANIM_NAV_DELAY = 80;  // --- daylight cycle ---  // shifts the palette based on time of day  var TIMES = [    { name: 'night',     start: 0,  end: 5 },    { name: 'dawn',      start: 5,  end: 8 },    { name: 'morning',   start: 8,  end: 12 },    { name: 'afternoon', start: 12, end: 17 },    { name: 'evening',   start: 17, end: 21 },    { 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++) {      if (h >= TIMES[i].start && h < TIMES[i].end) return TIMES[i].name;    }    return 'night';  }  // season detection for auto-refresh  function getSeasonName(date) {    var m = date.getMonth() + 1;    var d = date.getDate();    var ranges = [      ['winter',       1,1,   2,28],      ['early-spring', 3,1,   4,15],      ['late-spring',  4,16,  5,31],      ['early-summer', 6,1,   6,30],      ['midsummer',    7,1,   8,31],      ['early-fall',   9,1,   10,31],      ['late-fall',    11,1,  11,30],      ['winter',       12,1,  12,31]    ];    for (var i = 0; i < ranges.length; i++) {      var r = ranges[i];      var afterStart = m > r[1] || (m === r[1] && d >= r[2]);      var beforeEnd = m < r[3] || (m === r[3] && d <= r[4]);      if (afterStart && beforeEnd) return r[0];    }    return 'winter';  }  var CYCLES = {    night: {      bg: '#060504', text: '#9a8e80', accent: '#3a5030', heading: '#7a6a58',      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',      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',      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',      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',      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)'    }  };  var SEASON_COLORS = {    'winter':       { bg: '#07080b', tint: '40,50,80' },    'early-spring': { bg: '#0a0c08', tint: '50,70,35' },    'late-spring':  { bg: '#0b0d07', tint: '55,80,30' },    'early-summer': { bg: '#0d0b07', tint: '80,65,20' },    'midsummer':    { bg: '#0e0a06', tint: '90,60,15' },    'early-fall':   { bg: '#0d0906', tint: '85,45,20' },    'late-fall':    { bg: '#0b0908', tint: '65,40,30' }  };  var TIME_LIGHTS = {    night:      'linear-gradient(to bottom, rgba(100,120,180,0.15) 0%, rgba(100,120,180,0.05) 30%, transparent 70%)',    dawn:      'linear-gradient(to bottom, rgba(220,150,70,0.22) 0%, rgba(200,120,50,0.06) 40%, transparent 80%)',    morning:      'linear-gradient(to bottom, rgba(240,210,140,0.16) 0%, rgba(240,210,140,0.04) 40%, transparent 80%)',    afternoon:      'linear-gradient(to bottom, rgba(240,200,110,0.18) 0%, rgba(240,200,110,0.05) 40%, transparent 80%)',    evening:      'linear-gradient(to bottom, rgba(200,90,30,0.24) 0%, rgba(180,70,20,0.06) 40%, transparent 80%)'  };  function applyDaylightCycle(time, seasonName) {    var c = CYCLES[time] || CYCLES.morning;    var s = SEASON_COLORS[seasonName] || SEASON_COLORS['early-spring'];    var r = document.documentElement;    r.style.setProperty('--earth', s.bg);    r.style.setProperty('--bone', c.text);    r.style.setProperty('--sprout', c.accent);    r.style.setProperty('--ember', c.heading);    r.style.setProperty('--glow', c.glow);    r.style.setProperty('--under1', c.under1);    r.style.setProperty('--under2', c.under2);    r.style.setProperty('--under3', c.under3);    var sky = document.getElementById('sky-layer');    if (sky) {      sky.style.background =        'radial-gradient(ellipse at 30% 15%, rgba(' + s.tint + ',0.25) 0%, transparent 55%), ' +        'radial-gradient(ellipse at 70% 80%, rgba(' + s.tint + ',0.12) 0%, transparent 55%), ' +        'radial-gradient(ellipse at 50% 50%, rgba(' + s.tint + ',0.08) 0%, transparent 70%)';    }    var timeEl = document.getElementById('time-layer');    if (timeEl) {      timeEl.style.background = TIME_LIGHTS[time] || TIME_LIGHTS.morning;    }  }  // --- word reveal ---  function revealWords(root) {    if (!root) return;    var elements = root.querySelectorAll('h2, p, li, blockquote');    var allItems = [];    elements.forEach(function (el) {      if (el.tagName === 'LI') {        allItems.push(el);        return;      }      var nodes = [];      el.childNodes.forEach(function (node) {        if (node.nodeType === 3) {          node.textContent.split(/(\s+)/).forEach(function (w) {            if (/^\s*$/.test(w)) {              nodes.push(document.createTextNode(w));            } else {              var span = document.createElement('span');              span.className = 'word';              span.textContent = w;              nodes.push(span);              allItems.push(span);            }          });        } else if (node.nodeType === 1) {          var wrapper = document.createElement(node.tagName.toLowerCase());          for (var a = 0; a < node.attributes.length; a++) {            wrapper.setAttribute(node.attributes[a].name, node.attributes[a].value);          }          var innerText = node.textContent || '';          innerText.split(/(\s+)/).forEach(function (w) {            if (/^\s*$/.test(w)) {              wrapper.appendChild(document.createTextNode(w));            } else {              var span = document.createElement('span');              span.className = 'word';              span.textContent = w;              wrapper.appendChild(span);              allItems.push(span);            }          });          nodes.push(wrapper);        }      });      el.textContent = '';      nodes.forEach(function (n) { el.appendChild(n); });    });    var delay = 0;    allItems.forEach(function (item) {      item.style.animationDelay = delay + 'ms';      delay += item.tagName === 'LI' ? ANIM_LIST_DELAY : ANIM_WORD_DELAY;    });  }  // --- main ---  var currentSeasonOverride = null;  var currentTimeOverride = null;  var naturalSeason = null;  var naturalTime = null;  var dom = {};  function cacheDOM() {    dom.dateLine = document.querySelector('.date-line');    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.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'];    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('&') : '');    currentSeasonOverride = seasonOverride;    currentTimeOverride = timeOverride;    fetch(url)      .then(function (r) { return r.json(); })      .then(function (data) {        dom.dateLine.textContent = data.date_line;        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.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);        fadeTargets.forEach(function (sel) {          var el = document.querySelector(sel);          if (el) el.style.opacity = '1';        });        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>';        fadeTargets.forEach(function (sel) {          var el = document.querySelector(sel);          if (el) el.style.opacity = '1';        });      });  }  function bindNavClicks() {    dom.seasonsNav.querySelectorAll('a[data-season]').forEach(function (a) {      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);        }      });    });  }  // --- init ---  cacheDOM();  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;  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  setInterval(function () {    var check = new Date();    var newTime = getTimeOfDay(check);    var newSeason = getSeasonName(check);    if (newTime !== naturalTime || newSeason !== naturalSeason) {      naturalTime = newTime;      naturalSeason = newSeason;      if (!currentTimeOverride) {        loadContent(currentSeasonOverride, null);      }      // always update the daylight cycle to real time      applyDaylightCycle(newTime, document.body.getAttribute('data-season') || newSeason);    }  }, 60000);  // register service worker for offline access  if ('serviceWorker' in navigator) {    navigator.serviceWorker.register('/static/sw.js');  }})();
renamed site/favicon.svg → static/favicon.svg
renamed site/og.svg → static/og.svg
renamed site/style.css → static/style.css
@@ -116,6 +116,10 @@ main {  margin-top: 0.6rem;}.season-note p {  margin: 0;}/* --- sky (weather + celestial + moon tip) --- */
renamed site/sw.js → static/sw.js
@@ -1,16 +1,14 @@// service worker for dark furrow// the almanac should work like a book you own, not a service you rent.var CACHE_NAME = 'dark-furrow-v1';var CACHE_NAME = 'dark-furrow-v2';var CORE_ASSETS = [  '/',  '/index.html',  '/style.css',  '/almanac.js',  '/favicon.svg',  '/og.svg',  '/data/manifest.json'  '/static/style.css',  '/static/almanac.js',  '/static/favicon.svg',  '/static/og.svg'];self.addEventListener('install', function (event) {
@@ -39,7 +37,6 @@ self.addEventListener('activate', function (event) {self.addEventListener('fetch', function (event) {  event.respondWith(    fetch(event.request).then(function (response) {      // cache successful responses for offline use      if (response.ok) {        var clone = response.clone();        caches.open(CACHE_NAME).then(function (cache) {
renamed site/index.html → templates/index.html
@@ -15,30 +15,30 @@  <meta property="og:title" content="dark furrow">  <meta property="og:description" content="old knowledge preserved. a seasonal almanac of planting, harvest, moon phases, and the quiet rhythms the modern world forgot.">  <meta property="og:site_name" content="dark furrow">  <meta property="og:image" content="https://darkfurrow.com/og.svg">  <meta property="og:image" content="https://darkfurrow.com/static/og.svg">  <meta property="og:locale" content="en_US">  <!-- twitter / x -->  <meta name="twitter:card" content="summary">  <meta name="twitter:title" content="dark furrow">  <meta name="twitter:description" content="old knowledge preserved. what to plant, what to cook, what the moon is doing. an almanac that breathes with the season and the hour.">  <meta name="twitter:image" content="https://darkfurrow.com/og.svg">  <meta name="twitter:image" content="https://darkfurrow.com/static/og.svg">  <!-- misc -->  <meta name="robots" content="index, follow">  <meta name="generator" content="hand and soil">  <link rel="canonical" href="https://darkfurrow.com">  <link rel="icon" href="/favicon.svg" type="image/svg+xml">  <link rel="stylesheet" href="/style.css">  <link rel="icon" href="/static/favicon.svg" type="image/svg+xml">  <link rel="stylesheet" href="/static/style.css"></head><body><body data-time="{{ time_key }}" data-season="{{ season_key }}">  <div id="sky-layer"></div>  <div id="time-layer"></div>  <main>    <article class="readout">      <p class="date-line"></p>      <p class="date-line">{{ date_line }}</p>      <header class="readout-header">        <h1>dark furrow</h1>
@@ -46,37 +46,37 @@      </header>      <section class="flow flow-season">        <p class="season-name"></p>        <p class="season-note"></p>        <p class="season-name">{{ season_name }}</p>        <div class="season-note">{{ season_note|safe }}</div>      </section>      <section class="flow flow-sky">        <p class="weather-mood-text"></p>        <p class="sky-data"></p>        <p class="weather-mood-text">{{ weather_mood|safe }}</p>        <p class="sky-data">{{ sky_data|safe }}</p>      </section>      <section class="flow flow-haiku">        <blockquote></blockquote>        <blockquote>{{ haiku_html|safe }}</blockquote>      </section>      <section class="wisdom"></section>      <section class="flow flow-entries"></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"></nav>          <nav class="nav-strip times-nav"></nav>          <nav class="nav-strip seasons-nav">{{ season_nav_html|safe }}</nav>          <nav class="nav-strip times-nav">{{ time_nav_html|safe }}</nav>        </div>      </div>    </article>    <footer>      <p>zone 7a &middot; north carolina &middot; tended by hand</p>      <p>{{ footer_text }}</p>    </footer>  </main>  <script src="/almanac.js"></script>  <script src="/static/almanac.js"></script></body></html>
added uv.lock
@@ -0,0 +1,182 @@version = 1revision = 3requires-python = ">=3.13"[[package]]name = "blinker"version = "1.9.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },][[package]]name = "click"version = "8.3.2"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "colorama", marker = "sys_platform == 'win32'" },]sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },][[package]]name = "colorama"version = "0.4.6"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },][[package]]name = "darkfurrow"version = "0.1.0"source = { virtual = "." }dependencies = [    { name = "flask" },    { name = "gunicorn" },    { name = "mistune" },][package.metadata]requires-dist = [    { name = "flask", specifier = ">=3.1" },    { name = "gunicorn", specifier = ">=23.0" },    { name = "mistune", specifier = ">=3.1" },][[package]]name = "flask"version = "3.1.3"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "blinker" },    { name = "click" },    { name = "itsdangerous" },    { name = "jinja2" },    { name = "markupsafe" },    { name = "werkzeug" },]sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },][[package]]name = "gunicorn"version = "25.3.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "packaging" },]sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },][[package]]name = "itsdangerous"version = "2.2.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },][[package]]name = "jinja2"version = "3.1.6"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "markupsafe" },]sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },][[package]]name = "markupsafe"version = "3.0.3"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },    { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },    { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },    { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },    { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },    { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },    { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },    { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },    { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },    { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },    { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },    { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },    { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },    { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },    { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },    { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },    { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },    { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },    { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },    { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },    { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },    { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },    { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },    { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },    { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },    { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },    { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },    { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },    { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },    { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },    { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },    { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },    { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },    { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },    { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },    { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },    { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },    { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },    { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },    { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },    { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },    { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },    { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },    { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },][[package]]name = "mistune"version = "3.2.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },][[package]]name = "packaging"version = "26.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },][[package]]name = "werkzeug"version = "3.1.8"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "markupsafe" },]sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },]