@@ -1,2 +1,5 @@.env__pycache__/.venv/.playwright-mcp/*.png
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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",]
@@ -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'); }})();
@@ -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 · north carolina · tended by hand</p> <p>{{ footer_text }}</p> </footer> </main> <script src="/almanac.js"></script> <script src="/static/almanac.js"></script></body></html>
@@ -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" },]