diff --git a/build/Site.hs b/build/Site.hs
index b5e5447..de34a97 100644
--- a/build/Site.hs
+++ b/build/Site.hs
@@ -4,11 +4,13 @@ module Site (rules) where
import Control.Monad (filterM, when)
import Data.List (isPrefixOf)
-import Data.Maybe (fromMaybe)
+import Data.Maybe (catMaybes, fromMaybe)
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, takeFileName, replaceExtension)
+import Text.Read (readMaybe)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy.Char8 as LBS
+import qualified Data.Map.Strict as Map
import Hakyll
import Authors (buildAllAuthors, applyAuthorRules)
import Backlinks (backlinkRules)
@@ -445,6 +447,24 @@ rules = do
let urls = [ "/" ++ r | Just r <- routes ]
makeItem $ LBS.unpack (Aeson.encode urls)
+ -- ---------------------------------------------------------------------------
+ -- Epistemic metadata manifest — maps page URLs to epistemic fields
+ -- (status, confidence, importance, evidence, scope, novelty, practicality,
+ -- stability, score) for client-side search filtering.
+ -- ---------------------------------------------------------------------------
+ create ["data/epistemic-meta.json"] $ do
+ route idRoute
+ compile $ do
+ essays <- loadAll (allEssays .&&. hasNoVersion) :: Compiler [Item String]
+ posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion) :: Compiler [Item String]
+ fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion) :: Compiler [Item String]
+ poetry <- loadAll (allPoetry .&&. hasNoVersion) :: Compiler [Item String]
+ music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion) :: Compiler [Item String]
+ let items = essays ++ posts ++ fiction ++ poetry ++ music
+ pairs <- mapM epistemicEntry items
+ let metaMap = Map.fromList (catMaybes pairs)
+ makeItem $ LBS.unpack (Aeson.encode metaMap)
+
-- ---------------------------------------------------------------------------
-- Atom feed — all content sorted by date
-- ---------------------------------------------------------------------------
@@ -485,3 +505,48 @@ rules = do
<> bodyField "description"
<> defaultContext
renderAtom musicFeedConfig feedCtx compositions
+
+-- ---------------------------------------------------------------------------
+-- Epistemic metadata extraction
+-- ---------------------------------------------------------------------------
+
+-- | Extract epistemic metadata from a content item's frontmatter.
+-- Returns Nothing if the item has no route or no epistemic fields.
+epistemicEntry :: Item String -> Compiler (Maybe (String, Map.Map String String))
+epistemicEntry item = do
+ let ident = itemIdentifier item
+ mRoute <- getRoute ident
+ case mRoute of
+ Nothing -> return Nothing
+ Just r -> do
+ meta <- getMetadata ident
+ let url = "/" ++ r
+ fields = catMaybes
+ [ grab "status" meta
+ , grab "confidence" meta
+ , grab "importance" meta
+ , grab "evidence" meta
+ , grab "scope" meta
+ , grab "novelty" meta
+ , grab "practicality" meta
+ , grab "stability" meta
+ ]
+ obj = Map.fromList fields
+ -- Compute overall-score the same way Contexts.overallScoreField does.
+ obj' = case ( readMaybe =<< lookupString "confidence" meta :: Maybe Int
+ , readMaybe =<< lookupString "evidence" meta :: Maybe Int
+ ) of
+ (Just conf, Just ev) ->
+ let raw :: Double
+ raw = fromIntegral conf / 100.0 * 0.6
+ + fromIntegral (ev - 1) / 4.0 * 0.4
+ score = max 0 (min 100 (round (raw * 100.0) :: Int))
+ in Map.insert "score" (show score) obj
+ _ -> obj
+ if Map.null obj'
+ then return Nothing
+ else return (Just (url, obj'))
+ where
+ grab name meta = case lookupString name meta of
+ Just v -> Just (name, v)
+ Nothing -> Nothing
diff --git a/content/search.md b/content/search.md
index 5a45417..b07f7f1 100644
--- a/content/search.md
+++ b/content/search.md
@@ -8,6 +8,105 @@ search: true
+
+
+
+
+
+
status
+
+
+
+
+
+
+
+
+
+
+
+
importance
+
+≥
+
+
+
+
+
+
+
+
+
evidence
+
+≥
+
+
+
+
+
+
+
+
+
+
scope
+
+≥
+
+
+
+
+
+
+
+
novelty
+
+≥
+
+
+
+
+
+
+
+
practicality
+
+≥
+
+
+
+
+
+
+
+
stability
+
+≥
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/css/library.css b/static/css/library.css
index 7bd8471..3fe9e1b 100644
--- a/static/css/library.css
+++ b/static/css/library.css
@@ -9,14 +9,17 @@
}
/* ============================================================
- SORT CONTROLS
+ CONTROLS (sort + filter)
============================================================ */
.library-controls {
+ margin-bottom: 2.5rem;
+}
+
+.library-controls-row {
display: flex;
align-items: center;
gap: 0.6rem;
- margin-bottom: 2.5rem;
}
.library-controls-label {
@@ -53,6 +56,160 @@
font-weight: 600;
}
+/* Filter toggle */
+
+.library-filter-toggle {
+ font-family: var(--font-sans);
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ padding: 0.15em 0.55em;
+ cursor: pointer;
+ margin-left: auto;
+ transition: border-color 0.1s, color 0.1s;
+}
+
+.library-filter-toggle:hover,
+.library-filter-toggle[aria-expanded="true"] {
+ border-color: var(--text-muted);
+ color: var(--text);
+}
+
+.filter-toggle-badge {
+ font-weight: 600;
+}
+
+/* ============================================================
+ FILTER PANEL
+ ============================================================ */
+
+.library-filters {
+ border-top: 1px solid var(--border);
+ padding-top: 0.75rem;
+ margin-top: 0.75rem;
+}
+
+.library-filters[hidden] {
+ display: none;
+}
+
+.filter-row {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-bottom: 0.45rem;
+}
+
+.filter-label {
+ font-family: var(--font-sans);
+ font-size: 0.72rem;
+ color: var(--text-faint);
+ width: 5.5rem;
+ flex-shrink: 0;
+}
+
+.filter-options {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.filter-prefix {
+ font-family: var(--font-sans);
+ font-size: 0.72rem;
+ color: var(--text-faint);
+}
+
+.filter-btn {
+ font-family: var(--font-sans);
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ padding: 0.1em 0.45em;
+ cursor: pointer;
+ transition: border-color 0.1s, color 0.1s;
+}
+
+.filter-btn:hover {
+ border-color: var(--border-muted);
+ color: var(--text);
+}
+
+.filter-btn.is-active {
+ border-color: var(--text-muted);
+ color: var(--text);
+ font-weight: 600;
+}
+
+.filter-number {
+ font-family: var(--font-sans);
+ font-size: 0.72rem;
+ width: 3rem;
+ padding: 0.1em 0.3em;
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ background: transparent;
+ color: var(--text);
+}
+
+.filter-number:focus {
+ outline: none;
+ border-color: var(--text-muted);
+}
+
+.filter-row-actions {
+ justify-content: flex-end;
+ margin-top: 0.25rem;
+}
+
+.filter-clear-btn {
+ font-family: var(--font-sans);
+ font-size: 0.72rem;
+ color: var(--text-faint);
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+}
+
+.filter-clear-btn:hover {
+ color: var(--text);
+}
+
+/* Filtered state */
+
+.is-filtered {
+ display: none !important;
+}
+
+/* Search-page result filtering (applied via search-filters.js) */
+
+.search-filtered {
+ display: none !important;
+}
+
+/* Search page filter controls — just the toggle, no sort buttons */
+
+.search-filter-controls {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 0.5rem;
+}
+
+/* Empty state message */
+
+.library-empty {
+ font-family: var(--font-sans);
+ font-size: var(--text-size-small);
+ color: var(--text-faint);
+ font-style: italic;
+}
+
/* ============================================================
PORTAL SECTIONS
============================================================ */
diff --git a/static/css/popups.css b/static/css/popups.css
index d66400d..723b6b9 100644
--- a/static/css/popups.css
+++ b/static/css/popups.css
@@ -188,6 +188,24 @@
min-width: 230px;
}
+/* Epistemic term definition popup — shown on hover for filter labels,
+ metadata strip items, and footer DT elements. */
+[data-ep-term] {
+ cursor: help;
+}
+
+.popup-ep-term {
+ max-width: 320px;
+}
+
+.popup-ep-term .popup-source a {
+ color: inherit;
+ text-decoration: none;
+}
+.popup-ep-term .popup-source a:hover {
+ text-decoration: underline;
+}
+
/* PDF thumbnail popup — first-page image generated by pdftoppm at build time */
.link-popup:has(.popup-pdf) {
padding: 0;
diff --git a/static/css/typography.css b/static/css/typography.css
index 014cccb..b39d8dd 100644
--- a/static/css/typography.css
+++ b/static/css/typography.css
@@ -530,6 +530,14 @@ pre code {
margin-top: 0.825rem; /* Half line-height */
}
+/* Paragraphs inside annotation bodies: no first-line indent.
+ Annotations are short callouts, not body prose; the indent reads as a
+ typographic glitch when stacked inside the bordered box. */
+#markdownBody .annotation-body p + p {
+ text-indent: 0;
+ margin-top: 0.825rem;
+}
+
/* ============================================================
MATHEMATICS (KaTeX)
diff --git a/static/js/popups.js b/static/js/popups.js
index 86f3ea5..bdb3df1 100644
--- a/static/js/popups.js
+++ b/static/js/popups.js
@@ -56,7 +56,26 @@
});
}
+ /* Epistemic term definitions — concise summaries from the colophon,
+ shown on hover for filter labels, metadata strip items, etc. */
+ var EP_DEFS = {
+ status: 'A controlled vocabulary describing where the work stands: Draft, Working model, Durable, Refined, Superseded, or Deprecated.',
+ confidence: 'An integer from 0\u2013100, representing the author\u2019s credence in the central thesis.',
+ importance: 'How much the author thinks this matters, on a 1\u20135 dot scale. Useful for orienting a reader who has limited time.',
+ evidence: 'How well-evidenced the claims are, on a 1\u20135 scale. High importance and low evidence indicates a speculative position.',
+ trust: 'A 0\u2013100 score derived automatically from confidence (60%) and evidence quality (40%). Answers \u201chow much should you trust the central claim?\u201d and nothing else.',
+ scope: 'An orientation from personal to civilizational. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.',
+ novelty: 'An orientation from conventional to innovative. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.',
+ practicality: 'An orientation from abstract to exceptional. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.',
+ stability: 'Auto-computed from git history. Very new or barely-touched documents are volatile; actively-revised are revising; older settled documents are fairly stable, stable, or established.'
+ };
+
function bindTargets(root) {
+ /* Epistemic term definitions — filter labels, metadata strip, footer */
+ root.querySelectorAll('[data-ep-term]').forEach(function (el) {
+ bind(el, epistemicTermContent);
+ });
+
/* Citation markers */
root.querySelectorAll('a.cite-link[href^="#ref-"]').forEach(function (el) {
bind(el, citationContent);
@@ -672,6 +691,24 @@
return Promise.resolve(wrap);
}
+ /* Epistemic term definition — shows a concise description from the
+ colophon for any element tagged with data-ep-term="". */
+ function epistemicTermContent(target) {
+ var term = target.dataset.epTerm;
+ var def = term && EP_DEFS[term];
+ if (!def) return Promise.resolve(null);
+ var label = term.charAt(0).toUpperCase() + term.slice(1);
+ return Promise.resolve(
+ ''
+ );
+ }
+
/* Local PDF — shows the build-time first-page thumbnail (.thumb.png).
Returns null (no popup) if the thumbnail file does not exist. */
function pdfContent(target) {
diff --git a/static/js/search-filters.js b/static/js/search-filters.js
new file mode 100644
index 0000000..7d56d94
--- /dev/null
+++ b/static/js/search-filters.js
@@ -0,0 +1,314 @@
+/* search-filters.js — Epistemic effort filters for the search page.
+ *
+ * Loads /data/epistemic-meta.json (a map of URL → epistemic fields)
+ * and hides search results whose source page doesn't match the active
+ * filters. Works for both Pagefind keyword results and semantic results.
+ *
+ * Reuses the same CSS classes and filter-panel markup as library.html
+ * so the two pages look and behave identically.
+ */
+(function () {
+ 'use strict';
+
+ var KEY = 'search-filter-state';
+
+ var SCALES = {
+ scope: ['personal', 'average', 'broad', 'civilizational'],
+ novelty: ['conventional', 'moderate', 'idiosyncratic', 'innovative'],
+ practicality: ['abstract', 'moderate', 'high', 'exceptional'],
+ stability: ['volatile', 'revising', 'fairly stable', 'stable', 'established']
+ };
+
+ var state = {
+ status: [],
+ confidence: null,
+ importance: null,
+ evidence: null,
+ score: null,
+ scope: null,
+ novelty: null,
+ practicality: null,
+ stability: null
+ };
+
+ var epistemicMeta = null; /* URL → {status, confidence, …} loaded lazily */
+
+ /* ---- Persistence ---- */
+
+ function load() {
+ try {
+ var raw = localStorage.getItem(KEY);
+ if (raw) {
+ var obj = JSON.parse(raw);
+ for (var k in state) {
+ if (obj.hasOwnProperty(k)) state[k] = obj[k];
+ }
+ }
+ } catch (e) {}
+ }
+
+ function save() {
+ try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {}
+ }
+
+ /* ---- Metadata loading ---- */
+
+ var metaPromise = null;
+
+ function loadMeta() {
+ if (epistemicMeta) return Promise.resolve(epistemicMeta);
+ if (metaPromise) return metaPromise;
+ metaPromise = fetch('/data/epistemic-meta.json')
+ .then(function (r) { return r.ok ? r.json() : {}; })
+ .catch(function () { return {}; })
+ .then(function (data) { epistemicMeta = data; return data; });
+ return metaPromise;
+ }
+
+ /* ---- Filtering logic ---- */
+
+ function passes(meta) {
+ if (!meta) return true; /* no metadata = don't filter out */
+
+ if (state.status.length) {
+ var s = (meta.status || '').toLowerCase();
+ if (!s || state.status.indexOf(s) === -1) return false;
+ }
+ if (state.confidence !== null) {
+ if (!meta.confidence || +meta.confidence < state.confidence) return false;
+ }
+ if (state.importance !== null) {
+ if (!meta.importance || +meta.importance < state.importance) return false;
+ }
+ if (state.evidence !== null) {
+ if (!meta.evidence || +meta.evidence < state.evidence) return false;
+ }
+ if (state.score !== null) {
+ if (!meta.score || +meta.score < state.score) return false;
+ }
+
+ var ords = ['scope', 'novelty', 'practicality', 'stability'];
+ for (var i = 0; i < ords.length; i++) {
+ var k = ords[i];
+ if (state[k] !== null) {
+ var v = (meta[k] || '').toLowerCase();
+ var idx = SCALES[k].indexOf(v);
+ if (idx === -1 || idx < state[k]) return false;
+ }
+ }
+
+ return true;
+ }
+
+ function hasActiveFilters() {
+ if (state.status.length) return true;
+ var fields = ['confidence', 'importance', 'evidence', 'score',
+ 'scope', 'novelty', 'practicality', 'stability'];
+ for (var i = 0; i < fields.length; i++) {
+ if (state[fields[i]] !== null) return true;
+ }
+ return false;
+ }
+
+ /* ---- URL extraction ---- */
+
+ /* Normalise a URL to a pathname for lookup in epistemicMeta.
+ Pagefind results use full URLs; semantic results use relative paths. */
+ function normUrl(href) {
+ if (!href) return null;
+ try {
+ var u = new URL(href, window.location.origin);
+ return u.pathname;
+ } catch (e) {
+ return href;
+ }
+ }
+
+ /* ---- Apply filters to rendered results ---- */
+
+ function applyToPagefind() {
+ if (!epistemicMeta || !hasActiveFilters()) {
+ /* Remove any previous filtering */
+ document.querySelectorAll('.pagefind-ui__result.search-filtered').forEach(function (el) {
+ el.classList.remove('search-filtered');
+ });
+ return;
+ }
+ document.querySelectorAll('.pagefind-ui__result').forEach(function (el) {
+ var link = el.querySelector('.pagefind-ui__result-link');
+ if (!link) return;
+ var url = normUrl(link.getAttribute('href'));
+ var meta = url ? epistemicMeta[url] : null;
+ el.classList.toggle('search-filtered', !passes(meta));
+ });
+ }
+
+ function applyToSemantic() {
+ if (!epistemicMeta || !hasActiveFilters()) {
+ document.querySelectorAll('.semantic-result.search-filtered').forEach(function (el) {
+ el.classList.remove('search-filtered');
+ });
+ return;
+ }
+ document.querySelectorAll('.semantic-result').forEach(function (el) {
+ var link = el.querySelector('.semantic-result-title');
+ if (!link) return;
+ var url = normUrl(link.getAttribute('href'));
+ var meta = url ? epistemicMeta[url] : null;
+ el.classList.toggle('search-filtered', !passes(meta));
+ });
+ }
+
+ function applyFilters() {
+ applyToPagefind();
+ applyToSemantic();
+ syncUI();
+ save();
+ }
+
+ /* ---- UI sync ---- */
+
+ function activeCount() {
+ var n = 0;
+ if (state.status.length) n++;
+ var fields = ['confidence', 'importance', 'evidence', 'score',
+ 'scope', 'novelty', 'practicality', 'stability'];
+ for (var i = 0; i < fields.length; i++) {
+ if (state[fields[i]] !== null) n++;
+ }
+ return n;
+ }
+
+ function syncUI() {
+ var badge = document.querySelector('.filter-toggle-badge');
+ var n = activeCount();
+ if (badge) badge.textContent = n ? ' (' + n + ')' : '';
+
+ document.querySelectorAll('.filter-status-btn').forEach(function (btn) {
+ btn.classList.toggle('is-active', state.status.indexOf(btn.dataset.value) !== -1);
+ });
+
+ var ci = document.getElementById('filter-confidence');
+ if (ci) ci.value = state.confidence !== null ? state.confidence : '';
+ var si = document.getElementById('filter-score');
+ if (si) si.value = state.score !== null ? state.score : '';
+
+ document.querySelectorAll('.filter-threshold-btn').forEach(function (btn) {
+ btn.classList.toggle('is-active', state[btn.dataset.field] === +btn.dataset.value);
+ });
+
+ document.querySelectorAll('.filter-ordinal-btn').forEach(function (btn) {
+ btn.classList.toggle('is-active', state[btn.dataset.field] === +btn.dataset.index);
+ });
+ }
+
+ /* ---- Init ---- */
+
+ document.addEventListener('DOMContentLoaded', function () {
+ load();
+
+ var panel = document.getElementById('search-filters');
+ var toggle = document.querySelector('.library-filter-toggle');
+
+ if (activeCount() > 0 && panel && toggle) {
+ panel.hidden = false;
+ toggle.setAttribute('aria-expanded', 'true');
+ }
+
+ syncUI();
+
+ /* Load metadata eagerly if filters are active */
+ if (hasActiveFilters()) {
+ loadMeta().then(applyFilters);
+ }
+
+ /* Toggle panel */
+ if (toggle && panel) {
+ toggle.addEventListener('click', function () {
+ var opening = panel.hidden;
+ panel.hidden = !opening;
+ toggle.setAttribute('aria-expanded', opening ? 'true' : 'false');
+ });
+ }
+
+ /* Status buttons */
+ document.querySelectorAll('.filter-status-btn').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var v = btn.dataset.value;
+ var i = state.status.indexOf(v);
+ if (i === -1) state.status.push(v);
+ else state.status.splice(i, 1);
+ loadMeta().then(applyFilters);
+ });
+ });
+
+ /* Threshold buttons (importance, evidence) */
+ document.querySelectorAll('.filter-threshold-btn').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var f = btn.dataset.field;
+ var v = +btn.dataset.value;
+ state[f] = (state[f] === v) ? null : v;
+ loadMeta().then(applyFilters);
+ });
+ });
+
+ /* Ordinal buttons (scope, novelty, practicality, stability) */
+ document.querySelectorAll('.filter-ordinal-btn').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var f = btn.dataset.field;
+ var idx = +btn.dataset.index;
+ state[f] = (state[f] === idx) ? null : idx;
+ loadMeta().then(applyFilters);
+ });
+ });
+
+ /* Number inputs (confidence, trust/score) */
+ ['confidence', 'score'].forEach(function (field) {
+ var el = document.getElementById('filter-' + (field === 'score' ? 'score' : field));
+ if (!el) return;
+ el.addEventListener('input', function () {
+ var v = el.value.trim();
+ state[field] = v !== '' ? Math.max(0, Math.min(100, parseInt(v, 10) || 0)) : null;
+ loadMeta().then(applyFilters);
+ });
+ });
+
+ /* Clear all */
+ var clearBtn = document.querySelector('.filter-clear-btn');
+ if (clearBtn) {
+ clearBtn.addEventListener('click', function () {
+ state.status = [];
+ state.confidence = null;
+ state.importance = null;
+ state.evidence = null;
+ state.score = null;
+ state.scope = null;
+ state.novelty = null;
+ state.practicality = null;
+ state.stability = null;
+ applyFilters();
+ });
+ }
+
+ /* Observe Pagefind result changes to re-apply filters.
+ Pagefind dynamically rebuilds the results container. */
+ var searchEl = document.getElementById('search');
+ if (searchEl) {
+ new MutationObserver(function () {
+ if (hasActiveFilters() && epistemicMeta) {
+ applyToPagefind();
+ }
+ }).observe(searchEl, { childList: true, subtree: true });
+ }
+
+ /* Observe semantic results container */
+ var semanticEl = document.getElementById('semantic-results');
+ if (semanticEl) {
+ new MutationObserver(function () {
+ if (hasActiveFilters() && epistemicMeta) {
+ applyToSemantic();
+ }
+ }).observe(semanticEl, { childList: true, subtree: true });
+ }
+ });
+}());
diff --git a/templates/default.html b/templates/default.html
index 28e4068..17bc05f 100644
--- a/templates/default.html
+++ b/templates/default.html
@@ -10,6 +10,7 @@ $if(search)$
+
$endif$
$body$
$partial("templates/partials/footer.html")$
diff --git a/templates/library.html b/templates/library.html
index afe30f1..d5a32d1 100644
--- a/templates/library.html
+++ b/templates/library.html
@@ -3,19 +3,119 @@
Everything on this site, organized by portal.
-
Sort by
-
-
-
-
+
+
Sort by
+
+
+
+
+
+
+
+
+
+
status
+
+
+
+
+
+
+
+
+
+
+
+
importance
+
+ ≥
+
+
+
+
+
+
+
+
+
evidence
+
+ ≥
+
+
+
+
+
+
+
+
+
+
scope
+
+ ≥
+
+
+
+
+
+
+
+
novelty
+
+ ≥
+
+
+
+
+
+
+
+
practicality
+
+ ≥
+
+
+
+
+
+
+
+
stability
+
+ ≥
+
+
+
+
+
+
+
+
+
+
+
No entries match the current filters.
+
$if(research-entries)$
$for(research-entries)$
--
+
-