Add multi-theme site framework and notebook pattern

This commit is contained in:
wesley 2026-04-28 17:47:08 +00:00
parent 2c73b072bc
commit e0e5e414a0
29 changed files with 3360 additions and 281 deletions

22
.gitignore vendored
View File

@ -51,6 +51,7 @@ coverage.xml
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/ cover/
.ruff_cache/
# Translations # Translations
*.mo *.mo
@ -144,6 +145,12 @@ venv.bak/
.dmypy.json .dmypy.json
dmypy.json dmypy.json
# pyright
.pyright/
# basedpyright
.basedpyright/
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
@ -153,6 +160,16 @@ dmypy.json
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
# Python tooling
.python-version
poetry.toml
uv.lock
# Rust
Cargo.lock
**/*.rs.bk
*.pdb
# PyCharm # PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
@ -198,6 +215,9 @@ dist/
# Flycheck # Flycheck
flycheck_*.el flycheck_*.el
# Treemacs
.treemacs-persist
# server auth directory # server auth directory
/server/ /server/
@ -209,5 +229,3 @@ flycheck_*.el
# network security # network security
/network-security.data /network-security.data

View File

@ -3,14 +3,24 @@
A lightweight, responsive, static-site framework for open educational resources in science. A lightweight, responsive, static-site framework for open educational resources in science.
## 🎯 Purpose ## 🎯 Purpose
This repository provides a reusable foundation for sites like **evo-edu.org**, featuring: This repository provides a reusable foundation for sites like **evo-edu.org**, `www2.talkorigins.org`, and `pandasthumb.net`, featuring:
- Mobile-first responsive design - Mobile-first responsive design
- Modular content loading (HTML fragments) - Modular content loading (HTML fragments)
- Language-switching support (for multilingual sites) - Language-switching support for multilingual static trees
- Integrated app-card and notebook-section templates - Integrated app-card and notebook-section templates
- Intended to host Javascript web apps - Intended to host JavaScript web apps
-- With study guides, alignment documents, reading (links to notebook sections) -- With study guides, alignment documents, reading (links to notebook sections)
- Bibliography rendering (journal style + BibTeX) - Bibliography rendering (journal style + BibTeX)
- Multiple theme presets, including:
- `evo-edu`
- `talkorigins-modern`
- `pandasthumb`
- Optional content bridges for `doclift`, `GroundRecall`, `Didactopus`, and `CiteGeist`
- A generic notebook pattern for topic-level study modules that combine goals,
apps, source-derived sections, and bibliographies. See
[docs/NOTEBOOKS.md](docs/NOTEBOOKS.md)
- Optional translation tooling can use local GenieHive LLM endpoints. See
[docs/GENIEHIVE_TRANSLATION.md](docs/GENIEHIVE_TRANSLATION.md)
## 🛠️ Features ## 🛠️ Features
- Vanilla HTML/CSS/JS (no heavy frameworks) - Vanilla HTML/CSS/JS (no heavy frameworks)
@ -25,7 +35,8 @@ This repository provides a reusable foundation for sites like **evo-edu.org**, f
## 📂 Structure ## 📂 Structure
``` ```
/framework /framework
├── theme/ # Base layout, CSS, JS ├── theme/ # Shared assets plus theme presets
│ └── themes/ # Shipped theme variants
├── templates/ # Reusable HTML snippets ├── templates/ # Reusable HTML snippets
├── docs/ # Usage guide and examples ├── docs/ # Usage guide and examples
├── scripts/ # Language translation script and example glossary ├── scripts/ # Language translation script and example glossary
@ -34,11 +45,12 @@ This repository provides a reusable foundation for sites like **evo-edu.org**, f
## 🧩 How to Use ## 🧩 How to Use
1. Clone this repo 1. Clone this repo
2. Copy `/theme/base.html` into your content project 2. Choose a theme preset and optional content sources in `site.json`
3. Customize navigation and styling 3. Build with `scripts/build.py`
4. Use `main.js` for dynamic section loading 4. Use `main.js` for dynamic section loading and language switching
> See [`evo-edu/en`](https://evo-edu.org/en) for a working example. > Use the `talkorigins-modern` preset as the proving ground for the
> `www2.talkorigins.org` modernization line.
## 📜 License ## 📜 License
MIT — free to use, modify, and redistribute. MIT — free to use, modify, and redistribute.
@ -62,31 +74,24 @@ obtain the correct bibliography items to display. Content folding
with lazy-loading aims to prevent pages from growing in size without with lazy-loading aims to prevent pages from growing in size without
bound, and provide modularity in collaboration. Models, programs, and bound, and provide modularity in collaboration. Models, programs, and
simulations are meant to be implemented in a browser friendly form, simulations are meant to be implemented in a browser friendly form,
either Javascript, WebGL, or similar, so that processing takes place either JavaScript, WebGL, or similar, so that processing takes place
client-side, preventing heavy loads on servers. The current model of client-side, preventing heavy loads on servers. The current model of
content I am pursuing with this to to have a site landing page, a content I am pursuing with this is to have a site landing page, a
collection of web apps for a topic, and a 'notebook' on that topic collection of web apps for a topic, and a 'notebook' on that topic
that includes didectic material sufficient to ground a naive user in that includes didactic material sufficient to ground a naive user in
the relevant concepts that the models, programs, and simulations the relevant concepts that the models, programs, and simulations
address. address.
The other major feature here is the architecture to support multiple The other major feature here is the architecture to support multiple
languages. Because my efforts are currently down to one developer, me, languages. The core framework can present static language trees and switch
this is accomplished by use of one or more locally-hosted multilingual between covered locales without requiring a translation backend. Optional
large language models that can automaticlly provide decent translation translation tooling can use locally-hosted multilingual large language models
from a source language to a target language. In my case, the source routed through GenieHive; see
language will be English, I have a Python program for a batch offline [docs/GENIEHIVE_TRANSLATION.md](docs/GENIEHIVE_TRANSLATION.md) for that
process to traverse the site directory tree, open and parse HTML separate client-side configuration.
files, ask for translations at the paragraph level, and assemble those
back into the same HTML structure in order to obtain each translated
page. Each page will incorporate the language switcher Javascript code
in its header, which amounts to redirecting the user to a copy of the
site whose static files are in the target language. The translation
is to be done via an LLM running locally via Mozilla Llamafile.
This came together in a hurry, but I hope that other people may find This came together in a hurry, but I hope that other people may find
some utility in it to aid in disseminating domain knowledge they and some utility in it to aid in disseminating domain knowledge they and
their collaborators may have. their collaborators may have.
Wesley R. Elsberry, 2025-10-14 Wesley R. Elsberry, 2025-10-14

View File

@ -0,0 +1,136 @@
# GenieHive Translation Configuration
This guide covers the optional SciSiteForge translation path:
- `scripts/translate_site.py`
- the `translation` block in a SciSiteForge site config
- the GenieHive OpenAI-compatible chat endpoint used by the translator
The translator is intentionally separate from the static build path. It does
not own the translation model or routing policy. It reads site config, loads
optional glossaries, and sends paragraph-sized requests to GenieHive.
## Client-Side Configuration
SciSiteForge reads translation settings from the `translation` object in the
site config:
```json
{
"translation": {
"provider": "geniehive",
"base_url": "http://127.0.0.1:8800",
"model": "scientific_translator",
"api_key": "change-me-client-key",
"timeout": 120,
"system_prompt": "You are a careful scientific translator. Preserve meaning, structure, and technical terms. Return only the translation."
}
}
```
Recommended meaning of the fields:
- `base_url`: GenieHive control-plane URL or a reverse-proxied client URL.
- `provider`: translation backend. The supported provider is currently
`geniehive`.
- `model`: a GenieHive role ID or directly addressable model name.
- `api_key`: the GenieHive client key.
- `timeout`: request timeout in seconds.
- `system_prompt`: the translation policy for the client.
The CLI also accepts overrides:
- `--base-url`
- `--model`
- `--api-key`
- `--timeout`
Those flags override the site config for a single run.
## Request Shape
`scripts/translate_site.py` sends GenieHive a standard chat-completions request:
- `POST /v1/chat/completions`
- `model`: from the translation config
- `messages`: one system message plus one user prompt
- `temperature`: low, so translations stay stable
The user prompt asks for:
- translation into the target language
- no commentary
- preservation of structure and technical terminology
## Glossaries
If `scripts/glossary_<lang>.json` exists, the translator loads it and passes
the glossary entries into the prompt.
Use glossaries for:
- fixed technical terms
- proper names that should not be translated
- site-specific terminology
Keep each glossary small and explicit. The translator is not a terminology
database.
## Practical Workflow
1. Set the SciSiteForge `translation` block.
2. Confirm GenieHive is reachable at the configured `base_url`.
3. Confirm the selected GenieHive model or role exists.
4. Run `scripts/translate_site.py`.
5. Review the translated tree before publishing it.
For site-specific translation runs, keep the source tree and destination tree
separate. Translation should not overwrite the English source.
## Suggested Defaults
For local development:
- `base_url`: `http://127.0.0.1:8800`
- `model`: a translation-focused GenieHive role
- `timeout`: `120`
For production or a LAN host:
- use the reverse-proxied GenieHive URL
- keep the API key required
- prefer a translation role with conservative prompt policy
## Relationship to Site Themes
Theme choice and translation are independent.
- themes control layout, styling, and rendering
- translation controls content generation for alternate language trees
Use the same translation setup across `evo-edu`, `talkorigins-modern`, and
`pandasthumb` unless a site has language-specific terminology that needs a
custom glossary or prompt.
For the TalkOrigins modernization proof-of-concept, keep the language switcher
in priority order rather than alphabetical order. A practical top-ten ordering
is:
1. Spanish
2. French
3. Portuguese
4. German
5. Italian
6. Russian
7. Chinese
8. Japanese
9. Arabic
10. Hindi
That order keeps the most broadly useful and generally stable translations near
the top of the chooser while still exposing the full target set.
Use the `coverage` flag on each language entry to decide what appears in the
main switcher. Keep the full intended list in `language_policy.planned_languages`
so the site can show the broader roadmap without advertising unfinished locales
as active.

129
docs/NOTEBOOKS.md Normal file
View File

@ -0,0 +1,129 @@
# SciSiteForge Notebooks
A SciSiteForge notebook is a topic-level study module. It is smaller than a
full learner application and more structured than a list of cards.
Use a notebook when a site needs to connect:
- a concept or claim
- one or more interactive apps or labs
- recovered source documents
- grounded knowledge and provenance
- guided-study concepts
- citations or bibliography updates
## Role in the Ecosystem
The notebook pattern is intentionally generic:
- `doclift` rescues and normalizes legacy documents.
- `GroundRecall` provides grounded concepts, claims, observations, and
provenance.
- `Didactopus` provides learner-facing concepts, prerequisites, pathways, and
review-oriented packs.
- `CiteGeist` provides bibliography and literature-update material.
- SciSiteForge renders a static site shell that can present those artifacts in
a coherent topic module.
SciSiteForge should not take over the job of those systems. It should render
their outputs in a predictable static format.
## Notebook Shape
In a site config, a notebook looks like this:
```json
{
"notebooks": [
{
"id": "evidence-and-claims",
"title": "Evidence and Claims Notebook",
"summary": "Connect claims, evidence, source material, and citations.",
"audience": "self-learners and instructors",
"goals": [
"Move from a claim to relevant evidence",
"Expose provenance and review status",
"Connect source documents to guided study"
],
"apps": [
{
"title": "Public search",
"href": "/search/",
"description": "Search across related corpora"
}
],
"source_kinds": ["section", "notebook", "app", "bibliography"],
"max_items": 8
}
]
}
```
The build system renders each notebook as:
- title and summary
- audience note
- goals
- app/lab links
- selected study material from loaded content sources
## Content Sources
Notebook study material is selected from the loaded `content_sources`.
Recommended mapping:
- `doclift_bundle`: recovered legacy readings and source documents
- `groundrecall_bundle`: concepts, claims, observations, and provenance
- `didactopus_pack`: guided concepts and prerequisite structure
- `bibliography`: CiteGeist bibliography entries
The first implementation uses the existing card stream and filters by
`source_kinds`. That keeps the model simple while preserving room for richer
notebook manifests later.
## evo-edu.org Pattern
For evo-edu.org, notebooks should frame a learning pathway around:
- an app or lab, such as Avida-ED or an ecology/fitness landscape tool
- the concept sequence needed to use the tool well
- common misconceptions and review prompts
- source readings or curriculum fragments
- bibliography support for instructors or deeper learners
This supports the current evo-edu direction: lab, atlas, and guided study in
one coherent site.
## TalkOrigins Pattern
For the TalkOrigins modernization proof-of-concept, notebooks should frame:
- a claim or topic
- relevant Index to Creationist Claims entries
- stable Archive articles
- Panda's Thumb or TalkDesign context when appropriate
- bibliography updates and provenance
This fits the static/dynamic split: stable archive material remains stable,
while notebook pages can provide a modern guided route through it.
## Panda's Thumb Pattern
For Panda's Thumb, notebooks should work as topic dossiers:
- a recurring topic or controversy
- MT-era authoritative posts where available
- scraped-corpus posts for later years
- related Index to Creationist Claims material
- citations and source trails
The notebook should identify provenance clearly when the same topic is covered
by multiple corpora.
## Design Rule
Keep notebooks static, reviewable, and source-aware. If a workflow needs
learner state, mastery ledgers, evaluator behavior, or interactive mentoring,
that belongs in Didactopus. SciSiteForge should publish the durable study
surface.

View File

@ -1,10 +1,12 @@
# Using the evo-edu Framework # Using SciSiteForge
## 1. Copy Theme Files ## 1. Choose a Theme
Copy `/theme/` into your sites root. Select one of the shipped presets under `/theme/themes/` and let the build
script materialize it into your sites output tree.
## 2. Create Pages ## 2. Create Pages
Use `base.html` as a template. Replace `{{ }}` placeholders with actual content. Use the selected theme's `base.html` as a template. Replace `{{ }}` placeholders
with actual content.
## 3. Add Dynamic Behavior (Optional) ## 3. Add Dynamic Behavior (Optional)
Include `/theme/main.js` for: Include `/theme/main.js` for:
@ -16,4 +18,30 @@ Edit `style.css` to match your projects visual identity.
## 5. Multilingual Support ## 5. Multilingual Support
- Organize content under `/en/`, `/es/`, etc. - Organize content under `/en/`, `/es/`, etc.
- Update language switcher options in `base.html` - Update language switcher options in the theme template
- Translation generation is optional and separate from the static build path.
See [GENIEHIVE_TRANSLATION.md](GENIEHIVE_TRANSLATION.md) for the optional
GenieHive client settings and workflow.
## 6. Supported Presets and Bridges
- `evo-edu` for the learning-platform shell
- `talkorigins-modern` for the `www2.talkorigins.org` modernization proof-of-concept
- `pandasthumb` for the archive/news shell
- Content bridges for `doclift`, `GroundRecall`, `Didactopus`, and `CiteGeist`
## 7. Notebook Pattern
SciSiteForge notebooks are topic-level study modules. A notebook groups:
- goals and audience
- apps or labs
- source-derived sections from `doclift`, `GroundRecall`, and `Didactopus`
- bibliography entries from `CiteGeist`
- provenance-oriented links back to source material
Use notebooks when a site needs more than loose cards but does not need a full
learner application. The evo-edu instance can use this for digital evolution
study paths, while TalkOrigins can use the same pattern for claim-to-evidence
modules and Panda's Thumb can use it for topic dossiers.
See [NOTEBOOKS.md](NOTEBOOKS.md) for the generic notebook pattern and the
site-specific application notes.

View File

@ -0,0 +1,73 @@
{
"lang": "en",
"title": "TalkOrigins Archive: Modernized Preview",
"site_title": "TalkOrigins Archive",
"license": "CC BY-SA 4.0",
"github_url": "https://example.invalid/talkorigins-modern",
"contact_email": "admin@example.invalid",
"theme": "talkorigins-modern",
"languages": [
{ "code": "en", "name": "English", "coverage": true },
{ "code": "es", "name": "Español", "coverage": false },
{ "code": "fr", "name": "Français", "coverage": false },
{ "code": "pt", "name": "Português", "coverage": false },
{ "code": "de", "name": "Deutsch", "coverage": false },
{ "code": "it", "name": "Italiano", "coverage": false },
{ "code": "ru", "name": "Русский", "coverage": false },
{ "code": "zh", "name": "中文", "coverage": false },
{ "code": "ja", "name": "日本語", "coverage": false },
{ "code": "ar", "name": "العربية", "coverage": false },
{ "code": "hi", "name": "हिन्दी", "coverage": false }
],
"language_policy": {
"planned_languages": [
{ "code": "es", "name": "Español" },
{ "code": "fr", "name": "Français" },
{ "code": "pt", "name": "Português" },
{ "code": "de", "name": "Deutsch" },
{ "code": "it", "name": "Italiano" },
{ "code": "ru", "name": "Русский" },
{ "code": "zh", "name": "中文" },
{ "code": "ja", "name": "日本語" },
{ "code": "ar", "name": "العربية" },
{ "code": "hi", "name": "हिन्दी" }
]
},
"navigation": [
{ "label": "Home", "href": "/" },
{ "label": "Support", "href": "/foundation/2026-update/" },
{ "label": "Search", "href": "/search/" }
],
"hero": {
"kicker": "Archive Preview",
"title": "A cleaner, more readable TalkOrigins Archive that still feels like the Archive.",
"lede": "Use this preset as the proving ground for the www2.talkorigins.org modernization line.",
"actions": [
{ "label": "Open the preview", "href": "/#overview", "primary": true },
{ "label": "View the framework", "href": "https://example.invalid/scisiteforge", "primary": false }
]
},
"notebooks": [
{
"id": "evidence-and-claims",
"title": "Evidence and Claims Notebook",
"summary": "A reusable study module that can connect stable Archive articles, Index to Creationist Claims entries, guided concepts, and bibliography updates.",
"audience": "self-learners, instructors, and board-reviewed site editors",
"goals": [
"Move from a claim to the relevant evidence and archive context",
"Keep stable source material separate from dynamic commentary",
"Expose provenance, citations, and review status as first-class study material"
],
"apps": [
{
"title": "Public search",
"href": "/search/",
"description": "Search across Foundation corpora and the Index to Creationist Claims"
}
],
"source_kinds": ["section", "notebook", "app", "bibliography"],
"max_items": 8
}
],
"content_sources": {}
}

11
scisiteforge/__init__.py Normal file
View File

@ -0,0 +1,11 @@
from .content import (
ContentCard,
SiteContent,
load_citegeist_cards,
load_didactopus_cards,
load_doclift_cards,
load_groundrecall_cards,
)
from .render import render_template
from .notebook import Notebook, NotebookApp, load_notebooks, render_notebooks
from .themes import ThemeSpec, available_themes, get_theme, materialize_theme

25
scisiteforge/config.py Normal file
View File

@ -0,0 +1,25 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
DEFAULT_THEME = "evo-edu"
def load_config(path: str | Path) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def save_config(path: str | Path, config: dict[str, Any]) -> None:
Path(path).write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def resolve_path(value: str | Path | None, base_dir: str | Path | None = None) -> Path | None:
if value in (None, ""):
return None
path = Path(value)
if path.is_absolute() or base_dir is None:
return path
return Path(base_dir) / path

279
scisiteforge/content.py Normal file
View File

@ -0,0 +1,279 @@
from __future__ import annotations
from dataclasses import dataclass, field
import json
import re
from pathlib import Path
from typing import Any
from .render import html_escape
@dataclass(slots=True)
class ContentCard:
title: str
body: str
href: str = ""
meta: str = ""
kind: str = "feature"
source: str = ""
@dataclass(slots=True)
class SiteContent:
feature_cards: list[ContentCard] = field(default_factory=list)
section_cards: list[ContentCard] = field(default_factory=list)
app_cards: list[ContentCard] = field(default_factory=list)
bibliography_entries: list[ContentCard] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
def _first_paragraph(text: str) -> str:
paragraphs = [chunk.strip() for chunk in re.split(r"\n\s*\n", text) if chunk.strip()]
return paragraphs[0] if paragraphs else text.strip()
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _read_yaml(path: Path) -> Any:
text = path.read_text(encoding="utf-8")
try: # pragma: no cover - exercised only if PyYAML is installed
import yaml # type: ignore
return yaml.safe_load(text) or {}
except Exception:
stripped = text.strip()
if stripped.startswith("{") or stripped.startswith("["):
return json.loads(stripped)
return _parse_minimal_yaml(text)
def _parse_scalar(value: str) -> Any:
value = value.strip()
if value in {"", "null", "~"}:
return None
if value == "[]":
return []
if value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
if not inner:
return []
return [_parse_scalar(part) for part in inner.split(",")]
if value.startswith('"') and value.endswith('"'):
return value[1:-1]
if value.startswith("'") and value.endswith("'"):
return value[1:-1]
if value.isdigit():
return int(value)
if value.lower() in {"true", "false"}:
return value.lower() == "true"
return value
def _parse_minimal_yaml(text: str) -> dict[str, Any]:
lines = [line.rstrip() for line in text.splitlines() if line.strip() and not line.strip().startswith("#")]
root: dict[str, Any] = {}
current_key: str | None = None
current_item: dict[str, Any] | None = None
for index, raw in enumerate(lines):
stripped = raw.lstrip(" ")
indent = len(raw) - len(stripped)
if indent == 0:
current_item = None
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
key = key.strip()
value = value.strip()
if value:
root[key] = _parse_scalar(value)
else:
next_line = lines[index + 1] if index + 1 < len(lines) else ""
root[key] = [] if next_line.lstrip(" ").startswith("- ") else {}
current_key = key
continue
if current_key is None:
continue
container = root.get(current_key)
if isinstance(container, list) and stripped.startswith("- "):
item_text = stripped[2:].strip()
if not item_text:
current_item = {}
container.append(current_item)
elif ":" in item_text:
item_key, item_value = item_text.split(":", 1)
current_item = {item_key.strip(): _parse_scalar(item_value)}
container.append(current_item)
else:
current_item = None
container.append(_parse_scalar(item_text))
continue
target = current_item if isinstance(current_item, dict) else container
if isinstance(target, dict) and ":" in stripped:
key, value = stripped.split(":", 1)
target[key.strip()] = _parse_scalar(value)
elif isinstance(target, list) and stripped.startswith("- "):
target.append(_parse_scalar(stripped[2:]))
return root
def load_doclift_cards(bundle_root: str | Path) -> list[ContentCard]:
base = Path(bundle_root)
manifest = _read_json(base / "manifest.json")
cards: list[ContentCard] = []
for item in manifest.get("documents", []):
if not isinstance(item, dict):
continue
title = str(item.get("title") or item.get("document_id") or "Document")
body = str(item.get("summary") or item.get("description") or item.get("document_kind") or "")
markdown_path = item.get("markdown_path")
source_href = str(item.get("canonical_url") or item.get("source_path") or "")
if markdown_path:
md_path = base / str(markdown_path)
if md_path.exists():
body = _first_paragraph(md_path.read_text(encoding="utf-8"))
cards.append(
ContentCard(
title=title,
body=body,
href=source_href,
meta=str(item.get("document_kind") or "document"),
kind="notebook",
source=str(item.get("document_id") or title.lower().replace(" ", "-")),
)
)
return cards
def load_groundrecall_cards(bundle_root: str | Path) -> list[ContentCard]:
base = Path(bundle_root)
bundle_path = base / "groundrecall_query_bundle.json"
if not bundle_path.exists():
bundle_path = base / "exports" / "codex" / "codex_bundle.json"
if not bundle_path.exists():
return []
payload = _read_json(bundle_path)
concept = payload.get("concept") or {}
title = str(concept.get("title") or payload.get("title") or "GroundRecall concept")
body = str(payload.get("summary") or payload.get("explanation") or payload.get("body") or "")
claims = payload.get("claims") or payload.get("related_claims") or []
claim_count = len(claims) if isinstance(claims, list) else 0
cards = [
ContentCard(
title=title,
body=body or f"{claim_count} related claims and observations are bundled here.",
href=str(payload.get("source_url") or ""),
meta=f"GroundRecall bundle · {claim_count} claims",
kind="section",
source=str(concept.get("concept_id") or title.lower().replace(" ", "-")),
)
]
for claim in claims if isinstance(claims, list) else []:
if not isinstance(claim, dict):
continue
cards.append(
ContentCard(
title=str(claim.get("claim_text") or claim.get("title") or "Claim"),
body=str(claim.get("support") or claim.get("notes") or ""),
href=str(claim.get("source_url") or ""),
meta=str(claim.get("claim_kind") or "claim"),
kind="section",
source=str(claim.get("claim_id") or claim.get("id") or ""),
)
)
return cards
def load_didactopus_cards(pack_root: str | Path) -> list[ContentCard]:
base = Path(pack_root)
pack_path = base / "pack.yaml"
concepts_path = base / "concepts.yaml"
if not pack_path.exists() or not concepts_path.exists():
return []
pack = _read_yaml(pack_path) or {}
concepts = _read_yaml(concepts_path) or {}
cards: list[ContentCard] = []
for concept in concepts.get("concepts", []):
if not isinstance(concept, dict):
continue
title = str(concept.get("title") or concept.get("id") or "Concept")
description = str(concept.get("description") or "")
prerequisites = concept.get("prerequisites") or []
prereq_text = ", ".join(str(item) for item in prerequisites) if prerequisites else "None"
body = description or f"Prerequisites: {prereq_text}."
cards.append(
ContentCard(
title=title,
body=body,
href=str(pack.get("display_name") or pack.get("name") or ""),
meta=f"Didactopus concept · {prereq_text}",
kind="app",
source=str(concept.get("id") or title.lower().replace(" ", "-")),
)
)
return cards
def load_citegeist_cards(source_root: str | Path) -> list[ContentCard]:
root = Path(source_root)
bib_files = sorted(
path
for path in root.rglob("*.bib")
if path.is_file() and not path.name.endswith("-bak.bib") and not path.name.startswith(".")
)
if not bib_files:
return []
cards: list[ContentCard] = []
try:
from citegeist.bibtex import parse_bibtex # type: ignore
except Exception:
parse_bibtex = None
for bib_path in bib_files:
text = bib_path.read_text(encoding="utf-8")
entries = parse_bibtex(text) if parse_bibtex is not None else _fallback_parse_bibtex(text)
for entry in entries:
data = entry if isinstance(entry, dict) else entry.__dict__
title = str(data.get("title") or data.get("citation_key") or "Reference")
author = str(data.get("author") or data.get("editor") or "")
year = str(data.get("year") or "")
body = " · ".join(part for part in [author, year] if part).strip()
cards.append(
ContentCard(
title=title,
body=body or bib_path.name,
href=str(bib_path.relative_to(root)),
meta="CiteGeist bibliography",
kind="bibliography",
source=str(data.get("citation_key") or title.lower().replace(" ", "-")),
)
)
return cards
def _fallback_parse_bibtex(text: str) -> list[dict[str, str]]:
entries: list[dict[str, str]] = []
current: dict[str, str] | None = None
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("@") and "{" in stripped:
if current:
entries.append(current)
kind, rest = stripped[1:].split("{", 1)
key = rest.split(",", 1)[0].strip()
current = {"entry_type": kind.strip(), "citation_key": key}
continue
if current and "=" in stripped:
field, value = stripped.split("=", 1)
current[field.strip().lower()] = value.strip().strip(",{}")
if current:
entries.append(current)
return entries

110
scisiteforge/notebook.py Normal file
View File

@ -0,0 +1,110 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .content import ContentCard, SiteContent
from .render import html_escape
@dataclass(slots=True)
class NotebookApp:
title: str
href: str
description: str = ""
@dataclass(slots=True)
class Notebook:
notebook_id: str
title: str
summary: str = ""
audience: str = ""
goals: list[str] = field(default_factory=list)
apps: list[NotebookApp] = field(default_factory=list)
source_kinds: list[str] = field(default_factory=list)
max_items: int = 8
def load_notebooks(config: dict[str, Any]) -> list[Notebook]:
notebooks: list[Notebook] = []
for item in config.get("notebooks", []):
if not isinstance(item, dict):
continue
apps = [
NotebookApp(
title=str(app.get("title") or app.get("href") or "App"),
href=str(app.get("href") or "#"),
description=str(app.get("description") or ""),
)
for app in item.get("apps", [])
if isinstance(app, dict)
]
notebooks.append(
Notebook(
notebook_id=str(item.get("id") or item.get("notebook_id") or item.get("title") or "notebook"),
title=str(item.get("title") or "Notebook"),
summary=str(item.get("summary") or item.get("description") or ""),
audience=str(item.get("audience") or ""),
goals=[str(goal) for goal in item.get("goals", [])],
apps=apps,
source_kinds=[str(kind) for kind in item.get("source_kinds", [])],
max_items=int(item.get("max_items") or 8),
)
)
return notebooks
def select_notebook_cards(notebook: Notebook, site_content: SiteContent) -> list[ContentCard]:
cards = (
site_content.section_cards
+ site_content.app_cards
+ site_content.feature_cards
+ site_content.bibliography_entries
)
if notebook.source_kinds:
allowed = set(notebook.source_kinds)
cards = [card for card in cards if card.kind in allowed or card.meta in allowed]
return cards[: notebook.max_items]
def render_notebooks(notebooks: list[Notebook], site_content: SiteContent) -> str:
if not notebooks:
return ""
return "\n".join(render_notebook(notebook, site_content) for notebook in notebooks)
def render_notebook(notebook: Notebook, site_content: SiteContent) -> str:
cards = select_notebook_cards(notebook, site_content)
goals_html = "".join(f"<li>{html_escape(goal)}</li>" for goal in notebook.goals)
apps_html = "".join(
(
'<li>'
f'<a href="{html_escape(app.href)}">{html_escape(app.title)}</a>'
f' <span class="meta">{html_escape(app.description)}</span>'
'</li>'
)
for app in notebook.apps
)
cards_html = "".join(
(
'<li>'
f'<strong>{html_escape(card.title)}</strong>'
f' <span class="meta">{html_escape(card.meta)}</span>'
f'<p>{html_escape(card.body)}</p>'
'</li>'
)
for card in cards
)
audience_html = f'<p class="meta">Audience: {html_escape(notebook.audience)}</p>' if notebook.audience else ""
goals_block = f'<div><h3>Goals</h3><ul class="plain-list">{goals_html}</ul></div>' if goals_html else ""
apps_block = f'<div><h3>Apps and Labs</h3><ul class="plain-list">{apps_html}</ul></div>' if apps_html else ""
cards_block = f'<div><h3>Study Material</h3><ul class="plain-list">{cards_html}</ul></div>' if cards_html else ""
return (
f'<article class="notebook-panel" id="{html_escape(notebook.notebook_id)}">'
f'<h2>{html_escape(notebook.title)}</h2>'
f'<p>{html_escape(notebook.summary)}</p>'
f'{audience_html}'
f'<div class="notebook-grid">{goals_block}{apps_block}{cards_block}</div>'
'</article>'
)

31
scisiteforge/render.py Normal file
View File

@ -0,0 +1,31 @@
from __future__ import annotations
from html import escape
import re
from pathlib import Path
from typing import Any
_PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}")
def html_escape(value: Any) -> str:
return escape("" if value is None else str(value), quote=True)
def render_template(template: str, context: dict[str, Any]) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group(1)
value = context.get(key, "")
return "" if value is None else str(value)
return _PLACEHOLDER_RE.sub(replace, template)
def read_text(path: str | Path) -> str:
return Path(path).read_text(encoding="utf-8")
def write_text(path: str | Path, text: str) -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(text, encoding="utf-8")

105
scisiteforge/themes.py Normal file
View File

@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import shutil
REPO_ROOT = Path(__file__).resolve().parents[1]
@dataclass(frozen=True)
class ThemeSpec:
name: str
display_name: str
template_path: Path
stylesheet_path: Path
extra_assets: tuple[Path, ...] = field(default_factory=tuple)
body_class: str = ""
shell_class: str = ""
page_class: str = ""
description: str = ""
def _theme_path(*parts: str) -> Path:
return REPO_ROOT.joinpath(*parts)
_THEMES: dict[str, ThemeSpec] = {
"evo-edu": ThemeSpec(
name="evo-edu",
display_name="Evo-Edu",
template_path=_theme_path("theme", "themes", "evo-edu", "base.html"),
stylesheet_path=_theme_path("theme", "themes", "evo-edu", "style.css"),
body_class="theme-evo-edu",
shell_class="site-shell",
page_class="evo-edu-page",
description="Warm learning-focused theme derived from the evo-edu.org home page.",
),
"talkorigins-modern": ThemeSpec(
name="talkorigins-modern",
display_name="TalkOrigins Modern",
template_path=_theme_path("theme", "themes", "talkorigins-modern", "base.html"),
stylesheet_path=_theme_path("theme", "themes", "talkorigins-modern", "style.css"),
extra_assets=(
_theme_path("theme", "themes", "talkorigins-modern", "assets", "toa.ico"),
_theme_path("theme", "themes", "talkorigins-modern", "assets", "toa_logo_001_edit_001.png"),
),
body_class="theme-talkorigins-modern",
shell_class="site-shell",
page_class="talkorigins-preview",
description="Archive-forward theme derived from the www2.talkorigins.org modernization proof-of-concept.",
),
"pandasthumb": ThemeSpec(
name="pandasthumb",
display_name="Panda's Thumb",
template_path=_theme_path("theme", "themes", "pandasthumb", "base.html"),
stylesheet_path=_theme_path("theme", "themes", "pandasthumb", "style.css"),
body_class="theme-pandasthumb",
shell_class="site-shell",
page_class="pandasthumb-page",
description="Legacy-archive theme derived from pandasthumb.net.",
),
}
def available_themes() -> list[ThemeSpec]:
return [_THEMES[name] for name in sorted(_THEMES)]
def get_theme(name: str | None) -> ThemeSpec:
theme_name = name or "evo-edu"
try:
return _THEMES[theme_name]
except KeyError as exc:
raise KeyError(f"Unknown SciSiteForge theme: {theme_name}") from exc
def materialize_theme(theme: ThemeSpec, output_dir: str | Path) -> dict[str, str]:
out = Path(output_dir)
theme_root = out / "theme"
assets_root = theme_root / "assets"
assets_root.mkdir(parents=True, exist_ok=True)
style_target = theme_root / "style.css"
shutil.copyfile(theme.stylesheet_path, style_target)
copied_assets: list[str] = []
for asset in theme.extra_assets:
target = assets_root / asset.name
shutil.copyfile(asset, target)
copied_assets.append(target.relative_to(out).as_posix())
shared_js = _theme_path("theme", "main.js")
if shared_js.exists():
shutil.copyfile(shared_js, theme_root / "main.js")
return {
"theme_name": theme.name,
"theme_display_name": theme.display_name,
"theme_description": theme.description,
"theme_stylesheet_href": "/theme/style.css",
"theme_script_href": "/theme/main.js",
"theme_asset_prefix": "/theme/assets",
"theme_assets": copied_assets,
}

View File

@ -0,0 +1,71 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from urllib import error, request
@dataclass(slots=True)
class TranslationConfig:
provider: str = "geniehive"
base_url: str = "http://127.0.0.1:8800"
model: str = "general_assistant"
api_key: str = ""
timeout: int = 120
system_prompt: str = (
"You are a careful scientific translator. Preserve meaning, section structure, "
"and technical terminology. Return only the translation."
)
class GenieHiveTranslator:
def __init__(self, config: TranslationConfig):
if config.provider != "geniehive":
raise ValueError(f"Unsupported translation provider: {config.provider}")
self.config = config
def translate(self, text: str, target_language: str, glossary: dict[str, str] | None = None) -> str:
if not text.strip():
return text
prompt = self._build_prompt(text, target_language, glossary or {})
payload = {
"model": self.config.model,
"messages": [
{"role": "system", "content": self.config.system_prompt},
{"role": "user", "content": prompt},
],
"temperature": 0.1,
}
response = self._post_json("/v1/chat/completions", payload)
try:
return response["choices"][0]["message"]["content"].strip()
except Exception as exc:
raise RuntimeError("GenieHive response did not contain a translation.") from exc
def _build_prompt(self, text: str, target_language: str, glossary: dict[str, str]) -> str:
glossary_text = ""
if glossary:
glossary_text = "Use these translations when they fit the target language:\n" + "\n".join(
f"- {source} => {target}" for source, target in glossary.items()
)
glossary_text += "\n\n"
return (
f"Translate the following English text into {target_language}.\n"
"Keep the HTML/text structure intact. Do not add commentary.\n\n"
f"{glossary_text}Text:\n{text}\n"
)
def _post_json(self, path: str, payload: dict) -> dict:
url = self.config.base_url.rstrip("/") + path
data = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json"}
if self.config.api_key:
headers["Authorization"] = f"Bearer {self.config.api_key}"
req = request.Request(url, data=data, headers=headers, method="POST")
try:
with request.urlopen(req, timeout=self.config.timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except error.HTTPError as exc: # pragma: no cover - network path
raise RuntimeError(f"GenieHive request failed with HTTP {exc.code}") from exc
except error.URLError as exc: # pragma: no cover - network path
raise RuntimeError(f"GenieHive request failed: {exc.reason}") from exc

View File

@ -1,74 +1,55 @@
# SciSiteForge Scripts # SciSiteForge Scripts
## 🛠️ Build ## Build
🧪 Usage Example Initialize a site config:
Initialize config: ```bash
bash cd /opt/www/dev/SciSiteForge
python3 scripts/build.py --init
```
Build a site:
```bash
python3 scripts/build.py --config site.json --output /tmp/scisiteforge-site
```
The shipped theme presets are:
cd domain_di/framework - `evo-edu`
python build.py --init - `talkorigins-modern`
→ creates site.json - `pandasthumb`
Build English site:
python build.py --config site.json --output ../content/en/
Build Spanish site (after editing site.json to set "lang": "es"):
bash
python build.py --config site-es.json --output ../content/es/
### Benefits
- No runtime dependencies: Output is pure static HTML/CSS/JS
- Reusable: Same framework for any educational site
- Customizable: Each project has its own site.json
- Automation-friendly: Integrate into CI/CD or translation pipelines
Use `talkorigins-modern` as the proving ground for the
`www2.talkorigins.org` modernization line.
## Translate ## Translate
This site framework supports offline multilingual translation using Llamafile. Translation is optional and separate from the static build. The current
translation provider is GenieHive through its OpenAI-compatible chat endpoint.
### Prerequisites See `docs/GENIEHIVE_TRANSLATION.md` for the SciSiteForge client-side
- Download a multilingual GGUF model (e.g., `mistral-7b-instruct.Q5_K_M.gguf`) configuration guide and the GenieHive repository's
- Install [Llamafile](https://github.com/Mozilla-Ocho/llamafile) `docs/translation_support.md` for the control-plane and node-side notes.
- Python 3 with `requests` and `beautifulsoup4`
### Steps
1. Launch Llamafile:
```bash ```bash
./mistral-7b-instruct.Q5_K_M.llamafile --port 8080 python3 scripts/translate_site.py \
``` --config site.json \
2. Run translation: --langs es,fr \
```bash --src content/en \
python scripts/translate_site.py --langs es,fr --dest content
```
3. Commit translated content:
```bash
git add es/ fr/
``` ```
> Translated files are saved to `/es/`, `/fr/`, etc., and served alongside English content. Optional translation settings can be provided in the site config under
``` `translation`:
#### 📁 `example/content/scripts/glossary_es.json` - `provider`
→ Language-specific scientific term mappings - `base_url`
- `model`
- `api_key`
- `timeout`
- `system_prompt`
The translator loads language glossaries from `scripts/glossary_<lang>.json`
when present.

View File

@ -1,120 +1,224 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """Static site generator for SciSiteForge."""
Static site generator for evo-edu framework.
Two modes: from __future__ import annotations
1. --init : Prompt user for site config and save to site.json
2. --config <file> --output <dir> : Render templates using config
"""
import os
import json
import argparse import argparse
from pathlib import Path from pathlib import Path
import shutil import sys
from typing import Any
# Template directory (relative to this script) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
TEMPLATE_DIR = Path(__file__).parent / "templates"
def prompt_for_config(): from scisiteforge.config import DEFAULT_THEME, load_config, save_config
"""Prompt user for site configuration.""" from scisiteforge.content import (
print("=== evo-edu Framework Site Config ===") SiteContent,
config = { load_citegeist_cards,
"lang": input("Language code (e.g., 'en'): ") or "en", load_didactopus_cards,
"title": input("Page title (e.g., 'Notebook On Evolution'): ") or "Notebook On Evolution", load_doclift_cards,
"site_title": input("Site name (e.g., 'evo-edu.org'): ") or "evo-edu.org", load_groundrecall_cards,
"license": input("License text (e.g., 'CC BY-SA 4.0'): ") or "CC BY-SA 4.0", )
"github_url": input("GitHub URL: ") or "https://github.com/evo-edu", from scisiteforge.render import html_escape, read_text, render_template, write_text
"contact_email": input("Contact email: ") or "admin@evo-edu.org", from scisiteforge.notebook import load_notebooks, render_notebooks
"languages": [] from scisiteforge.themes import available_themes, get_theme, materialize_theme
def _prompt_for_config() -> dict[str, Any]:
print("=== SciSiteForge Site Config ===")
themes = available_themes()
print("Available themes:")
for theme in themes:
print(f" - {theme.name}: {theme.description}")
theme_name = input(f"Theme name (default: {DEFAULT_THEME}): ").strip() or DEFAULT_THEME
languages_input = input("Languages (code:name pairs, comma-separated; default: en:English): ").strip() or "en:English"
languages = []
for pair in languages_input.split(","):
code, name = pair.strip().split(":", 1)
languages.append({"code": code.strip(), "name": name.strip()})
return {
"lang": input("Language code (default: en): ").strip() or "en",
"title": input("Page title (default: SciSiteForge Preview): ").strip() or "SciSiteForge Preview",
"site_title": input("Site name (default: SciSiteForge): ").strip() or "SciSiteForge",
"license": input("License text (default: CC BY-SA 4.0): ").strip() or "CC BY-SA 4.0",
"github_url": input("GitHub URL (optional): ").strip() or "https://github.com/",
"contact_email": input("Contact email (optional): ").strip() or "admin@example.org",
"theme": theme_name,
"languages": languages,
"navigation": [
{"label": "Home", "href": "/"},
],
"hero": {
"kicker": "Preview",
"title": "A site shell that can adapt to more than one audience.",
"lede": "SciSiteForge now supports multiple theme presets and local content loaders for reusable science sites.",
"actions": [
{"label": "Read the overview", "href": "#overview", "primary": True},
{"label": "Theme catalog", "href": "#themes", "primary": False},
],
},
"content_sources": {},
"notebooks": [],
} }
# Language options
print("\nLanguage switcher options (e.g., en:English, es:Español):")
lang_input = input("Enter as 'code:name' pairs (comma-separated): ") or "en:English"
for pair in lang_input.split(','):
code, name = pair.strip().split(':', 1)
config["languages"].append({"code": code, "name": name})
lang_options = "\n".join([
f'<option value="{lang["code"]}" {"selected" if lang["code"] == config["lang"] else ""}>{lang["name"]}</option>'
for lang in config["languages"]
])
result = result.replace("{{language_options}}", lang_options)
# Save
out_file = input("\nSave config as (default: site.json): ") or "site.json"
with open(out_file, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2)
print(f"✅ Config saved to {out_file}")
def render_template(template_text, config): def _language_options_html(languages: list[dict[str, str]], current_lang: str) -> str:
"""Replace {{key}} and {{#each}} blocks with config values.""" visible_languages = [
result = template_text item
for item in languages
if item.get("coverage", True) or item.get("code") == current_lang
]
return "\n".join(
f'<option value="{html_escape(item["code"])}" {"selected" if item["code"] == current_lang else ""}>{html_escape(item["name"])}</option>'
for item in visible_languages
)
# Simple key replacements
for key, value in config.items():
if key != "languages":
result = result.replace("{{" + key + "}}", str(value))
# Handle {{#each languages}}...{{/each}} def _language_policy_html(language_policy: dict[str, Any]) -> str:
if "{{#each languages}}" in result: planned_languages = language_policy.get("planned_languages", [])
lang_block_start = result.find("{{#each languages}}") if not planned_languages:
lang_block_end = result.find("{{/each}}", lang_block_start) return ""
if lang_block_end != -1: planned_names = ", ".join(html_escape(item.get("name", item.get("code", ""))) for item in planned_languages if item.get("name") or item.get("code"))
block = result[lang_block_start + len("{{#each languages}}"):lang_block_end] if not planned_names:
rendered_langs = [] return ""
for lang in config.get("languages", []): return f'<p class="language-policy-note">Planned languages: {planned_names}</p>'
lang_item = block
lang_item = lang_item.replace("{{code}}", lang["code"])
lang_item = lang_item.replace("{{name}}", lang["name"])
# Handle {{#if (eq code ../lang)}}
if f'{{{{lang}}}}' in result:
selected = 'selected' if lang["code"] == config.get("lang", "") else ''
lang_item = lang_item.replace("{{selected_attr}}", f'selected="{selected}"' if selected else '')
else:
lang_item = lang_item.replace("{{selected_attr}}", "")
rendered_langs.append(lang_item)
result = result[:lang_block_start] + "".join(rendered_langs) + result[lang_block_end + len("{{/each}}"):]
return result
def build_site(config_file, output_dir): def _hero_actions_html(actions: list[dict[str, Any]]) -> str:
"""Render all templates using config.""" if not actions:
with open(config_file, 'r', encoding='utf-8') as f: return ""
config = json.load(f) return "\n".join(
f'<a class="button-link{" button-link-secondary" if not action.get("primary") else ""}" href="{html_escape(action.get("href", "#"))}">{html_escape(action.get("label", "Open"))}</a>'
for action in actions
)
def _navigation_html(navigation: list[dict[str, str]]) -> str:
return "\n".join(
f'<a href="{html_escape(item.get("href", "#"))}">{html_escape(item.get("label", "Link"))}</a>'
for item in navigation
)
def _render_cards(cards: list, template_path: str | Path, lang: str) -> str:
if not cards:
return ""
template = read_text(template_path)
rendered: list[str] = []
for card in cards:
rendered.append(
render_template(
template,
{
"lang": lang,
"app_title": html_escape(card.title),
"app_description": html_escape(card.body),
"app_slug": html_escape(card.source or card.title.lower().replace(" ", "-")),
"section_title": html_escape(card.title),
"section_meta": html_escape(card.meta),
"section_excerpt": html_escape(card.body),
"section_path": html_escape(card.source or card.title.lower().replace(" ", "-")),
"href": html_escape(card.href),
"link_label": "Open",
},
)
)
return "\n".join(rendered)
def build_site(config_file: str | Path, output_dir: str | Path) -> dict[str, Any]:
config = load_config(config_file)
theme = get_theme(config.get("theme"))
out_path = Path(output_dir) out_path = Path(output_dir)
out_path.mkdir(parents=True, exist_ok=True) out_path.mkdir(parents=True, exist_ok=True)
# Copy theme assets theme_context = materialize_theme(theme, out_path)
theme_src = Path(__file__).parent / "theme" template = read_text(theme.template_path)
for asset in ["style.css", "main.js"]:
shutil.copy(theme_src / asset, out_path / asset)
# Render base.html → index.html (example) content_sources = config.get("content_sources", {})
with open(theme_src / "base.html", 'r', encoding='utf-8') as f: site_content = SiteContent()
template = f.read() if source := content_sources.get("doclift_bundle"):
site_content.section_cards.extend(load_doclift_cards(source))
if source := content_sources.get("groundrecall_bundle"):
site_content.section_cards.extend(load_groundrecall_cards(source))
if source := content_sources.get("didactopus_pack"):
site_content.feature_cards.extend(load_didactopus_cards(source))
if source := content_sources.get("bibliography"):
site_content.bibliography_entries.extend(load_citegeist_cards(source))
notebooks = load_notebooks(config)
rendered = render_template(template, config) languages = config.get("languages", [{"code": config.get("lang", "en"), "name": "English", "coverage": True}])
with open(out_path / "index.html", 'w', encoding='utf-8') as f: language_policy = config.get("language_policy", {})
f.write(rendered) hero = config.get("hero", {})
page_context = {
"lang": config.get("lang", "en"),
"page_title": html_escape(config.get("title", config.get("site_title", "SciSiteForge"))),
"site_title": html_escape(config.get("site_title", "SciSiteForge")),
"description": html_escape(config.get("description", "")),
"license": html_escape(config.get("license", "CC BY-SA 4.0")),
"github_url": html_escape(config.get("github_url", "")),
"contact_email": html_escape(config.get("contact_email", "")),
"theme_name": html_escape(theme.name),
"theme_display_name": html_escape(theme.display_name),
"theme_description": html_escape(theme.description),
"theme_stylesheet_href": theme_context["theme_stylesheet_href"],
"theme_script_href": theme_context["theme_script_href"],
"theme_asset_prefix": theme_context["theme_asset_prefix"],
"body_class": html_escape(theme.body_class),
"site_shell_class": html_escape(theme.shell_class),
"page_class": html_escape(theme.page_class),
"navigation_html": _navigation_html(config.get("navigation", [])),
"language_options": _language_options_html(languages, config.get("lang", "en")),
"language_policy_html": _language_policy_html(language_policy),
"hero_kicker": html_escape(hero.get("kicker", theme.display_name)),
"hero_title": html_escape(hero.get("title", config.get("title", ""))),
"hero_lede": html_escape(hero.get("lede", config.get("description", ""))),
"hero_actions_html": _hero_actions_html(hero.get("actions", [])),
"feature_cards_html": _render_cards(site_content.feature_cards, Path(__file__).parent.parent / "templates" / "app-card.html", config.get("lang", "en")),
"section_cards_html": _render_cards(site_content.section_cards, Path(__file__).parent.parent / "templates" / "notebook-section.html", config.get("lang", "en")),
"app_cards_html": _render_cards(site_content.app_cards, Path(__file__).parent.parent / "templates" / "app-card.html", config.get("lang", "en")),
"bibliography_html": "\n".join(
f'<li><strong>{html_escape(card.title)}</strong> <span class="meta">{html_escape(card.body)}</span></li>'
for card in site_content.bibliography_entries
),
"notebook_html": render_notebooks(notebooks, site_content),
}
page_context.update(
{
"content_panels_html": page_context["feature_cards_html"] + "\n" + page_context["section_cards_html"],
}
)
rendered = render_template(template, page_context)
write_text(out_path / "index.html", rendered)
return {"output_dir": str(out_path), "theme": theme.name, "theme_assets": theme_context["theme_assets"]}
print(f"✅ Site built in {output_dir}")
def main(): def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser(description="Build a SciSiteForge site from a JSON config.")
parser.add_argument("--init", action="store_true", help="Create site config interactively") parser.add_argument("--init", action="store_true", help="Create site config interactively")
parser.add_argument("--config", help="Path to site.json") parser.add_argument("--config", help="Path to site.json")
parser.add_argument("--output", help="Output directory for built site") parser.add_argument("--output", help="Output directory for built site")
parser.add_argument("--themes", action="store_true", help="List the built-in theme presets")
parser.add_argument("--save-config", help="Where to write the config when using --init", default="site.json")
args = parser.parse_args() args = parser.parse_args()
if args.themes:
for theme in available_themes():
print(f"{theme.name}: {theme.description}")
return
if args.init: if args.init:
prompt_for_config() config = _prompt_for_config()
elif args.config and args.output: save_config(args.save_config, config)
build_site(args.config, args.output) print(f"Wrote config to {args.save_config}")
else: return
print("Usage:")
print(" python build.py --init") if args.config and args.output:
print(" python build.py --config site.json --output ../content/en/") result = build_site(args.config, args.output)
print(f"Built {result['output_dir']} with theme {result['theme']}")
return
parser.print_help()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,119 +1,111 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """Optional offline multilingual translation for SciSiteForge sites."""
Offline multilingual translation for evo-edu.org using Llamafile.
Requires: BeautifulSoup4, requests from __future__ import annotations
Install with: pip install beautifulsoup4 requests
"""
import os
import json
import argparse import argparse
import time import json
import sys
from pathlib import Path from pathlib import Path
from bs4 import BeautifulSoup, NavigableString from time import sleep
import requests
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scisiteforge.config import load_config
from scisiteforge.translations import GenieHiveTranslator, TranslationConfig
# --- Configuration ---
MODEL_API_URL = "http://localhost:8080/completion"
LANGUAGES = { LANGUAGES = {
"es": "Spanish", "es": "Spanish",
"fr": "French", "fr": "French",
"pt": "Portuguese", "pt": "Portuguese",
"de": "German" "de": "German",
"it": "Italian",
"ru": "Russian",
"zh": "Chinese",
"ja": "Japanese",
"ar": "Arabic",
"hi": "Hindi",
} }
def translate_text(text, target_lang_name, glossary=None):
"""Translate a block of text using Llamafile."""
if not text.strip():
return text
glossary_text = ""
if glossary:
glossary_text = "Use these translations:\n" + "\n".join(f"'{k}' → '{v}'" for k, v in glossary.items()) + "\n\n"
prompt = f"""You are a scientific translator. Translate the following English text into {target_lang_name}.
Preserve technical terms like "genetic drift" or "natural selection" unless a standard translation exists.
Maintain paragraph structure. Do not add commentary.
{glossary_text}Text:
"{text}"
Translation:"""
def _load_bs4():
try: try:
response = requests.post(MODEL_API_URL, json={ from bs4 import BeautifulSoup, NavigableString # type: ignore
"prompt": prompt, except Exception as exc: # pragma: no cover - import-time fallback
"temperature": 0.1, raise RuntimeError("BeautifulSoup4 is required for HTML translation.") from exc
"stop": ["\n\n", "Text:", "Translation:"], return BeautifulSoup, NavigableString
"n_predict": 1024
}, timeout=120)
response.raise_for_status()
result = response.json()["content"].strip()
return result
except Exception as e:
print(f" âš ï¸ Translation failed: {e}")
return text # fallback to original
def extract_translatable_text(soup): def extract_translatable_text(soup):
"""Extract text nodes for translation, preserving structure.""" _, NavigableString = _load_bs4()
texts = []
for elem in soup.descendants: for elem in soup.descendants:
if isinstance(elem, NavigableString) and elem.parent.name not in ['script', 'style']: if isinstance(elem, NavigableString) and elem.parent.name not in ["script", "style"]:
if elem.strip(): if elem.strip():
texts.append(elem) yield elem
return texts
def translate_html_file(src_path, dest_path, target_lang_code):
"""Translate an HTML file."""
print(f"Translating {src_path} → {dest_path}")
with open(src_path, 'r', encoding='utf-8') as f:
html = f.read()
soup = BeautifulSoup(html, 'html.parser') def translate_html_file(src_path: Path, dest_path: Path, target_lang_code: str, translator: GenieHiveTranslator, glossary: dict[str, str] | None = None) -> None:
text_nodes = extract_translatable_text(soup) BeautifulSoup, _ = _load_bs4()
print(f"Translating {src_path} -> {dest_path}")
# Optional: load glossary for this language html = src_path.read_text(encoding="utf-8")
glossary = {} soup = BeautifulSoup(html, "html.parser")
glossary_path = Path(__file__).parent / f"glossary_{target_lang_code}.json" for node in extract_translatable_text(soup):
if glossary_path.exists(): translated = translator.translate(str(node), LANGUAGES[target_lang_code], glossary=glossary)
with open(glossary_path, 'r') as f:
glossary = json.load(f)
# Translate each text node
for node in text_nodes:
original = str(node)
translated = translate_text(original, LANGUAGES[target_lang_code], glossary)
node.replace_with(translated) node.replace_with(translated)
time.sleep(0.1) # be gentle on CPU sleep(0.05)
# Save translated HTML
dest_path.parent.mkdir(parents=True, exist_ok=True) dest_path.parent.mkdir(parents=True, exist_ok=True)
with open(dest_path, 'w', encoding='utf-8') as f: dest_path.write_text(str(soup), encoding="utf-8")
f.write(str(soup))
def main():
parser = argparse.ArgumentParser() def build_translator(config_path: str | Path | None, args: argparse.Namespace) -> GenieHiveTranslator:
parser.add_argument("--langs", required=True, help="Comma-separated language codes (e.g., es,fr)") site_config = {}
if config_path:
site_config = load_config(config_path)
translation_cfg = site_config.get("translation", {})
provider = getattr(args, "provider", None) or translation_cfg.get("provider") or "geniehive"
cfg = TranslationConfig(
provider=provider,
base_url=args.base_url or translation_cfg.get("base_url") or "http://127.0.0.1:8800",
model=args.model or translation_cfg.get("model") or "general_assistant",
api_key=args.api_key or translation_cfg.get("api_key") or "",
timeout=args.timeout or int(translation_cfg.get("timeout") or 120),
system_prompt=translation_cfg.get("system_prompt")
or "You are a careful scientific translator. Preserve meaning, structure, and technical terms. Return only the translation.",
)
return GenieHiveTranslator(cfg)
def main() -> None:
parser = argparse.ArgumentParser(description="Translate a SciSiteForge site with an optional provider backend.")
parser.add_argument("--langs", required=True, help="Comma-separated language codes (e.g. es,fr)")
parser.add_argument("--src", default="content/en", help="Source directory (English)") parser.add_argument("--src", default="content/en", help="Source directory (English)")
parser.add_argument("--dest", default="content", help="Base destination directory") parser.add_argument("--dest", default="content", help="Base destination directory")
parser.add_argument("--config", help="Optional site config to pull GenieHive settings from")
parser.add_argument("--provider", help="Translation provider (currently: geniehive)")
parser.add_argument("--base-url", help="Provider base URL (default for GenieHive: http://127.0.0.1:8800)")
parser.add_argument("--model", help="Provider model or role alias")
parser.add_argument("--api-key", help="Provider API key")
parser.add_argument("--timeout", type=int, help="HTTP timeout in seconds")
args = parser.parse_args() args = parser.parse_args()
lang_codes = args.langs.split(',') translator = build_translator(args.config, args)
src_base = Path(args.src) src_base = Path(args.src)
dest_base = Path(args.dest) dest_base = Path(args.dest)
glossary_cache: dict[str, dict[str, str]] = {}
for lang_code in lang_codes: for lang_code in args.langs.split(","):
if lang_code not in LANGUAGES: if lang_code not in LANGUAGES:
print(f"Unsupported language: {lang_code}") print(f"Unsupported language: {lang_code}")
continue continue
print(f"\n=== Translating to {LANGUAGES[lang_code]} ({lang_code}) ===") print(f"\n=== Translating to {LANGUAGES[lang_code]} ({lang_code}) ===")
glossary_path = Path(__file__).parent / f"glossary_{lang_code}.json"
glossary = glossary_cache.setdefault(lang_code, json.loads(glossary_path.read_text(encoding="utf-8")) if glossary_path.exists() else {})
for html_file in src_base.rglob("*.html"): for html_file in src_base.rglob("*.html"):
rel_path = html_file.relative_to(src_base) rel_path = html_file.relative_to(src_base)
dest_file = dest_base / lang_code / rel_path translate_html_file(html_file, dest_base / lang_code / rel_path, lang_code, translator, glossary=glossary)
translate_html_file(html_file, dest_file, lang_code) print("\nTranslation complete.")
print("\n✅ Translation complete.")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,5 +1,5 @@
<article class="card"> <article class="card feature-card">
<h3>{{ app_title }}</h3> <h3>{{ app_title }}</h3>
<p>{{ app_description }}</p> <p>{{ app_description }}</p>
<a href="/{{ lang }}/apps/{{ app_slug }}/" class="btn">Launch App</a> <a href="/{{ lang }}/apps/{{ app_slug }}/" class="btn button-link">Launch App</a>
</article> </article>

View File

@ -1,7 +1,7 @@
<div class="section-card"> <div class="section-card card">
<h3>{{ section_title }}</h3> <h3>{{ section_title }}</h3>
<p class="meta">{{ section_meta }}</p> <p class="meta">{{ section_meta }}</p>
<p class="excerpt">{{ section_excerpt }}</p> <p class="excerpt">{{ section_excerpt }}</p>
<button class="expand-btn" data-src="/{{ lang }}/notebook/{{ section_path }}">Show</button> <button class="expand-btn btn button-link" data-src="/{{ lang }}/notebook/{{ section_path }}">Show</button>
<div class="content"></div> <div class="content"></div>
</div> </div>

9
tests/conftest.py Normal file
View File

@ -0,0 +1,9 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))

326
tests/test_scisiteforge.py Normal file
View File

@ -0,0 +1,326 @@
from __future__ import annotations
import json
import unittest
from pathlib import Path
from types import SimpleNamespace
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))
import build
import translate_site
from scisiteforge.content import SiteContent, load_citegeist_cards, load_didactopus_cards, load_doclift_cards, load_groundrecall_cards
from scisiteforge.notebook import load_notebooks, render_notebooks
from scisiteforge.themes import get_theme, materialize_theme
from scisiteforge.translations import GenieHiveTranslator, TranslationConfig
class SciSiteForgeTests(unittest.TestCase):
def test_theme_materialization_copies_theme_assets(self) -> None:
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
theme = get_theme("talkorigins-modern")
payload = materialize_theme(theme, tmp_path)
self.assertTrue((tmp_path / "theme" / "style.css").exists())
self.assertTrue((tmp_path / "theme" / "main.js").exists())
self.assertTrue((tmp_path / "theme" / "assets" / "toa.ico").exists())
self.assertTrue((tmp_path / "theme" / "assets" / "toa_logo_001_edit_001.png").exists())
self.assertEqual(payload["theme_name"], "talkorigins-modern")
def test_content_loaders_parse_local_repo_artifacts(self) -> None:
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
doclift_root = tmp_path / "doclift"
doclift_root.mkdir()
(doclift_root / "manifest.json").write_text(
json.dumps(
{
"documents": [
{
"document_id": "doc-1",
"title": "Legacy Document",
"document_kind": "article",
"markdown_path": "documents/doc-1/document.md",
"source_path": "source/doc-1.html",
}
]
}
),
encoding="utf-8",
)
(doclift_root / "documents" / "doc-1").mkdir(parents=True)
(doclift_root / "documents" / "doc-1" / "document.md").write_text("First paragraph.\n\nSecond.", encoding="utf-8")
groundrecall_root = tmp_path / "groundrecall"
groundrecall_root.mkdir()
(groundrecall_root / "groundrecall_query_bundle.json").write_text(
json.dumps(
{
"concept": {"concept_id": "concept::topic", "title": "GroundRecall Topic"},
"summary": "Grounded summary.",
"claims": [{"claim_id": "clm-1", "claim_text": "Claim one", "claim_kind": "summary"}],
}
),
encoding="utf-8",
)
didactopus_root = tmp_path / "didactopus"
didactopus_root.mkdir()
(didactopus_root / "pack.yaml").write_text("name: test-pack\ndisplay_name: Test Pack\n", encoding="utf-8")
(didactopus_root / "concepts.yaml").write_text(
"concepts:\n - id: prior\n title: Prior\n description: Previous knowledge.\n prerequisites: []\n",
encoding="utf-8",
)
citegeist_root = tmp_path / "citegeist"
citegeist_root.mkdir()
(citegeist_root / "refs.bib").write_text(
"""@article{smith2024,\n title = {A Study},\n author = {Smith, Jane and Roe, John},\n year = {2024}\n}\n""",
encoding="utf-8",
)
doclift_cards = load_doclift_cards(doclift_root)
groundrecall_cards = load_groundrecall_cards(groundrecall_root)
didactopus_cards = load_didactopus_cards(didactopus_root)
citegeist_cards = load_citegeist_cards(citegeist_root)
self.assertEqual(doclift_cards[0].title, "Legacy Document")
self.assertEqual(doclift_cards[0].body, "First paragraph.")
self.assertEqual(groundrecall_cards[0].title, "GroundRecall Topic")
self.assertEqual(groundrecall_cards[1].title, "Claim one")
self.assertEqual(didactopus_cards[0].title, "Prior")
self.assertEqual(citegeist_cards[0].title, "A Study")
def test_build_site_renders_selected_theme_and_content(self) -> None:
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
content_root = tmp_path / "content"
content_root.mkdir()
doclift_root = content_root / "doclift"
doclift_root.mkdir()
(doclift_root / "manifest.json").write_text(
json.dumps({"documents": [{"document_id": "doc-1", "title": "Legacy Document", "markdown_path": "documents/doc-1/document.md"}]}),
encoding="utf-8",
)
(doclift_root / "documents" / "doc-1").mkdir(parents=True)
(doclift_root / "documents" / "doc-1" / "document.md").write_text("Doclift content.", encoding="utf-8")
didactopus_root = content_root / "didactopus"
didactopus_root.mkdir()
(didactopus_root / "pack.yaml").write_text("name: test-pack\ndisplay_name: Test Pack\n", encoding="utf-8")
(didactopus_root / "concepts.yaml").write_text(
"concepts:\n - id: prior\n title: Prior\n description: Previous knowledge.\n prerequisites: []\n",
encoding="utf-8",
)
groundrecall_root = content_root / "groundrecall"
groundrecall_root.mkdir()
(groundrecall_root / "groundrecall_query_bundle.json").write_text(
json.dumps({"concept": {"concept_id": "concept::topic", "title": "GroundRecall Topic"}, "summary": "Grounded summary."}),
encoding="utf-8",
)
citegeist_root = content_root / "citegeist"
citegeist_root.mkdir()
(citegeist_root / "refs.bib").write_text(
"""@article{smith2024,\n title = {A Study},\n author = {Smith, Jane and Roe, John},\n year = {2024}\n}\n""",
encoding="utf-8",
)
config = {
"lang": "en",
"title": "TalkOrigins Preview",
"site_title": "TalkOrigins Archive",
"license": "CC BY-SA 4.0",
"github_url": "https://example.invalid",
"contact_email": "admin@example.invalid",
"theme": "talkorigins-modern",
"languages": [{"code": "en", "name": "English"}],
"navigation": [{"label": "Home", "href": "/"}],
"hero": {
"kicker": "Archive Preview",
"title": "Modernized, reviewable, and still archive-first.",
"lede": "Proof-of-concept output from SciSiteForge.",
"actions": [{"label": "Open", "href": "#overview", "primary": True}],
},
"content_sources": {
"doclift_bundle": str(doclift_root),
"groundrecall_bundle": str(groundrecall_root),
"didactopus_pack": str(didactopus_root),
"bibliography": str(citegeist_root),
},
}
config_path = tmp_path / "site.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
out_dir = tmp_path / "out"
result = build.build_site(config_path, out_dir)
html = (out_dir / "index.html").read_text(encoding="utf-8")
self.assertEqual(result["theme"], "talkorigins-modern")
self.assertIn("TalkOrigins Preview", html)
self.assertIn("Legacy Document", html)
self.assertIn("GroundRecall Topic", html)
self.assertIn("Prior", html)
self.assertIn("A Study", html)
self.assertTrue((out_dir / "theme" / "assets" / "toa.ico").exists())
def test_build_site_filters_languages_by_coverage_and_shows_planned_list(self) -> None:
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
config = {
"lang": "en",
"title": "TalkOrigins Preview",
"site_title": "TalkOrigins Archive",
"license": "CC BY-SA 4.0",
"github_url": "https://example.invalid",
"contact_email": "admin@example.invalid",
"theme": "talkorigins-modern",
"languages": [
{"code": "en", "name": "English", "coverage": True},
{"code": "es", "name": "Español", "coverage": False},
{"code": "fr", "name": "Français", "coverage": False},
],
"language_policy": {
"planned_languages": [
{"code": "es", "name": "Español"},
{"code": "fr", "name": "Français"},
]
},
"navigation": [{"label": "Home", "href": "/"}],
"hero": {
"kicker": "Archive Preview",
"title": "Modernized, reviewable, and still archive-first.",
"lede": "Proof-of-concept output from SciSiteForge.",
"actions": [{"label": "Open", "href": "#overview", "primary": True}],
},
"content_sources": {},
}
config_path = tmp_path / "site.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
out_dir = tmp_path / "out"
build.build_site(config_path, out_dir)
html = (out_dir / "index.html").read_text(encoding="utf-8")
self.assertIn('<option value="en" selected>English</option>', html)
self.assertNotIn('value="es"', html)
self.assertNotIn('value="fr"', html)
self.assertIn("Planned languages: Español, Français", html)
def test_notebook_pattern_groups_goals_apps_and_source_cards(self) -> None:
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
doclift_root = tmp_path / "doclift"
doclift_root.mkdir()
(doclift_root / "manifest.json").write_text(
json.dumps(
{
"documents": [
{
"document_id": "doc-1",
"title": "Legacy Reading",
"document_kind": "article",
"markdown_path": "documents/doc-1/document.md",
}
]
}
),
encoding="utf-8",
)
(doclift_root / "documents" / "doc-1").mkdir(parents=True)
(doclift_root / "documents" / "doc-1" / "document.md").write_text("Recovered source paragraph.", encoding="utf-8")
config = {
"notebooks": [
{
"id": "digital-evolution",
"title": "Digital Evolution Notebook",
"summary": "Lab plus source-grounded study path.",
"audience": "self-learners",
"goals": ["Connect simulation output to evolutionary concepts"],
"apps": [{"title": "Avida-ED", "href": "/app4/", "description": "Digital evolution lab"}],
"source_kinds": ["notebook"],
}
]
}
notebooks = load_notebooks(config)
content = SiteContent(section_cards=load_doclift_cards(doclift_root))
html = render_notebooks(notebooks, content)
self.assertIn("Digital Evolution Notebook", html)
self.assertIn("Avida-ED", html)
self.assertIn("Legacy Reading", html)
self.assertIn("Recovered source paragraph.", html)
def test_geniehive_translator_uses_openai_compatible_chat_payload(self) -> None:
translator = GenieHiveTranslator(
TranslationConfig(base_url="http://geniehive.local:8800", model="translation-role", api_key="abc123")
)
captured: dict[str, object] = {}
def fake_post_json(path: str, payload: dict) -> dict:
captured["path"] = path
captured["payload"] = payload
return {"choices": [{"message": {"content": "Hola"}}]}
translator._post_json = fake_post_json # type: ignore[method-assign]
result = translator.translate("Hello world", "Spanish", {"evolution": "evolución"})
self.assertEqual(result, "Hola")
self.assertEqual(captured["path"], "/v1/chat/completions")
payload = captured["payload"]
self.assertEqual(payload["model"], "translation-role")
user_text = payload["messages"][1]["content"]
self.assertIn("Spanish", user_text)
self.assertIn("evolución", user_text)
def test_translate_site_builds_translator_from_config(self) -> None:
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
config_path = tmp_path / "site.json"
config_path.write_text(
json.dumps(
{
"translation": {
"base_url": "http://geniehive.local:8800",
"model": "translation-role",
"api_key": "abc123",
"timeout": 33,
"system_prompt": "Translate carefully.",
}
}
),
encoding="utf-8",
)
args = SimpleNamespace(base_url=None, model=None, api_key=None, timeout=None)
translator = translate_site.build_translator(config_path, args)
self.assertEqual(translator.config.provider, "geniehive")
self.assertEqual(translator.config.base_url, "http://geniehive.local:8800")
self.assertEqual(translator.config.model, "translation-role")
self.assertEqual(translator.config.api_key, "abc123")
self.assertEqual(translator.config.timeout, 33)
if __name__ == "__main__":
unittest.main()

View File

@ -1,15 +1,30 @@
// Auto-update year window.langCode = document.documentElement.lang || "en";
document.getElementById('year')?.textContent = new Date().getFullYear();
document.getElementById("year")?.textContent = new Date().getFullYear();
// Language switcher
function switchLanguage(lang) { function switchLanguage(lang) {
const currentPath = window.location.pathname; if (!lang) return;
let newPath = currentPath.replace(new RegExp(`^/${window.langCode}/|^/`), `/${lang}/`); const currentPath = window.location.pathname || "/";
if (!currentPath.startsWith(`/${lang}/`)) { const parts = currentPath.split("/").filter(Boolean);
newPath = `/${lang}${currentPath}`; if (parts.length > 0 && parts[0].length === 2) {
parts[0] = lang;
} else {
parts.unshift(lang);
} }
window.location.href = newPath; const nextPath = "/" + parts.join("/");
window.location.href = nextPath.endsWith("/") ? nextPath : nextPath + (currentPath.endsWith("/") ? "/" : "");
} }
// Optional: expose langCode for JS logic document.querySelectorAll("[data-src]").forEach((button) => {
window.langCode = document.documentElement.lang || 'en'; button.addEventListener("click", () => {
const target = button.getAttribute("data-src");
if (!target) return;
const content = button.parentElement?.querySelector(".content");
if (!content) return;
fetch(target)
.then((resp) => resp.text())
.then((html) => {
content.innerHTML = html;
});
});
});

View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ page_title }} — {{ site_title }}</title>
<meta name="description" content="{{ description }}" />
<link rel="stylesheet" href="{{ theme_stylesheet_href }}">
<script src="{{ theme_script_href }}" defer></script>
</head>
<body class="{{ body_class }}">
<div class="{{ site_shell_class }}">
<header class="site-topbar">
<div class="brand-block">
<span class="brand-mark">{{ theme_display_name }}</span>
<a href="/">{{ site_title }}</a>
<p class="brand-summary">{{ theme_description }}</p>
</div>
<nav class="site-nav" aria-label="Primary navigation">
{{ navigation_html }}
<select id="lang-switch" onchange="switchLanguage(this.value)">
{{ language_options }}
</select>
{{ language_policy_html }}
</nav>
</header>
<section class="hero-card">
<p class="eyebrow">{{ hero_kicker }}</p>
<div class="hero-grid">
<div>
<h1>{{ hero_title }}</h1>
<p class="lede">{{ hero_lede }}</p>
<div class="hero-actions">
{{ hero_actions_html }}
</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<strong>Theme</strong>
<span>{{ theme_display_name }}</span>
</div>
<div class="stat-card">
<strong>Language</strong>
<span>{{ lang }}</span>
</div>
<div class="stat-card">
<strong>Sources</strong>
<span>doclift, GroundRecall, Didactopus, CiteGeist</span>
</div>
<div class="stat-card">
<strong>LLM</strong>
<span>GenieHive-backed translation</span>
</div>
</div>
</div>
</section>
<section class="content-card" id="overview">
<p class="section-kicker">Overview</p>
<h2 class="section-heading">What this theme supports</h2>
<div class="feature-grid">
{{ feature_cards_html }}
</div>
</section>
<section class="content-card">
<p class="section-kicker">Notebook and apps</p>
<h2 class="section-heading">Structured sources and learning artifacts</h2>
{{ notebook_html }}
<div class="path-grid">
{{ section_cards_html }}
{{ app_cards_html }}
</div>
</section>
<section class="note-band" id="themes">
<h2 class="section-heading">Theme catalog</h2>
<p>
SciSiteForge ships multiple theme presets so different science sites can share the same
content pipeline while presenting different reading experiences.
</p>
</section>
<section class="footer-card">
<div>
<h3>{{ site_title }}</h3>
<p>{{ license }}</p>
</div>
<small>
<a href="{{ github_url }}">GitHub</a> · <a href="mailto:{{ contact_email }}">Contact</a>
</small>
</section>
</div>
</body>
</html>

View File

@ -0,0 +1,375 @@
:root {
--bg-top: #f8f4ec;
--bg-bottom: #e7dcc9;
--paper: rgba(255, 252, 247, 0.84);
--paper-strong: rgba(255, 252, 247, 0.94);
--ink: #16251f;
--muted: #5b675f;
--accent: #0f766e;
--accent-strong: #0b5d57;
--accent-warm: #bc6c25;
--line: rgba(22, 37, 31, 0.12);
--shadow: 0 24px 70px rgba(24, 35, 30, 0.12);
--max-width: 1180px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 28%),
radial-gradient(circle at top right, rgba(188, 108, 37, 0.16), transparent 24%),
linear-gradient(180deg, var(--bg-top), var(--bg-bottom) 72%, #dfd3bf);
}
a {
color: var(--accent-strong);
}
img {
max-width: 100%;
display: block;
}
.site-shell {
width: min(var(--max-width), calc(100vw - 28px));
margin: 0 auto;
padding: 18px 0 40px;
}
.site-topbar,
.hero-card,
.content-card,
.feature-card,
.footer-card,
.path-card,
.resource-card,
.note-band {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.site-topbar {
display: flex;
gap: 18px;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
margin-bottom: 20px;
}
.brand-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.brand-mark {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.76rem;
}
.brand-block a {
color: var(--ink);
text-decoration: none;
font-size: 1.5rem;
font-weight: 700;
}
.brand-summary {
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
.site-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-end;
}
.site-nav a,
.button-link,
.button-link-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 999px;
padding: 11px 16px;
text-decoration: none;
font-size: 0.95rem;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
}
.site-nav a,
.button-link-secondary {
color: var(--ink);
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.58);
}
.button-link {
color: white;
background: var(--accent);
border: 1px solid transparent;
}
.site-nav a:hover,
.button-link:hover,
.button-link-secondary:hover {
transform: translateY(-1px);
}
.hero-card {
padding: 34px;
margin-bottom: 22px;
}
.eyebrow {
margin: 0 0 12px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.8rem;
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
gap: 24px;
align-items: start;
}
.hero-card h1 {
margin: 0 0 14px;
font-size: clamp(2.6rem, 6vw, 4.9rem);
line-height: 0.96;
}
.hero-card .lede,
.intro-text,
.content-card p,
.feature-card p,
.path-card p,
.resource-card p,
.footer-card p,
.note-band p,
.roadmap-phase li {
color: var(--muted);
line-height: 1.62;
font-size: 1.02rem;
}
.hero-actions,
.button-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 20px;
}
.stat-grid,
.feature-grid,
.path-grid,
.resource-grid {
display: grid;
gap: 16px;
}
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat-card {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.66);
}
.stat-card strong {
display: block;
font-size: 1.8rem;
margin-bottom: 6px;
}
.stat-card span {
color: var(--muted);
}
.content-card,
.note-band,
.footer-card {
padding: 26px;
margin-bottom: 18px;
}
.section-heading {
margin: 0 0 14px;
font-size: 1.8rem;
}
.section-kicker {
margin: 0 0 10px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.78rem;
}
.feature-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.feature-card,
.path-card,
.resource-card {
padding: 22px;
}
.feature-card h3,
.path-card h3,
.resource-card h3,
.content-card h3 {
margin: 0 0 10px;
font-size: 1.28rem;
}
.meta-list,
.plain-list {
margin: 12px 0 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.6;
}
.path-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.resource-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.note-band {
background: rgba(15, 118, 110, 0.1);
}
.roadmap-phase {
padding-left: 20px;
}
.roadmap-phase li + li {
margin-top: 8px;
}
.footer-card {
display: flex;
gap: 18px;
align-items: start;
justify-content: space-between;
}
.footer-card small {
color: var(--muted);
}
.redirect-card {
max-width: 720px;
margin: 7vh auto;
}
.notebook-panel {
border: 1px solid var(--line);
border-radius: 16px;
padding: 20px;
background: rgba(255, 255, 255, 0.76);
margin: 18px 0;
}
.notebook-panel h2,
.notebook-panel h3 {
margin-top: 0;
}
.notebook-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.notebook-panel .meta {
color: var(--muted);
font-size: 0.9rem;
}
@media (max-width: 980px) {
.hero-grid,
.feature-grid,
.path-grid,
.resource-grid,
.notebook-grid {
grid-template-columns: 1fr;
}
.site-topbar,
.footer-card {
flex-direction: column;
align-items: stretch;
}
.site-nav {
justify-content: flex-start;
}
}
@media (max-width: 640px) {
.site-shell {
width: min(var(--max-width), calc(100vw - 18px));
padding-top: 10px;
}
.site-topbar,
.hero-card,
.content-card,
.feature-card,
.path-card,
.resource-card,
.notebook-panel,
.footer-card,
.note-band {
border-radius: 20px;
}
.hero-card,
.content-card,
.feature-card,
.path-card,
.resource-card,
.notebook-panel,
.footer-card,
.note-band {
padding: 20px;
}
.stat-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title }} — {{ site_title }}</title>
<meta name="description" content="{{ description }}">
<link rel="stylesheet" href="{{ theme_stylesheet_href }}">
<script src="{{ theme_script_href }}" defer></script>
</head>
<body class="{{ body_class }}">
<header class="site-header">
<span class="site-name"><a href="/">{{ site_title }}</a></span>
<span class="site-tagline">{{ theme_description }}</span>
<select id="lang-switch" onchange="switchLanguage(this.value)">
{{ language_options }}
</select>
{{ language_policy_html }}
</header>
<main>
<div class="card">
<h1>{{ hero_title }}</h1>
<p>{{ hero_lede }}</p>
<div class="button-row">
{{ hero_actions_html }}
</div>
</div>
<section class="topic">
<h2>Feature cards</h2>
<div class="cards-grid">
{{ feature_cards_html }}
</div>
</section>
<section class="topic">
<h2>Notebook and app content</h2>
{{ notebook_html }}
<div class="cards-grid">
{{ section_cards_html }}
{{ app_cards_html }}
</div>
</section>
<section class="topic">
<h2>Bibliography</h2>
<ul class="archive-list bibliography-list">
{{ bibliography_html }}
</ul>
</section>
</main>
<footer class="site-footer">
<p>{{ site_title }} · {{ license }}</p>
</footer>
</body>
</html>

View File

@ -0,0 +1,395 @@
/* Panda's Thumb legacy archive, restyled to match the TalkOrigins preview system. */
:root {
--bg: #f2eee6;
--panel: #fffdf9;
--ink: #1b2330;
--muted: #5b6471;
--line: rgba(28, 35, 48, 0.12);
--blue: #214f94;
--blue-deep: #163659;
--gold: #b18d33;
--shadow: 0 22px 55px rgba(20, 33, 53, 0.1);
--max-width: 1180px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
color: var(--ink);
background: linear-gradient(180deg, #f7f3ea, var(--bg));
font-family: Georgia, "Times New Roman", serif;
}
a {
color: var(--blue);
}
a:hover {
color: var(--blue-deep);
}
img {
max-width: 100%;
height: auto;
}
body > header,
body > main,
body > footer {
width: min(var(--max-width), calc(100vw - 28px));
margin-left: auto;
margin-right: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
box-shadow: var(--shadow);
}
body > header,
body > main,
body > footer {
padding: 22px 24px;
}
body > header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px 20px;
flex-wrap: wrap;
margin-top: 20px;
margin-bottom: 18px;
border-top: 4px solid var(--gold);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 253, 249, 0.92));
}
.site-header .site-name,
.site-header .site-name a {
color: var(--ink);
}
.site-header .site-name {
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: clamp(1.35rem, 2vw, 1.8rem);
font-weight: 700;
}
.site-header .site-name a {
text-decoration: none;
}
.site-header .site-name a:hover {
color: var(--blue-deep);
}
.site-header .site-tagline {
color: var(--muted);
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.12em;
}
body > main {
margin-bottom: 18px;
line-height: 1.78;
font-size: 1.08rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(251, 247, 240, 0.98));
border-top: 5px solid var(--blue);
}
body > main > h1:first-child,
.post-title {
margin: 0 0 14px;
font: 700 clamp(2rem, 4vw, 2.8rem)/1.08 "Helvetica Neue", Arial, sans-serif;
}
body > main > h2,
body > main > h3,
.comments-heading {
font-family: "Helvetica Neue", Arial, sans-serif;
line-height: 1.2;
color: var(--blue-deep);
}
body > main > p:first-of-type,
.post-meta,
.comment-meta,
body > footer,
.canonical-link {
color: var(--muted);
}
.post-meta,
.comment-meta {
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 0.9rem;
}
.post-author,
.comment-author {
font-weight: 700;
color: var(--ink);
}
.post-body,
.comment-body {
line-height: 1.78;
}
.post-body p,
.post-body li,
.post-body blockquote,
.comment-body p,
.comment-body li,
.comment-body blockquote {
max-width: 68ch;
}
.post-body blockquote,
.comment-body blockquote {
margin: 1.2rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--gold);
background: rgba(177, 141, 51, 0.08);
}
.canonical-link,
.post-canonical {
margin: 0 0 1.2rem;
padding: 0.8rem 1rem;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(251, 246, 238, 0.98));
border: 1px solid rgba(22, 54, 89, 0.12);
font-size: 0.92rem;
}
.comments-section {
border-top: 2px solid rgba(28, 35, 48, 0.12);
margin-top: 2rem;
padding-top: 1rem;
}
.comments-heading {
margin: 0 0 1rem;
font-size: 1.15rem;
}
.comment {
margin-bottom: 1.2rem;
padding: 0.85rem 1rem 0.9rem;
border-left: 4px solid var(--blue);
border-radius: 14px;
background: linear-gradient(180deg, rgba(244, 248, 252, 0.98), white);
}
.archive-list {
list-style: none;
padding: 0;
columns: 3 16rem;
column-gap: 1rem;
}
.archive-list li {
break-inside: avoid;
margin: 0.2rem 0;
}
.archive-list a {
text-decoration: none;
}
.archive-list a:hover {
text-decoration: underline;
}
body > footer {
margin-bottom: 28px;
text-align: center;
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 0.85rem;
line-height: 1.6;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 248, 252, 0.92));
}
@media (max-width: 860px) {
body > header,
body > main,
body > footer {
width: min(var(--max-width), calc(100vw - 18px));
padding: 18px 16px;
}
body > main {
font-size: 1.02rem;
}
.archive-list {
columns: 2 14rem;
}
}
@media (max-width: 620px) {
body > header {
align-items: flex-start;
}
body > header .tagline {
letter-spacing: 0.08em;
}
.archive-list {
columns: 1;
}
}
.archive-index h2 {
font-family: var(--font-ui);
font-size: 1.05rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.2rem;
margin: 1.5rem 0 0.5rem;
}
.month-links {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
margin-bottom: 0.5rem;
font-family: var(--font-ui);
font-size: 0.9rem;
}
.post-list {
list-style: none;
padding: 0;
margin: 0;
}
.post-list li {
padding: 0.22rem 0;
border-bottom: 1px dotted #eee;
}
.post-list a {
text-decoration: none;
}
.post-list a:hover {
text-decoration: underline;
}
.post-list .meta {
font-family: var(--font-ui);
font-size: 0.78rem;
color: var(--color-meta);
margin-left: 0.35rem;
}
/* ── Home page ────────────────────────────────────────── */
.home-intro {
margin-bottom: 1.5rem;
}
.recent-posts {
list-style: none;
padding: 0;
}
.recent-posts li {
padding: 0.28rem 0;
border-bottom: 1px dotted #eee;
}
.recent-posts a {
text-decoration: none;
}
.recent-posts a:hover {
text-decoration: underline;
}
.recent-posts .meta {
font-family: var(--font-ui);
font-size: 0.78rem;
color: var(--color-meta);
margin-left: 0.35rem;
}
/* ── Breadcrumb ───────────────────────────────────────── */
.breadcrumb {
font-family: var(--font-ui);
font-size: 0.83rem;
color: var(--color-meta);
margin-bottom: 1rem;
}
.breadcrumb a {
color: var(--color-meta);
}
.notebook-panel {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 1rem;
margin: 1rem 0 1.5rem;
background: #fff;
}
.notebook-panel h2,
.notebook-panel h3 {
margin-top: 0;
}
.notebook-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.notebook-panel .meta {
color: var(--color-meta);
font-family: var(--font-ui);
font-size: 0.85rem;
}
.plain-list {
padding-left: 1.1rem;
}
.plain-list li + li {
margin-top: 0.5rem;
}
/* ── Site footer ──────────────────────────────────────── */
.site-footer {
border-top: 1px solid var(--color-border);
margin-top: 2.5rem;
padding: 1.2rem 0 1rem;
text-align: center;
font-family: var(--font-ui);
font-size: 0.75rem;
color: #aaa;
}
/* ── Responsive ───────────────────────────────────────── */
@media (max-width: 600px) {
.post-title { font-size: 1.25rem; }
.site-header { gap: 0.1rem; }
.archive-index h2 { font-size: 1rem; }
.notebook-grid { grid-template-columns: 1fr; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ page_title }} — {{ site_title }}</title>
<meta name="description" content="{{ description }}" />
<link rel="stylesheet" href="{{ theme_stylesheet_href }}" />
<link rel="icon" href="{{ theme_asset_prefix }}/toa.ico" sizes="any" />
<script src="{{ theme_script_href }}" defer></script>
</head>
<body class="{{ body_class }}">
<a class="skip-link" href="#main">Skip to content</a>
<div class="{{ site_shell_class }}">
<header class="site-header">
<div class="brand-block">
<div class="brand-row">
<a class="brand-mark" href="/" aria-label="{{ site_title }} home">
<img src="{{ theme_asset_prefix }}/toa_logo_001_edit_001.png" alt="{{ site_title }} logo" />
</a>
<div class="brand-copy">
<p class="brand-kicker">{{ hero_kicker }}</p>
<a class="brand-title" href="/">{{ site_title }}</a>
<p class="brand-summary">{{ theme_description }}</p>
</div>
</div>
</div>
<nav class="top-nav" aria-label="Primary navigation">
{{ navigation_html }}
<select id="lang-switch" onchange="switchLanguage(this.value)">
{{ language_options }}
</select>
{{ language_policy_html }}
</nav>
</header>
<main id="main">
<section class="hero-panel">
<p class="eyebrow">{{ hero_kicker }}</p>
<div class="hero-grid">
<div class="hero-copy">
<h1>{{ hero_title }}</h1>
<div class="lede">{{ hero_lede }}</div>
<div class="button-row">
{{ hero_actions_html }}
</div>
</div>
<div class="continuity-note hero-note">
<p class="eyebrow">Framework</p>
<h2>Modular, static, and reviewable</h2>
<p>This theme is intended to prove the modernization line without giving up the archive posture.</p>
<ul class="link-list compact-list">
<li>Responsive reading</li>
<li>Reusable content blocks</li>
<li>Search and bibliography ready</li>
</ul>
</div>
</div>
</section>
<section class="content-panel">
<h2>Feature cards</h2>
<div class="feature-grid">
{{ feature_cards_html }}
</div>
</section>
<section class="content-panel">
<h2>Notebook and app content</h2>
{{ notebook_html }}
<div class="section-grid">
{{ section_cards_html }}
{{ app_cards_html }}
</div>
</section>
<section class="content-panel">
<h2>Bibliography</h2>
<ul class="archive-list bibliography-list">
{{ bibliography_html }}
</ul>
</section>
</main>
<footer class="site-footer">
<p class="footer-note"><strong>{{ site_title }}</strong> is the current SciSiteForge proving ground for modern archive-style science sites.</p>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,618 @@
:root {
--bg: #f2eee6;
--panel: #fffdf9;
--panel-warm: #fbf6ee;
--panel-cool: #f4f8fc;
--ink: #1b2330;
--muted: #5b6471;
--line: rgba(28, 35, 48, 0.12);
--blue: #214f94;
--blue-deep: #163659;
--blue-soft: #dbe8f7;
--gold: #b18d33;
--gold-soft: #f3e4b3;
--brick-soft: #efe1dc;
--shadow: 0 22px 55px rgba(20, 33, 53, 0.1);
--max-width: 1180px;
--article-width: 760px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(33, 79, 148, 0.11), transparent 22%),
radial-gradient(circle at top right, rgba(177, 141, 51, 0.12), transparent 18%),
linear-gradient(180deg, #f7f3ea, var(--bg));
font-family: Georgia, "Times New Roman", serif;
}
a {
color: var(--blue);
}
a:hover {
color: var(--blue-deep);
}
.skip-link {
position: absolute;
left: -999px;
top: 0;
}
.skip-link:focus {
left: 1rem;
top: 1rem;
background: var(--ink);
color: white;
padding: 0.7rem 0.9rem;
z-index: 10;
}
.site-shell {
width: min(var(--max-width), calc(100vw - 28px));
margin: 0 auto;
padding: 20px 0 40px;
}
.site-header,
.hero-panel,
.content-panel,
.article-header,
.article-body,
.side-card,
.site-footer,
.continuity-note,
.feature-card,
.section-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: var(--shadow);
}
.site-header,
.site-footer,
.hero-panel,
.content-panel,
.article-header,
.article-body,
.side-card {
padding: 22px 24px;
}
.site-header {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: center;
margin-bottom: 20px;
border-top: 4px solid var(--gold);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 253, 249, 0.92));
backdrop-filter: blur(8px);
}
.brand-kicker,
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--gold);
font: 700 0.78rem/1.2 "Helvetica Neue", Arial, sans-serif;
margin: 0 0 8px;
}
.brand-title {
display: inline-block;
text-decoration: none;
color: var(--ink);
font: 700 clamp(1.7rem, 3vw, 2.2rem)/1.05 "Helvetica Neue", Arial, sans-serif;
letter-spacing: -0.02em;
}
.brand-row {
display: flex;
align-items: center;
gap: 16px;
}
.brand-copy {
min-width: 0;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: clamp(58px, 8vw, 82px);
height: clamp(58px, 8vw, 82px);
border-radius: 18px;
padding: 6px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 248, 252, 0.9));
border: 1px solid rgba(22, 54, 89, 0.12);
box-shadow: 0 14px 28px rgba(20, 33, 53, 0.1);
}
.brand-mark img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-summary,
.site-footer p,
.continuity-note p,
.feature-card p,
.section-card p,
.article-note,
.article-meta,
.article-sidebar li span {
color: var(--muted);
}
.top-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.top-nav a,
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.72rem 1rem;
border-radius: 999px;
border: 1px solid var(--line);
text-decoration: none;
font: 600 0.95rem/1.2 "Helvetica Neue", Arial, sans-serif;
}
.top-nav a,
.button-link.button-link-secondary {
background: rgba(255, 255, 255, 0.82);
color: var(--ink);
}
.button-link,
.top-nav a:first-child {
background: linear-gradient(180deg, var(--blue), var(--blue-deep));
border-color: transparent;
color: white;
}
.hero-panel {
margin-bottom: 18px;
overflow: hidden;
position: relative;
background:
linear-gradient(135deg, rgba(33, 79, 148, 0.08), transparent 42%),
linear-gradient(180deg, #fffefb, #faf6ee);
border-top: 5px solid var(--blue);
}
.hero-panel::after {
content: "";
position: absolute;
inset: auto -7% -32px auto;
width: 280px;
height: 280px;
background: radial-gradient(circle, rgba(177, 141, 51, 0.18), transparent 68%);
pointer-events: none;
}
.hero-grid,
.article-layout {
display: grid;
gap: 20px;
}
.hero-grid {
grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr);
align-items: start;
}
.hero-copy {
position: relative;
z-index: 1;
}
.hero-panel h1,
.article-header h1 {
margin: 0 0 14px;
font: 700 clamp(2rem, 4vw, 3rem)/1.05 "Helvetica Neue", Arial, sans-serif;
letter-spacing: -0.03em;
}
.lede {
font-size: 1.08rem;
line-height: 1.72;
color: var(--muted);
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 20px;
}
.continuity-note {
padding: 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 248, 252, 0.9));
border: 1px solid rgba(22, 54, 89, 0.12);
}
.hero-note {
position: relative;
z-index: 1;
}
.content-panel {
margin-bottom: 18px;
position: relative;
overflow: hidden;
}
.content-panel h2,
.continuity-note h2,
.side-card h2 {
margin-top: 0;
font: 700 1.2rem/1.2 "Helvetica Neue", Arial, sans-serif;
}
.lead-panel {
background:
linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(251, 246, 238, 0.98));
border-top: 5px solid var(--gold);
}
.direction-panel {
background:
linear-gradient(180deg, rgba(244, 248, 252, 0.98), rgba(255, 255, 255, 0.98));
border-top: 5px solid var(--blue-soft);
}
.roadmap-panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 241, 233, 0.98));
border-top: 5px solid rgba(177, 141, 51, 0.45);
}
.preview-panel {
background:
linear-gradient(180deg, rgba(244, 248, 252, 0.92), rgba(255, 255, 255, 0.96));
}
.feature-grid,
.section-grid {
display: grid;
gap: 16px;
}
.feature-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.section-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.feature-card,
.section-card {
padding: 18px;
position: relative;
overflow: hidden;
}
.feature-card h3,
.section-card h3 {
margin-top: 0;
font: 700 1.02rem/1.3 "Helvetica Neue", Arial, sans-serif;
}
.feature-card::before,
.section-card::before {
content: "";
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 4px;
background: var(--gold);
opacity: 0.85;
}
.lead-panel .feature-card:nth-child(4n + 1),
.direction-panel .feature-card:nth-child(3n + 1),
.section-card:nth-child(4n + 1) {
background: linear-gradient(180deg, rgba(255, 250, 241, 0.98), white);
}
.lead-panel .feature-card:nth-child(4n + 2),
.direction-panel .feature-card:nth-child(3n + 2),
.section-card:nth-child(4n + 2) {
background: linear-gradient(180deg, rgba(244, 248, 252, 0.98), white);
}
.lead-panel .feature-card:nth-child(4n + 3),
.direction-panel .feature-card:nth-child(3n + 3),
.section-card:nth-child(4n + 3) {
background: linear-gradient(180deg, rgba(239, 225, 220, 0.55), white);
}
.lead-panel .feature-card:nth-child(4n + 4),
.section-card:nth-child(4n + 4) {
background: linear-gradient(180deg, rgba(243, 228, 179, 0.32), white);
}
.article-layout {
grid-template-columns: minmax(0, var(--article-width)) minmax(250px, 1fr);
align-items: start;
}
.article-header {
background:
linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(255, 255, 255, 0.98));
border-top: 5px solid var(--gold);
}
.article-body {
line-height: 1.78;
font-size: 1.08rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(251, 247, 240, 0.98));
border-top: 4px solid rgba(33, 79, 148, 0.18);
}
.article-body p,
.article-body li,
.article-body blockquote {
max-width: 68ch;
}
.article-body h2,
.article-body h3 {
margin-top: 2rem;
font-family: "Helvetica Neue", Arial, sans-serif;
line-height: 1.2;
color: var(--blue-deep);
}
.article-body h2 {
font-size: 1.45rem;
}
.article-body h3 {
font-size: 1.15rem;
}
.article-body blockquote {
margin: 1.4rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--gold);
background: rgba(177, 141, 51, 0.08);
}
.article-body code {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.92em;
background: rgba(23, 56, 102, 0.08);
padding: 0.1rem 0.3rem;
border-radius: 4px;
}
.claim-block {
margin-bottom: 1.8rem;
padding: 1.35rem 1.4rem 1.1rem;
border: 1px solid rgba(22, 54, 89, 0.12);
border-left: 6px solid var(--blue);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(219, 232, 247, 0.55), rgba(255, 255, 255, 0.96));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.claim-block h2 {
margin-top: 0;
margin-bottom: 0.9rem;
color: var(--blue-deep);
}
.claim-block p:first-of-type {
margin-top: 0;
font-size: 1.22rem;
line-height: 1.55;
color: var(--ink);
}
.claim-block p:first-of-type::before {
content: "Claim at issue";
display: block;
margin-bottom: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--gold);
font: 700 0.76rem/1.2 "Helvetica Neue", Arial, sans-serif;
}
.response-block {
margin-bottom: 1.6rem;
}
.article-sidebar {
position: sticky;
top: 18px;
}
.side-card {
margin-bottom: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 248, 252, 0.92));
}
.side-card-links {
border-top: 4px solid var(--blue);
}
.side-card-hooks {
border-top: 4px solid var(--gold);
}
.link-list {
padding-left: 1.1rem;
}
.link-list li + li {
margin-top: 0.8rem;
}
.article-meta,
.article-note,
.footer-note {
font-size: 0.95rem;
line-height: 1.6;
}
.meta-link {
word-break: break-word;
}
.site-footer {
display: grid;
gap: 10px;
margin-top: 10px;
color: rgba(255, 255, 255, 0.86);
background:
linear-gradient(180deg, #223247, #142236);
border-color: rgba(255, 255, 255, 0.08);
}
.site-footer h2 {
margin-top: 0;
color: white;
font: 700 1.1rem/1.2 "Helvetica Neue", Arial, sans-serif;
}
.site-footer p {
color: rgba(255, 255, 255, 0.88);
}
.site-footer a,
.site-footer strong,
.footer-note {
color: rgba(255, 255, 255, 0.9);
}
.compact-list {
margin: 0;
padding-left: 1rem;
}
.compact-list li + li {
margin-top: 0.5rem;
}
.notebook-panel {
border: 1px solid var(--line);
border-radius: 8px;
padding: 1.25rem;
margin: 1rem 0 1.5rem;
background: rgba(255, 255, 255, 0.84);
}
.notebook-panel h2,
.notebook-panel h3 {
margin-top: 0;
}
.notebook-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.notebook-panel .meta {
color: var(--muted);
font-size: 0.9rem;
}
.plain-list {
padding-left: 1rem;
}
.plain-list li + li {
margin-top: 0.5rem;
}
a:focus-visible,
button:focus-visible {
outline: 3px solid #e5b748;
outline-offset: 3px;
}
@media (max-width: 900px) {
.site-header,
.hero-grid,
.article-layout,
.notebook-grid {
grid-template-columns: 1fr;
}
.article-sidebar {
position: static;
}
.site-header {
align-items: flex-start;
}
}
@media (max-width: 640px) {
.site-shell {
width: min(var(--max-width), calc(100vw - 18px));
padding-top: 12px;
}
.site-header,
.site-footer,
.hero-panel,
.content-panel,
.article-header,
.article-body,
.side-card {
padding: 18px;
border-radius: 18px;
}
.top-nav {
width: 100%;
}
.brand-row {
align-items: flex-start;
gap: 12px;
}
.brand-mark {
width: 58px;
height: 58px;
border-radius: 14px;
}
.top-nav a,
.button-link {
width: 100%;
}
}