From 7569c3ac4628aa5ef8b89ae0f45a9926a79cc3f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 18:43:53 +0000 Subject: [PATCH 1/6] Add Arthuriana section and use username display throughout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arthuriana: ports the Claude Design prototype (medieval heraldic character network) into the Vite/React build as two new pages — apps/arthuriana.html (armorial graph) and apps/arthuriana-studio.html (coat-of-arms builder). Converts Babel globals to ES module exports, replaces the host-only TweaksPanel with a persistent ArthurianControls overlay (layout buttons + relation toggles), and wires up nav links in SiteNavPanel and index.html. Username display: ContributorPortalApp now prefers clerkUser.username over fullName/firstName when building displayName. adminService ensureUserExists now syncs display_name on every login (not only when empty), so existing users get their username applied on next sign-in. https://claude.ai/code/session_013yG27kBU5JeKdX29ZwV1kv --- index.html | 1 + timeline-scratch/arthuriana-studio.html | 15 + timeline-scratch/arthuriana.html | 15 + .../src/Arthuriana/ArthurianApp.css | 524 +++++++++++ .../src/Arthuriana/ArthurianApp.jsx | 138 +++ .../src/Arthuriana/ArthurianControls.jsx | 57 ++ .../src/Arthuriana/ArthurianGraph.jsx | 425 +++++++++ .../src/Arthuriana/ArthurianHeraldry.jsx | 462 ++++++++++ .../src/Arthuriana/ArthurianPanel.jsx | 186 ++++ .../src/Arthuriana/ArthurianStudio.css | 336 +++++++ .../src/Arthuriana/ArthurianStudio.jsx | 298 +++++++ timeline-scratch/src/Arthuriana/characters.js | 832 ++++++++++++++++++ timeline-scratch/src/ContributorPortalApp.jsx | 4 +- .../src/components/SiteNavPanel.jsx | 1 + .../src/main-arthuriana-studio.jsx | 9 + timeline-scratch/src/main-arthuriana.jsx | 9 + timeline-scratch/src/services/adminService.js | 2 +- timeline-scratch/vite.config.js | 2 + 18 files changed, 3313 insertions(+), 3 deletions(-) create mode 100644 timeline-scratch/arthuriana-studio.html create mode 100644 timeline-scratch/arthuriana.html create mode 100644 timeline-scratch/src/Arthuriana/ArthurianApp.css create mode 100644 timeline-scratch/src/Arthuriana/ArthurianApp.jsx create mode 100644 timeline-scratch/src/Arthuriana/ArthurianControls.jsx create mode 100644 timeline-scratch/src/Arthuriana/ArthurianGraph.jsx create mode 100644 timeline-scratch/src/Arthuriana/ArthurianHeraldry.jsx create mode 100644 timeline-scratch/src/Arthuriana/ArthurianPanel.jsx create mode 100644 timeline-scratch/src/Arthuriana/ArthurianStudio.css create mode 100644 timeline-scratch/src/Arthuriana/ArthurianStudio.jsx create mode 100644 timeline-scratch/src/Arthuriana/characters.js create mode 100644 timeline-scratch/src/main-arthuriana-studio.jsx create mode 100644 timeline-scratch/src/main-arthuriana.jsx diff --git a/index.html b/index.html index 60733a8..f62701a 100644 --- a/index.html +++ b/index.html @@ -74,6 +74,7 @@

Windhover

Pantheons database + Arthuriana Contributor portal diff --git a/timeline-scratch/arthuriana-studio.html b/timeline-scratch/arthuriana-studio.html new file mode 100644 index 0000000..6df4c05 --- /dev/null +++ b/timeline-scratch/arthuriana-studio.html @@ -0,0 +1,15 @@ + + + + + + Heraldic Studio — Arthuriana + + + + + +
+ + + diff --git a/timeline-scratch/arthuriana.html b/timeline-scratch/arthuriana.html new file mode 100644 index 0000000..e0584da --- /dev/null +++ b/timeline-scratch/arthuriana.html @@ -0,0 +1,15 @@ + + + + + + Arthuriana — An Armorial of the Matter of Britain + + + + + +
+ + + diff --git a/timeline-scratch/src/Arthuriana/ArthurianApp.css b/timeline-scratch/src/Arthuriana/ArthurianApp.css new file mode 100644 index 0000000..9215107 --- /dev/null +++ b/timeline-scratch/src/Arthuriana/ArthurianApp.css @@ -0,0 +1,524 @@ +/* ─── PARCHMENT THEME ───────────────────────────────────────────────────── */ +:root { + --ink: #2a1810; + --ink-soft: #4a2f1f; + --ink-mute: #6a4a2a; + --rule: rgba(74, 47, 31, 0.25); + --gold: #8a5a1a; + --gold-pale: #c9a85a; + --rouge: #a82c2a; + --azure: #2a4b7c; + --parchment: #e8dcb8; + --parchment-deep: #d8c498; + --parchment-soft: #f0e7c8; + --shadow: 0 2px 6px rgba(60, 30, 10, 0.18); + --shadow-lg: 0 12px 32px rgba(60, 30, 10, 0.32); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; padding: 0; width: 100%; height: 100%; + background: #c8b48a; + font-family: 'EB Garamond', Georgia, serif; + color: var(--ink); + overflow: hidden; +} + +body::before { + content: ''; + position: fixed; inset: 0; + background: var(--parchment); + z-index: 0; +} +body::after { + content: ''; + position: fixed; inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + z-index: 1; pointer-events: none; mix-blend-mode: multiply; +} + +.stains { + position: fixed; inset: 0; z-index: 1; pointer-events: none; + background-image: + radial-gradient(ellipse 200px 150px at 12% 18%, rgba(100,60,20,.18), transparent 60%), + radial-gradient(ellipse 260px 180px at 88% 76%, rgba(100,60,20,.14), transparent 60%), + radial-gradient(ellipse 150px 100px at 78% 22%, rgba(120,70,30,.10), transparent 60%), + radial-gradient(ellipse 200px 140px at 22% 88%, rgba(120,70,30,.12), transparent 60%); +} + +#root { position: relative; z-index: 2; height: 100vh; display: flex; flex-direction: column; } + +/* ─── APP CHROME ────────────────────────────────────────────────────────── */ +.app { height: 100vh; display: flex; flex-direction: column; } + +.topbar { + display: flex; align-items: center; gap: 24px; + padding: 14px 28px; + border-bottom: 1px solid var(--rule); + background: linear-gradient(to bottom, rgba(232,220,184,0.6), rgba(232,220,184,0)); + position: relative; z-index: 10; +} +.topbar::after { + content: ''; position: absolute; left: 28px; right: 28px; bottom: 0; + height: 2px; + background: linear-gradient(to right, transparent, var(--gold) 20%, var(--gold) 80%, transparent); + opacity: 0.5; +} + +.brand { display: flex; align-items: center; gap: 14px; } +.brand svg { filter: drop-shadow(0 2px 3px rgba(60,30,10,.3)); } +.brand-title { + font-family: 'Cinzel', serif; font-weight: 700; + font-size: 26px; letter-spacing: 0.04em; + color: var(--ink); line-height: 1; +} +.brand-sub { + font-family: 'IM Fell English', serif; font-style: italic; + font-size: 13px; color: var(--ink-mute); margin-top: 4px; +} + +.search-wrap { flex: 1; max-width: 460px; margin: 0 auto; position: relative; } +.search { + width: 100%; + font-family: 'IM Fell English', serif; font-size: 16px; + padding: 9px 16px 9px 38px; + background: rgba(255, 248, 220, 0.6); + border: 1px solid var(--rule); + border-radius: 2px; + color: var(--ink); + outline: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: 12px center; + transition: border-color 200ms, background 200ms; +} +.search:focus { + border-color: var(--gold); + background: rgba(255, 248, 220, 0.85); +} +.search::placeholder { color: var(--ink-mute); font-style: italic; } + +.search-results { + position: absolute; top: calc(100% + 6px); left: 0; right: 0; + background: var(--parchment-soft); + border: 1px solid var(--rule); + box-shadow: var(--shadow-lg); + z-index: 50; + max-height: 360px; + overflow-y: auto; +} +.search-result { + display: flex; align-items: center; gap: 10px; width: 100%; + padding: 8px 12px; + border: 0; background: transparent; cursor: pointer; + text-align: left; font-family: inherit; color: inherit; + border-bottom: 1px solid var(--rule); +} +.search-result:last-child { border-bottom: 0; } +.search-result:hover { background: rgba(138, 90, 26, 0.12); } +.sr-name { font-family: 'IM Fell English', serif; font-size: 15px; color: var(--ink); } +.sr-title { font-size: 11px; font-style: italic; color: var(--ink-mute); } + +.studio-link { + display: inline-flex; align-items: center; gap: 7px; + padding: 8px 14px; + font-family: 'Cinzel', serif; font-size: 11px; font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; + color: var(--parchment-soft); + background: var(--ink-soft); + text-decoration: none; + border: 1px solid var(--gold); + transition: background 200ms; +} +.studio-link:hover { background: var(--ink); } + +/* ─── STAGE ─────────────────────────────────────────────────────────────── */ +.stage { + flex: 1; position: relative; overflow: hidden; + transition: padding-right 380ms cubic-bezier(.4, 0, .2, 1); +} +.stage.has-panel { padding-right: 460px; } + +.graph-wrap { + position: absolute; inset: 0; + overflow: hidden; + user-select: none; +} + +.graph-inner { + position: absolute; + top: 50%; left: 50%; + transition: transform 220ms ease-out; + will-change: transform; +} + +.graph-svg { + position: absolute; top: 0; left: 0; + pointer-events: none; +} +.graph-svg .edges path { transition: opacity 200ms; } + +.gn { + position: absolute; + display: flex; flex-direction: column; align-items: center; + cursor: pointer; + transition: filter 240ms ease, transform 240ms cubic-bezier(.34,1.5,.64,1); + z-index: 5; +} +.gn:hover { transform: translate(0, -3px); z-index: 8; } +.gn-focus { z-index: 10; } +.gn-focus .gn-banner { + background: var(--ink); color: var(--parchment-soft); + border-color: var(--gold); transform: scale(1.05); +} + +.gn-banner { + margin-top: -2px; + padding: 3px 10px 4px; + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-size: 12px; letter-spacing: 0.04em; + color: var(--ink); + background: var(--parchment-soft); + border: 1px solid var(--ink); + border-radius: 1px; + white-space: nowrap; + position: relative; + text-align: center; + max-width: 130px; + overflow: hidden; text-overflow: ellipsis; + transition: all 220ms; +} +.gn-banner::before, .gn-banner::after { + content: ''; position: absolute; top: 50%; transform: translateY(-50%); + width: 0; height: 0; + border-top: 6px solid transparent; border-bottom: 6px solid transparent; +} +.gn-banner::before { left: -6px; border-right: 6px solid var(--ink); } +.gn-banner::after { right: -6px; border-left: 6px solid var(--ink); } + +/* Zoom controls */ +.zoom-controls { + position: absolute; left: 24px; bottom: 24px; + display: flex; flex-direction: column; gap: 4px; + z-index: 20; +} +.zoom-controls button { + width: 36px; height: 36px; + background: rgba(232,220,184,0.85); + border: 1px solid var(--ink-soft); + color: var(--ink); + font-family: 'Cinzel', serif; font-size: 18px; font-weight: 700; + cursor: pointer; + box-shadow: var(--shadow); + transition: background 160ms; +} +.zoom-controls button:hover { background: var(--parchment-soft); } + +/* Legend */ +.legend { + position: absolute; left: 24px; top: 24px; + background: rgba(240, 231, 200, 0.88); + border: 1px solid var(--ink-soft); + padding: 14px 16px; + font-family: 'EB Garamond', serif; font-size: 13px; + box-shadow: var(--shadow); + z-index: 15; + min-width: 200px; + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); +} +.legend-title { + font-family: 'Cinzel', serif; font-size: 11px; font-weight: 700; + letter-spacing: 0.14em; text-transform: uppercase; + color: var(--ink-soft); margin-bottom: 8px; + padding-bottom: 6px; border-bottom: 1px solid var(--rule); +} +.legend-item { + display: flex; align-items: center; gap: 10px; + padding: 3px 0; + transition: opacity 200ms; +} +.legend-item.off { opacity: 0.32; } +.legend-item span { color: var(--ink); font-style: italic; } +.legend-hint { + margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rule); + font-size: 11px; font-style: italic; color: var(--ink-mute); +} + +/* ─── ARTHURIANA CONTROLS ────────────────────────────────────────────────── */ +.arthuriana-controls { + position: absolute; left: 24px; bottom: 96px; + background: rgba(240, 231, 200, 0.92); + border: 1px solid var(--ink-soft); + box-shadow: var(--shadow); + z-index: 20; + min-width: 180px; + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); +} +.ac-header { + display: flex; align-items: center; justify-content: space-between; + padding: 7px 10px; + border-bottom: 1px solid var(--rule); +} +.ac-title { + font-family: 'Cinzel', serif; font-size: 10px; font-weight: 700; + letter-spacing: 0.14em; text-transform: uppercase; + color: var(--gold); +} +.ac-toggle { + background: transparent; border: 0; + color: var(--ink-mute); font-size: 12px; + cursor: pointer; padding: 0 2px; + line-height: 1; +} +.ac-body { padding: 8px 10px 10px; } +.ac-section-label { + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-size: 10px; letter-spacing: 0.1em; + color: var(--ink-mute); + margin: 6px 0 4px; +} +.ac-section-label:first-child { margin-top: 0; } +.ac-layout-row { + display: flex; flex-direction: column; gap: 2px; + margin-bottom: 4px; +} +.ac-layout-btn { + font-family: 'IM Fell English', serif; font-size: 12px; + padding: 4px 8px; + background: rgba(255, 248, 220, 0.5); + border: 1px solid var(--rule); + color: var(--ink-soft); cursor: pointer; + text-align: left; + transition: all 140ms; +} +.ac-layout-btn:hover { background: rgba(138, 90, 26, 0.1); } +.ac-layout-btn.on { + background: var(--ink); + color: var(--parchment-soft); + border-color: var(--ink); +} +.ac-rel-list { + display: flex; flex-direction: column; gap: 2px; +} +.ac-rel-btn { + display: flex; align-items: center; gap: 7px; + font-family: 'IM Fell English', serif; font-size: 12px; + padding: 3px 8px; + background: rgba(255, 248, 220, 0.5); + border: 1px solid var(--rule); + color: var(--ink-soft); cursor: pointer; + text-align: left; + transition: opacity 140ms, background 140ms; +} +.ac-rel-btn:hover { background: rgba(138, 90, 26, 0.1); } +.ac-rel-btn.off { opacity: 0.4; } +.ac-rel-swatch { + width: 12px; height: 12px; + border-radius: 1px; + flex-shrink: 0; +} + +/* ─── PANEL ──────────────────────────────────────────────────────────────── */ +.panel { + position: absolute; top: 0; right: 0; bottom: 0; + width: 460px; + background: var(--parchment); + border-left: 1px solid var(--ink-soft); + overflow-y: auto; overflow-x: hidden; + z-index: 30; + animation: panel-in 380ms cubic-bezier(.4, 0, .2, 1); +} +.panel::before { + content: ''; position: absolute; inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + pointer-events: none; mix-blend-mode: multiply; +} +@keyframes panel-in { + from { transform: translateX(40px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.panel > * { position: relative; z-index: 1; } + +.panel-close { + position: absolute; top: 14px; right: 14px; + width: 32px; height: 32px; + border: 1px solid var(--ink-soft); + background: rgba(255, 248, 220, 0.6); + font-family: 'Cinzel', serif; font-size: 18px; + color: var(--ink); cursor: pointer; + z-index: 5; + transition: background 160ms; +} +.panel-close:hover { background: rgba(168, 44, 42, 0.15); } + +.panel-head { + display: flex; gap: 18px; + padding: 28px 28px 18px; + border-bottom: 1px solid var(--rule); +} +.panel-shield-wrap { flex-shrink: 0; } +.panel-id { min-width: 0; flex: 1; } +.panel-title { + font-family: 'Cinzel', serif; font-weight: 700; + font-size: 24px; line-height: 1.1; + color: var(--ink); + margin-bottom: 4px; +} +.panel-subtitle { + font-family: 'IM Fell English', serif; font-style: italic; + font-size: 14px; color: var(--ink-mute); + margin-bottom: 8px; +} +.panel-meta { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.group-badge, .realm-badge { + display: inline-block; + padding: 3px 9px; + color: #f0e7c8; + font-family: 'Cinzel', serif; font-size: 10px; + font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; + border-radius: 1px; +} +.role-tag { + font-family: 'IM Fell English', serif; + font-style: italic; + font-size: 12.5px; + color: var(--ink-mute); +} +.blazon-line { + font-family: 'EB Garamond', serif; font-style: italic; + font-size: 13px; color: var(--ink-soft); line-height: 1.45; +} +.blazon-label { + display: inline-block; + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-style: normal; + color: var(--ink-mute); font-size: 10px; + letter-spacing: 0.12em; margin-right: 6px; +} + +.panel-section-title { + font-family: 'Cinzel', serif; font-weight: 700; + font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--gold); + margin: 20px 28px 10px; + padding-bottom: 6px; + border-bottom: 1px solid var(--rule); +} + +/* Source tabs */ +.source-tabs { + display: flex; flex-wrap: wrap; gap: 4px; + padding: 0 28px; + margin-bottom: 12px; +} +.source-tab { + display: flex; flex-direction: column; align-items: flex-start; gap: 1px; + padding: 6px 10px; + background: transparent; + border: 1px solid var(--rule); + border-radius: 1px; + cursor: pointer; + font-family: inherit; + color: var(--ink-soft); + transition: all 160ms; +} +.source-tab:hover { + background: rgba(138, 90, 26, 0.08); + border-color: var(--ink-soft); +} +.source-tab.active { + background: var(--ink); + color: var(--parchment-soft); + border-color: var(--ink); +} +.src-name { + font-family: 'IM Fell English', serif; + font-size: 13px; + line-height: 1; +} +.src-date { + font-size: 9.5px; + letter-spacing: 0.06em; + opacity: 0.7; +} + +.source-body { + margin: 0 28px 18px; + padding: 14px 16px; + background: rgba(240, 231, 200, 0.5); + border: 1px solid var(--rule); + border-left: 3px solid var(--gold); +} +.source-work { + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-size: 11px; letter-spacing: 0.1em; + color: var(--ink-mute); + margin-bottom: 8px; +} +.source-body p { + font-family: 'EB Garamond', serif; + font-size: 14.5px; line-height: 1.6; + color: var(--ink); + margin: 0; + text-wrap: pretty; +} +.source-body p::first-letter { + font-family: 'UnifrakturMaguntia', 'Cinzel', serif; + font-size: 2.4em; + float: left; + line-height: 0.85; + padding-right: 6px; + margin-top: 4px; + color: var(--rouge); +} + +/* Relations list */ +.rel-list { padding: 0 28px 28px; display: flex; flex-direction: column; gap: 14px; } +.rel-bucket { + background: rgba(240, 231, 200, 0.35); + border: 1px solid var(--rule); + padding: 10px 12px; +} +.rel-bucket-h { + display: flex; align-items: center; gap: 8px; + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-size: 11px; letter-spacing: 0.1em; + color: var(--ink-mute); + margin-bottom: 8px; +} +.rel-swatch { + width: 18px; height: 0; border-top-width: 2px; border-radius: 0; +} +.rel-bucket ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; } +.rel-link { + display: inline-flex; align-items: center; gap: 6px; + background: rgba(255, 248, 220, 0.5); + border: 1px solid var(--rule); + border-radius: 1px; + padding: 4px 10px 4px 4px; + font-family: 'IM Fell English', serif; font-size: 13px; + color: var(--ink); cursor: pointer; + transition: all 160ms; +} +.rel-link:hover { + background: var(--parchment-soft); + border-color: var(--ink-soft); + transform: translate(0, -1px); +} + +/* responsive */ +@media (max-width: 1100px) { + .stage.has-panel { padding-right: 0; } + .panel { width: 100%; max-width: 460px; } + .legend { display: none; } +} +@media (max-width: 600px) { + .topbar { padding: 10px 16px; gap: 12px; } + .brand-title { font-size: 20px; } + .brand-sub { display: none; } + .arthuriana-controls { left: 12px; bottom: 80px; } +} diff --git a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx new file mode 100644 index 0000000..efe14b2 --- /dev/null +++ b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx @@ -0,0 +1,138 @@ +import { useState, useMemo } from 'react'; +import { CHARACTERS } from './characters.js'; +import { Shield } from './ArthurianHeraldry.jsx'; +import { Graph, REL_TYPES } from './ArthurianGraph.jsx'; +import { Panel } from './ArthurianPanel.jsx'; +import { ArthurianControls } from './ArthurianControls.jsx'; +import './ArthurianApp.css'; + +export function ArthurianApp() { + const [layout, setLayout] = useState('round-table'); + const [relFilter, setRelFilter] = useState({ + family: true, + marriage: true, + mentor: true, + fellowship: true, + rival: true, + quest: true, + }); + const [focusId, setFocusId] = useState(null); + const [hoverId, setHoverId] = useState(null); + const [query, setQuery] = useState(''); + const [searchOpen, setSearchOpen] = useState(false); + + const handleRelFilter = (key, value) => + setRelFilter(f => ({ ...f, [key]: value })); + + const focused = focusId ? CHARACTERS.find((c) => c.id === focusId) : null; + + const matches = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return []; + return CHARACTERS.filter((c) => + c.name.toLowerCase().includes(q) || + c.title.toLowerCase().includes(q) + ).slice(0, 8); + }, [query]); + + return ( +
+ + ); +} diff --git a/timeline-scratch/src/Arthuriana/ArthurianControls.jsx b/timeline-scratch/src/Arthuriana/ArthurianControls.jsx new file mode 100644 index 0000000..44b1bf0 --- /dev/null +++ b/timeline-scratch/src/Arthuriana/ArthurianControls.jsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { REL_TYPES } from './ArthurianGraph.jsx'; + +const LAYOUTS = [ + { value: 'round-table', label: 'Round Table' }, + { value: 'hub', label: 'Hub' }, + { value: 'tree', label: 'Tree' }, + { value: 'web', label: 'Web' }, +]; + +export function ArthurianControls({ layout, onLayout, relFilter, onRelFilter }) { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+
+ Controls + +
+ {!collapsed && ( +
+
Layout
+
+ {LAYOUTS.map((l) => ( + + ))} +
+
Relations
+
+ {Object.entries(REL_TYPES).map(([key, def]) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/timeline-scratch/src/Arthuriana/ArthurianGraph.jsx b/timeline-scratch/src/Arthuriana/ArthurianGraph.jsx new file mode 100644 index 0000000..d294577 --- /dev/null +++ b/timeline-scratch/src/Arthuriana/ArthurianGraph.jsx @@ -0,0 +1,425 @@ +// ArthurianGraph.jsx — Graph layout & rendering for the Arthurian character map. +import { useMemo, useState, useRef, useEffect } from 'react'; +import { Shield } from './ArthurianHeraldry.jsx'; +import { REALMS, REALM_BY_ID, KNIGHT_ORDER_BY_REALM } from './characters.js'; + +export const REL_TYPES = { + family: { label: 'Family', color: '#7a1e1e', dash: 'none', width: 2.2 }, + marriage: { label: 'Marriage / love', color: '#a82c2a', dash: 'none', width: 2.6 }, + mentor: { label: 'Mentor / squire', color: '#5a3a1e', dash: '6 4', width: 1.8 }, + fellowship: { label: 'Fellowship', color: '#2a4b7c', dash: 'none', width: 1.6 }, + rival: { label: 'Rival / enemy', color: '#2a1a1a', dash: '2 4', width: 1.8 }, + quest: { label: 'Quest companion', color: '#355e3b', dash: '8 3 2 3', width: 1.8 }, +}; + +// ─── LAYOUTS ───────────────────────────────────────────────────────────────── + +function roundTableLayout(characters, w, h) { + const cx = w / 2, cy = h / 2; + const knightR = Math.min(w, h) * 0.34; + const ringOuter = knightR - 28; + const ringInner = knightR * 0.56; + const innerCircleR = ringInner * 0.55; + const outerR = knightR + 230; + const labelR = knightR + 110; + + const positions = new Map(); + positions.set('arthur', { x: cx, y: cy - innerCircleR }); + positions.set('merlin', { x: cx - innerCircleR * 0.866, y: cy + innerCircleR * 0.5 }); + positions.set('guinevere', { x: cx + innerCircleR * 0.866, y: cy + innerCircleR * 0.5 }); + const innerIds = new Set(['arthur', 'merlin', 'guinevere']); + + let cursor = -Math.PI / 2 - (REALMS[0].width / 2) * Math.PI / 180; + const slots = REALMS.map((r) => { + const widthRad = r.width * Math.PI / 180; + const start = cursor; + const end = cursor + widthRad; + const mid = (start + end) / 2; + cursor = end; + return { ...r, start, end, mid, halfWidth: widthRad / 2 }; + }); + + slots.forEach((realm) => { + const realmChars = characters.filter((c) => REALM_BY_ID[c.id] === realm.id && !innerIds.has(c.id)); + const order = KNIGHT_ORDER_BY_REALM[realm.id] || []; + const knights = order + .map((id) => realmChars.find((c) => c.id === id && c.group === 'round-table')) + .filter(Boolean); + for (const c of realmChars) { + if (c.group === 'round-table' && !knights.includes(c)) knights.push(c); + } + const others = realmChars.filter((c) => c.group !== 'round-table'); + + knights.forEach((c, i) => { + const t = knights.length === 1 ? 0.5 : (i + 0.5) / knights.length; + const angle = realm.start + (realm.end - realm.start) * t; + positions.set(c.id, { x: cx + Math.cos(angle) * knightR, y: cy + Math.sin(angle) * knightR }); + }); + + const arcLen = (realm.end - realm.start) * outerR; + const needPerChar = 92; + const useTwoRows = others.length > 0 && (others.length * needPerChar) > arcLen; + const innerCount = useTwoRows ? Math.ceil(others.length / 2) : others.length; + const outerCount = useTwoRows ? Math.floor(others.length / 2) : 0; + others.forEach((c, i) => { + let row, idx, n; + if (useTwoRows) { row = i % 2; idx = Math.floor(i / 2); n = row === 0 ? innerCount : outerCount; } + else { row = 0; idx = i; n = others.length; } + const t = n <= 1 ? 0.5 : (idx + 0.5) / n; + const angle = realm.start + (realm.end - realm.start) * t; + const r = outerR + row * 120; + positions.set(c.id, { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }); + }); + }); + + characters.forEach((c, i) => { + if (!positions.has(c.id)) positions.set(c.id, { x: 100 + (i % 8) * 120, y: h - 100 }); + }); + + return { + positions, + table: { cx, cy, outerR: ringOuter, innerR: ringInner }, + sectorLabels: slots.map((s) => ({ label: s.label, mid: s.mid, half: s.halfWidth, r: labelR })), + }; +} + +function hubSpokeLayout(characters, w, h) { + const cx = w / 2, cy = h / 2; + const positions = new Map(); + positions.set('arthur', { x: cx, y: cy }); + const rest = characters.filter((c) => c.id !== 'arthur'); + const innerGroups = ['round-table', 'pendragon', 'enchanter']; + const inner = rest.filter((c) => innerGroups.includes(c.group)); + const outer = rest.filter((c) => !innerGroups.includes(c.group)); + const r1 = Math.min(w, h) * 0.32, r2 = Math.min(w, h) * 0.46; + inner.forEach((c, i) => { + const a = -Math.PI / 2 + (i / inner.length) * Math.PI * 2; + positions.set(c.id, { x: cx + Math.cos(a) * r1, y: cy + Math.sin(a) * r1 }); + }); + outer.forEach((c, i) => { + const a = -Math.PI / 2 + (i / outer.length) * Math.PI * 2 + Math.PI / outer.length; + positions.set(c.id, { x: cx + Math.cos(a) * r2, y: cy + Math.sin(a) * r2 }); + }); + return { positions }; +} + +function familyTreeLayout(characters, w, h) { + const positions = new Map(); + const rows = [ + ['vortigern', 'bran'], + ['uther', 'igraine', 'gorlois', 'merlin', 'lady-lake', 'taliesin'], + ['ector', 'arthur', 'guinevere', 'morgause', 'lot', 'morgan', 'urien', 'mark', 'pellinore', 'lot-king', 'pelles', 'pellam'], + ['kay', 'bedivere', 'lucan', 'lancelot', 'tristan', 'gawain', 'cador', + 'lamorak', 'palamedes', 'safir', 'segwarides', 'yvain', 'geraint', + 'caradoc', 'pelleas', 'nimue', 'bagdemagus', 'accolon', 'meleagant', + 'morholt', 'green-knight', 'balin', 'balan', 'ironside', + 'isolde-ireland', 'isolde-hands', 'enide', 'laudine', 'blanchefleur', + 'lyonesse', 'ettarde', 'elaine-corbenic', 'elaine-astolat', 'elaine-garlot'], + ['mordred', 'gareth', 'gaheris', 'agravain', 'galahad', 'percival', 'dindrane', + 'bors', 'lionel', 'ector-marais'], + ]; + const topMargin = 100, bottomMargin = 100; + const usableH = h - topMargin - bottomMargin; + rows.forEach((row, ri) => { + const y = topMargin + (ri / (rows.length - 1)) * usableH; + const sideMargin = 120; + const usableW = w - sideMargin * 2; + row.forEach((id, ci) => { + const x = sideMargin + (row.length === 1 ? usableW / 2 : (ci / (row.length - 1)) * usableW); + positions.set(id, { x, y }); + }); + }); + let stashX = 100; + characters.forEach((c) => { + if (!positions.has(c.id)) { positions.set(c.id, { x: stashX, y: h - 60 }); stashX += 110; } + }); + return { positions }; +} + +function forceWebLayout(characters, w, h) { + const positions = new Map(); + const cx = w / 2, cy = h / 2; + const R = Math.min(w, h) * 0.42; + characters.forEach((c, i) => { + const a = (i * 2.39996323) % (Math.PI * 2); + const r = R * (0.35 + (i % 7) / 10); + positions.set(c.id, { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }); + }); + const edges = []; + characters.forEach((c) => { + (c.relations || []).forEach((rel) => { if (positions.has(rel.to)) edges.push([c.id, rel.to]); }); + }); + const idealLen = 180, repK = 9000, attK = 0.04, damp = 0.85; + for (let iter = 0; iter < 220; iter++) { + const forces = new Map(); + positions.forEach((_, id) => forces.set(id, { x: 0, y: 0 })); + const ids = [...positions.keys()]; + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = positions.get(ids[i]), b = positions.get(ids[j]); + const dx = a.x - b.x, dy = a.y - b.y; + const d2 = dx * dx + dy * dy + 0.01; + const f = repK / d2, d = Math.sqrt(d2); + const fx = (dx / d) * f, fy = (dy / d) * f; + forces.get(ids[i]).x += fx; forces.get(ids[i]).y += fy; + forces.get(ids[j]).x -= fx; forces.get(ids[j]).y -= fy; + } + } + edges.forEach(([a, b]) => { + const pa = positions.get(a), pb = positions.get(b); + const dx = pb.x - pa.x, dy = pb.y - pa.y; + const d = Math.sqrt(dx * dx + dy * dy) + 0.01; + const f = attK * (d - idealLen); + const fx = (dx / d) * f, fy = (dy / d) * f; + forces.get(a).x += fx; forces.get(a).y += fy; + forces.get(b).x -= fx; forces.get(b).y -= fy; + }); + positions.forEach((p, id) => { + const f = forces.get(id); + const dx = cx - p.x, dy = cy - p.y; + f.x += dx * 0.005; f.y += dy * 0.005; + }); + positions.forEach((p, id) => { + const f = forces.get(id); + const stepCap = 30, fl = Math.sqrt(f.x * f.x + f.y * f.y); + const s = fl > stepCap ? stepCap / fl : 1; + p.x += f.x * s * damp; p.y += f.y * s * damp; + p.x = Math.max(80, Math.min(w - 80, p.x)); + p.y = Math.max(80, Math.min(h - 80, p.y)); + }); + } + return { positions }; +} + +function layoutFor(name, characters, w, h) { + if (name === 'round-table') return roundTableLayout(characters, w, h); + if (name === 'hub') return hubSpokeLayout(characters, w, h); + if (name === 'tree') return familyTreeLayout(characters, w, h); + if (name === 'web') return forceWebLayout(characters, w, h); + return roundTableLayout(characters, w, h); +} + +export function edgesFor(characters, focusId, relFilter) { + const seen = new Set(), out = []; + const byId = new Map(characters.map((c) => [c.id, c])); + characters.forEach((c) => { + (c.relations || []).forEach((rel) => { + if (!byId.has(rel.to)) return; + if (!relFilter[rel.type]) return; + const key = [c.id, rel.to].sort().join('|') + '|' + rel.type; + if (seen.has(key)) return; + seen.add(key); + out.push({ from: c.id, to: rel.to, type: rel.type }); + }); + }); + if (focusId) return out.filter((e) => e.from === focusId || e.to === focusId); + return out; +} + +// ─── TABLE ART ─────────────────────────────────────────────────────────────── + +function SectorLabels({ cx, cy, sectors }) { + if (!sectors || sectors.length === 0) return null; + return ( + {sectors.map((s, i) => { + const isLower = Math.sin(s.mid) > 0.05; + let a0, a1, sweep; + if (isLower) { a0 = s.mid + s.half; a1 = s.mid - s.half; sweep = 0; } + else { a0 = s.mid - s.half; a1 = s.mid + s.half; sweep = 1; } + const x0 = cx + Math.cos(a0) * s.r, y0 = cy + Math.sin(a0) * s.r; + const x1 = cx + Math.cos(a1) * s.r, y1 = cy + Math.sin(a1) * s.r; + const d = `M ${x0} ${y0} A ${s.r} ${s.r} 0 0 ${sweep} ${x1} ${y1}`; + const pid = `sector-arc-${i}`; + return ( + + + + + + {s.label} + + + + ); + })} + ); +} + +function TableArt({ table }) { + if (!table) return null; + const { cx, cy, outerR, innerR } = table; + const ringPath = + `M ${cx - outerR} ${cy} A ${outerR} ${outerR} 0 1 0 ${cx + outerR} ${cy} A ${outerR} ${outerR} 0 1 0 ${cx - outerR} ${cy} Z ` + + `M ${cx - innerR} ${cy} A ${innerR} ${innerR} 0 1 1 ${cx + innerR} ${cy} A ${innerR} ${innerR} 0 1 1 ${cx - innerR} ${cy} Z`; + return ( + + + + + + + + + + + + + + + + + + + + {Array.from({ length: 48 }, (_, i) => { + const a = (i / 48) * Math.PI * 2; + const x1 = cx + Math.cos(a) * (outerR - 14), y1 = cy + Math.sin(a) * (outerR - 14); + const x2 = cx + Math.cos(a) * (outerR - 4), y2 = cy + Math.sin(a) * (outerR - 4); + return ; + })} + ); +} + +// ─── GRAPH NODE ────────────────────────────────────────────────────────────── + +function GraphNode({ character, x, y, onClick, onHover, onLeave, focused, dimmed, hovered, shieldSize }) { + const w = shieldSize + 28, h = shieldSize * 1.2 + 36; + return ( +
+ +
+ {character.name.replace(/^(Sir|King|Queen|Lady) /, '')} +
+
+ ); +} + +// ─── GRAPH ─────────────────────────────────────────────────────────────────── + +export function Graph({ characters, layout, focusId, hoverId, onFocus, onHover, relFilter }) { + const W = 1700, H = 1300; + const containerRef = useRef(null); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(0.55); + const [dragging, setDragging] = useState(false); + const dragRef = useRef(null); + + const { positions, table, sectorLabels } = useMemo( + () => layoutFor(layout, characters, W, H), + [layout, characters] + ); + + const activeId = focusId || hoverId; + const edges = useMemo(() => { + if (!activeId) return []; + return edgesFor(characters, activeId, relFilter); + }, [characters, activeId, relFilter]); + + const connected = useMemo(() => { + if (!activeId) return null; + const s = new Set([activeId]); + edges.forEach((e) => { s.add(e.from); s.add(e.to); }); + return s; + }, [activeId, edges]); + + const onMouseDown = (e) => { + if (e.target.closest('.gn')) return; + setDragging(true); + dragRef.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; + }; + const onMouseMove = (e) => { + if (!dragging || !dragRef.current) return; + setPan({ x: dragRef.current.panX + (e.clientX - dragRef.current.x), y: dragRef.current.panY + (e.clientY - dragRef.current.y) }); + }; + const onMouseUp = () => setDragging(false); + const onWheel = (e) => { + e.preventDefault(); + setZoom((z) => Math.max(0.25, Math.min(1.8, z * (1 + (-e.deltaY * 0.0012))))); + }; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); + + useEffect(() => { setPan({ x: 0, y: 0 }); }, [layout]); + + const shieldSize = layout === 'tree' ? 48 : 60; + + return ( +
+
+ + {layout === 'round-table' && } + + {edges.map((e, i) => { + const a = positions.get(e.from), b = positions.get(e.to); + if (!a || !b) return null; + const style = REL_TYPES[e.type]; + const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + const dx = b.x - a.x, dy = b.y - a.y; + const len = Math.sqrt(dx * dx + dy * dy); + const nx = -dy / len, ny = dx / len; + const bend = Math.min(40, len * 0.08); + const qx = mx + nx * bend, qy = my + ny * bend; + return ( + + ); + })} + + {layout === 'round-table' && } + + + {characters.map((c) => { + const p = positions.get(c.id); + if (!p) return null; + return ( + onFocus(c.id)} + onHover={() => onHover(c.id)} + onLeave={() => onHover(null)} + focused={focusId === c.id} + hovered={hoverId === c.id && focusId !== c.id} + dimmed={connected && !connected.has(c.id)} + /> + ); + })} +
+ +
+ + + +
+
+ ); +} diff --git a/timeline-scratch/src/Arthuriana/ArthurianHeraldry.jsx b/timeline-scratch/src/Arthuriana/ArthurianHeraldry.jsx new file mode 100644 index 0000000..a7b83ef --- /dev/null +++ b/timeline-scratch/src/Arthuriana/ArthurianHeraldry.jsx @@ -0,0 +1,462 @@ +// ArthurianHeraldry.jsx — Heraldic shield rendering engine. +import { useMemo } from 'react'; + +export const TINCTURES = { + or: { fill: '#d4a843', stroke: '#7a5a1a', label: 'Or' }, + argent: { fill: '#ece2c8', stroke: '#8a7c5c', label: 'Argent' }, + gules: { fill: '#a82c2a', stroke: '#5a1414', label: 'Gules' }, + azure: { fill: '#2a4b7c', stroke: '#142a4a', label: 'Azure' }, + sable: { fill: '#2a1f17', stroke: '#0a0604', label: 'Sable' }, + vert: { fill: '#355e3b', stroke: '#1a2f1d', label: 'Vert' }, + purpure: { fill: '#6b3a6b', stroke: '#3a1f3a', label: 'Purpure' }, + murrey: { fill: '#6b2c4a', stroke: '#3a1424', label: 'Murrey' }, + sanguine:{ fill: '#6b1a1a', stroke: '#3a0a0a', label: 'Sanguine'}, + tenne: { fill: '#a85a1f', stroke: '#5a2a0a', label: 'Tenné' }, +}; + +const tFill = (t) => (TINCTURES[t] || TINCTURES.argent).fill; +const tStroke = (t) => (TINCTURES[t] || TINCTURES.argent).stroke; + +const SHIELD_PATH = 'M 8 6 L 92 6 L 92 56 C 92 88 70 108 50 116 C 30 108 8 88 8 56 Z'; + +function Field({ field, id }) { + if (typeof field === 'string') { + return ; + } + const { division, pattern, tinctures = ['argent', 'azure'] } = field; + const [a, b] = tinctures; + + if (division) { + if (division === 'per pale') return (<>); + if (division === 'per fess') return (<>); + if (division === 'per bend') return (<>); + if (division === 'per bend sinister') return (<>); + if (division === 'per chevron') return (<>); + if (division === 'per saltire') return (<>); + if (division === 'quarterly') return (<>); + } + + if (pattern) return ; + return ; +} + +function PatternField({ pattern, a, b, id }) { + const pid = `pat-${id}-${pattern}`; + let def; + if (pattern === 'chequy') { + def = ( + + + + + ); + } else if (pattern === 'lozengy') { + def = ( + + + ); + } else if (pattern === 'bendy') { + def = ( + + + ); + } else if (pattern === 'paly') { + def = ( + + + ); + } else if (pattern === 'barry') { + def = ( + + + ); + } else if (pattern === 'ermine') { + def = ( + + + + + + ); + } else if (pattern === 'vair') { + def = ( + + + ); + } else { + return ; + } + return (<>{def}); +} + +function Ordinary({ ord }) { + if (!ord) return null; + const f = tFill(ord.tincture), s = tStroke(ord.tincture), sw = 0.6; + switch (ord.type) { + case 'chief': return ; + case 'pale': return ; + case 'fess': return ; + case 'bend': return ; + case 'bend sinister': return ; + case 'cross': return (<>); + case 'saltire': return (<>); + case 'chevron': return ; + case 'bordure': return ; + case 'pile': return ; + default: return null; + } +} + +export const CHARGES = { + lion: (t, attitude = 'rampant') => { + const f = tFill(t), s = tStroke(t); + if (attitude === 'passant') { + return ( + + + + ); + } + return ( + + + + ); + }, + dragon: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + + + ); + }, + eagle: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + griffin: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + ); + }, + unicorn: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + ); + }, + boar: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + ); + }, + stag: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + bear: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + + ); + }, + serpent: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + fish: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + toad: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + + ); + }, + rose: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + {[0,72,144,216,288].map(a => )} + + ); + }, + 'fleur-de-lis': (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + cross: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + ); + }, + 'cross-patty': (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + ); + }, + mullet: (t) => { + const f = tFill(t), s = tStroke(t); + const pts = []; + for (let i = 0; i < 10; i++) { + const r = i % 2 ? 4 : 10; + const a = (i * Math.PI) / 5 - Math.PI / 2; + pts.push(`${Math.cos(a) * r},${Math.sin(a) * r}`); + } + return ; + }, + crescent: (t) => { + const f = tFill(t), s = tStroke(t); + return ; + }, + sun: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + {[0,45,90,135,180,225,270,315].map(a => )} + ); + }, + moon: (t) => { + const f = tFill(t), s = tStroke(t); + return ; + }, + crown: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + + ); + }, + sword: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + + ); + }, + arrow: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + tower: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + ); + }, + castle: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + key: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + + ); + }, + harp: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + hand: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + ); + }, + arm: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + raven: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + chalice: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + ); + }, + horseshoe: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + ); + }, + pentangle: (t) => { + const f = tFill(t), s = tStroke(t); + const pts = []; + for (let i = 0; i < 5; i++) { + const a = (i * 4 * Math.PI) / 5 - Math.PI / 2; + pts.push(`${Math.cos(a) * 13},${Math.sin(a) * 13}`); + } + return ; + }, + trefoil: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + + + ); + }, + oak: (t) => { + const f = tFill(t), s = tStroke(t); + return ( + + + ); + }, + pall: (t) => { + const f = tFill(t), s = tStroke(t); + return ; + }, + fess: (t) => { + const f = tFill(t), s = tStroke(t); + return ; + }, +}; + +export const CHARGE_LIST = Object.keys(CHARGES); + +function chargePositions(count, arrangement) { + if (count === 1 || !count) return [{ x: 50, y: 55, scale: 2.0 }]; + if (arrangement === 'in chief') { + if (count === 3) return [{ x: 30, y: 18, scale: 0.9 }, { x: 50, y: 18, scale: 0.9 }, { x: 70, y: 18, scale: 0.9 }]; + if (count === 2) return [{ x: 35, y: 18, scale: 0.9 }, { x: 65, y: 18, scale: 0.9 }]; + } + if (arrangement === 'in bend') { + if (count === 3) return [{ x: 25, y: 25, scale: 1.0 }, { x: 50, y: 50, scale: 1.0 }, { x: 75, y: 75, scale: 1.0 }]; + } + if (arrangement === 'in pale') { + if (count === 3) return [{ x: 50, y: 22, scale: 1.0 }, { x: 50, y: 55, scale: 1.0 }, { x: 50, y: 88, scale: 1.0 }]; + if (count === 2) return [{ x: 50, y: 30, scale: 1.1 }, { x: 50, y: 75, scale: 1.1 }]; + } + if (arrangement === 'in fess') { + if (count === 3) return [{ x: 24, y: 55, scale: 1.0 }, { x: 50, y: 55, scale: 1.0 }, { x: 76, y: 55, scale: 1.0 }]; + } + if (arrangement === 'in saltire' && count === 2) { + return [{ x: 50, y: 55, scale: 1.5, rotate: -45 }, { x: 50, y: 55, scale: 1.5, rotate: 45 }]; + } + if (count === 3) return [{ x: 30, y: 30, scale: 1.0 }, { x: 70, y: 30, scale: 1.0 }, { x: 50, y: 78, scale: 1.0 }]; + if (count === 2) return [{ x: 35, y: 45, scale: 1.3 }, { x: 65, y: 45, scale: 1.3 }]; + if (count === 4) return [{ x: 30, y: 28, scale: 0.85 }, { x: 70, y: 28, scale: 0.85 }, { x: 30, y: 75, scale: 0.85 }, { x: 70, y: 75, scale: 0.85 }]; + return Array.from({ length: count }, (_, i) => ({ x: 22 + (i % 3) * 28, y: 25 + Math.floor(i / 3) * 25, scale: 0.7 })); +} + +function ChargesLayer({ charges }) { + if (!charges || charges.length === 0) return null; + return (<> + {charges.flatMap((ch, i) => { + const fn = CHARGES[ch.device]; + if (!fn) return []; + const positions = chargePositions(ch.count || 1, ch.arrangement); + return positions.map((p, j) => ( + + {fn(ch.tincture, ch.attitude)} + + )); + })} + ); +} + +let __shieldUid = 0; + +export function Shield({ blazon, size = 96, label, banner = true, dim = false, highlighted = false, frame = true }) { + const uid = useMemo(() => ++__shieldUid, []); + const clipId = `shield-clip-${uid}`; + const grainId = `shield-grain-${uid}`; + const inkId = `shield-ink-${uid}`; + + return ( +
+ + + + + + + + + + + + + + + + + + + + {frame && } + {highlighted && } + + {banner && label && ( +
{label}
+ )} +
+ ); +} diff --git a/timeline-scratch/src/Arthuriana/ArthurianPanel.jsx b/timeline-scratch/src/Arthuriana/ArthurianPanel.jsx new file mode 100644 index 0000000..0fa8ab5 --- /dev/null +++ b/timeline-scratch/src/Arthuriana/ArthurianPanel.jsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from 'react'; +import { Shield } from './ArthurianHeraldry.jsx'; +import { REL_TYPES } from './ArthurianGraph.jsx'; +import { REALM_BY_ID, REALM_LABEL } from './characters.js'; + +export const SOURCE_META = { + 'Geoffrey of Monmouth': { date: 'c. 1136', work: 'Historia Regum Britanniae' }, + 'Wace': { date: '1155', work: 'Roman de Brut' }, + 'Chrétien de Troyes': { date: 'c. 1170–90', work: 'The five romances' }, + 'Mabinogion': { date: '11–14th c.', work: 'Welsh prose tales' }, + 'Other early sources': { date: '12–13th c.', work: 'Béroul, Thomas, the Pearl Poet et al.' }, + 'Wolfram von Eschenbach': { date: 'c. 1210', work: 'Parzival' }, + 'Sir Gawain and the Green Knight': { date: 'c. 1400', work: 'Pearl Poet, anon.' }, + 'Vulgate Cycle': { date: 'c. 1215–35', work: 'Lancelot-Grail (Old French)' }, + 'Malory': { date: '1485', work: "Le Morte d'Arthur" }, + 'Spenser': { date: '1590', work: 'The Faerie Queene' }, + 'Tennyson': { date: '1859–85', work: 'Idylls of the King' }, + 'Mark Twain': { date: '1889', work: 'A Connecticut Yankee…' }, + 'T.H. White': { date: '1938–58', work: 'The Once and Future King' }, + 'Tolkien': { date: 'c. 1934', work: 'The Fall of Arthur' }, +}; + +export function describeBlazon(b) { + if (!b) return ''; + const parts = []; + const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); + if (typeof b.field === 'string') { + parts.push(cap(b.field)); + } else if (b.field?.division) { + parts.push(cap(b.field.division) + ' ' + b.field.tinctures.join(' and ')); + } else if (b.field?.pattern) { + parts.push(cap(b.field.pattern) + ' ' + b.field.tinctures.join(' and ')); + } + if (b.ordinary) { + parts.push(`a ${b.ordinary.type} ${b.ordinary.tincture}`); + } + (b.charges || []).forEach((ch) => { + let s = ''; + if (ch.count > 1) s += `${numWord(ch.count)} `; + s += pluralize(ch.device, ch.count); + if (ch.attitude) s += ` ${ch.attitude}`; + s += ` ${ch.tincture}`; + if (ch.arrangement) s += ` ${ch.arrangement}`; + parts.push(s); + }); + return parts.join(', '); +} + +function numWord(n) { + return ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven'][n] || String(n); +} +function pluralize(d, n) { + if (!n || n === 1) { + if (d === 'fleur-de-lis') return 'fleur-de-lis'; + return 'a ' + d; + } + const map = { 'fleur-de-lis': 'fleurs-de-lis', mullet: 'mullets', cross: 'crosses', lion: 'lions', dragon: 'dragons', crown: 'crowns', rose: 'roses', sword: 'swords', arrow: 'arrows', tower: 'towers', key: 'keys', fish: 'fishes', crescent: 'crescents', mullets: 'mullets' }; + return map[d] || d + 's'; +} + +const REALM_COLORS = { + logres: '#7a1e1e', + cornwall: '#2a1f17', + wales: '#355e3b', + north: '#3a3a5a', + ireland: '#1f5a4a', + france: '#2a4b7c', + other: '#3a1f3a', +}; + +const ROLE_LABEL = { + 'round-table': 'Knight of the Round Table', + 'pendragon': 'House Pendragon', + 'orkney': 'House Orkney', + 'cornwall': 'House Cornwall', + 'enchanter': 'Enchanter', + 'lady': 'Lady of Story', + 'foe': 'Foe of the Realm', + 'other': 'Of the Wider Isle', +}; + +function RealmBadge({ id }) { + const label = REALM_LABEL[id] || id; + const color = REALM_COLORS[id] || '#444'; + return {label}; +} + +export function Panel({ character, allCharacters, onClose, onNavigate, focusedRelTypes }) { + const [tab, setTab] = useState(null); + const sources = character?.sources || []; + + useEffect(() => { + setTab(sources[0]?.source || null); + }, [character?.id]); + + if (!character) return null; + + const byId = new Map(allCharacters.map((c) => [c.id, c])); + const rels = (character.relations || []).filter(r => byId.has(r.to)); + + const seen = new Set(); + const buckets = {}; + rels.forEach(r => { + const k = r.to + '|' + r.type; + if (seen.has(k)) return; + seen.add(k); + (buckets[r.type] ||= []).push(r); + }); + const relOrder = ['family', 'marriage', 'mentor', 'fellowship', 'quest', 'rival']; + + const activeSource = sources.find((s) => s.source === tab) || sources[0]; + + return ( + + ); +} diff --git a/timeline-scratch/src/Arthuriana/ArthurianStudio.css b/timeline-scratch/src/Arthuriana/ArthurianStudio.css new file mode 100644 index 0000000..7db1a94 --- /dev/null +++ b/timeline-scratch/src/Arthuriana/ArthurianStudio.css @@ -0,0 +1,336 @@ +/* ─── PARCHMENT THEME ───────────────────────────────────────────────────── */ +:root { + --ink: #2a1810; + --ink-soft: #4a2f1f; + --ink-mute: #6a4a2a; + --rule: rgba(74, 47, 31, 0.25); + --gold: #8a5a1a; + --gold-pale: #c9a85a; + --rouge: #a82c2a; + --parchment: #e8dcb8; + --parchment-deep: #d8c498; + --parchment-soft: #f0e7c8; + --shadow: 0 2px 6px rgba(60, 30, 10, 0.18); + --shadow-lg: 0 12px 32px rgba(60, 30, 10, 0.32); +} +* { box-sizing: border-box; } + +html, body { + margin: 0; padding: 0; width: 100%; min-height: 100%; + background: #c8b48a; + font-family: 'EB Garamond', Georgia, serif; + color: var(--ink); +} +body::before { + content: ''; + position: fixed; inset: 0; + background: var(--parchment); + z-index: 0; +} +body::after { + content: ''; + position: fixed; inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + z-index: 1; pointer-events: none; mix-blend-mode: multiply; +} + +.stains { + position: fixed; inset: 0; z-index: 1; pointer-events: none; + background-image: + radial-gradient(ellipse 200px 150px at 12% 18%, rgba(100,60,20,.18), transparent 60%), + radial-gradient(ellipse 260px 180px at 88% 76%, rgba(100,60,20,.14), transparent 60%), + radial-gradient(ellipse 150px 100px at 78% 22%, rgba(120,70,30,.10), transparent 60%), + radial-gradient(ellipse 200px 140px at 22% 88%, rgba(120,70,30,.12), transparent 60%); +} + +#root { position: relative; z-index: 2; min-height: 100vh; } + +.studio { min-height: 100vh; display: flex; flex-direction: column; position: relative; z-index: 2; } + +.topbar { + display: flex; align-items: center; gap: 24px; + padding: 14px 28px; + border-bottom: 1px solid var(--rule); + position: relative; +} +.topbar::after { + content: ''; position: absolute; left: 28px; right: 28px; bottom: 0; + height: 2px; + background: linear-gradient(to right, transparent, var(--gold) 20%, var(--gold) 80%, transparent); + opacity: 0.5; +} +.back-link { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 14px; + font-family: 'Cinzel', serif; font-size: 11px; font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; + color: var(--ink); + background: rgba(255, 248, 220, 0.6); + border: 1px solid var(--ink-soft); + text-decoration: none; + transition: background 200ms; +} +.back-link:hover { background: rgba(255, 248, 220, 0.95); } +.brand-row { flex: 1; } +.brand-title { + font-family: 'Cinzel', serif; font-weight: 700; + font-size: 26px; letter-spacing: 0.04em; + color: var(--ink); line-height: 1; +} +.brand-sub { + font-family: 'IM Fell English', serif; font-style: italic; + font-size: 13px; color: var(--ink-mute); margin-top: 4px; +} + +.studio-main { + flex: 1; display: grid; + grid-template-columns: minmax(340px, 1fr) minmax(420px, 600px); + gap: 36px; + padding: 36px 28px; + max-width: 1400px; margin: 0 auto; width: 100%; +} +@media (max-width: 900px) { + .studio-main { grid-template-columns: 1fr; } +} + +/* Preview pane */ +.studio-preview { + position: sticky; top: 24px; align-self: start; + display: flex; flex-direction: column; align-items: center; + gap: 20px; +} +.preview-wrap { + padding: 32px 40px 48px; + background: var(--parchment); + border: 1px solid var(--ink-soft); + position: relative; +} +.preview-wrap::before { + content: ''; position: absolute; inset: 6px; + border: 1px solid var(--gold); opacity: 0.45; pointer-events: none; +} + +.blazon-display { + width: 100%; max-width: 480px; + padding: 14px 18px; + background: rgba(240, 231, 200, 0.55); + border: 1px solid var(--rule); + border-left: 3px solid var(--gold); +} +.blazon-display .blazon-label { + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-size: 11px; letter-spacing: 0.12em; + color: var(--ink-mute); + margin-bottom: 4px; +} +.blazon-display .blazon-text { + font-family: 'EB Garamond', serif; font-style: italic; + font-size: 17px; line-height: 1.4; + color: var(--ink); + text-wrap: pretty; +} + +.preset-row { + display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; +} +.preset { + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 8px 10px; + background: transparent; + border: 1px solid var(--rule); + cursor: pointer; + font-family: 'IM Fell English', serif; font-size: 11px; + color: var(--ink-soft); + transition: all 160ms; +} +.preset:hover { + background: rgba(255, 248, 220, 0.55); + border-color: var(--ink-soft); + transform: translate(0, -1px); +} + +/* Controls */ +.studio-controls { + display: flex; flex-direction: column; gap: 18px; +} + +.ctrl-section { + background: rgba(240, 231, 200, 0.45); + border: 1px solid var(--rule); + padding: 16px 20px; + position: relative; +} +.ctrl-h { + font-family: 'Cinzel', serif; font-weight: 700; + font-size: 13px; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--gold); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); +} +.ctrl-h-row { display: flex; justify-content: space-between; align-items: flex-start; } + +.add-btn, .rm-btn { + font-family: 'Cinzel', serif; font-size: 11px; font-weight: 700; + letter-spacing: 0.08em; + padding: 5px 10px; + background: var(--ink); + color: var(--parchment-soft); + border: 1px solid var(--ink); + cursor: pointer; + transition: background 160ms; +} +.add-btn:hover { background: var(--ink-soft); } +.rm-btn { + background: rgba(168, 44, 42, 0.85); + font-size: 14px; padding: 2px 8px; line-height: 1; +} +.rm-btn:hover { background: var(--rouge); } + +.ctrl-row { + display: grid; + grid-template-columns: 130px 1fr; + gap: 12px; + align-items: start; + padding: 8px 0; +} +.ctrl-row label { + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-size: 12px; letter-spacing: 0.08em; + color: var(--ink-mute); + padding-top: 6px; +} +.ctrl-row select { + font-family: 'EB Garamond', serif; font-size: 14px; + padding: 6px 8px; + background: rgba(255, 248, 220, 0.7); + border: 1px solid var(--rule); + color: var(--ink); + cursor: pointer; +} + +/* Tincture row */ +.tinct-row { display: flex; flex-wrap: wrap; gap: 5px; } +.tinct { + border-radius: 1px; + border: 1px solid; + cursor: pointer; + transition: transform 120ms; +} +.tinct:hover { transform: scale(1.08); } +.tinct.on { + outline: 2px solid var(--gold); + outline-offset: 2px; +} + +/* Segmented buttons */ +.seg { + display: inline-flex; + border: 1px solid var(--rule); + background: rgba(255, 248, 220, 0.5); + border-radius: 1px; + flex-wrap: wrap; +} +.seg button { + font-family: 'IM Fell English', serif; font-size: 13px; + padding: 5px 12px; + background: transparent; + border: 0; + cursor: pointer; + color: var(--ink-soft); + border-right: 1px solid var(--rule); + transition: background 160ms; +} +.seg button:last-child { border-right: 0; } +.seg button:hover { background: rgba(138, 90, 26, 0.08); } +.seg button.on { + background: var(--ink); + color: var(--parchment-soft); +} + +/* Charge cards */ +.charge-card { + border: 1px solid var(--rule); + background: rgba(255, 248, 220, 0.55); + padding: 12px 14px; + margin-top: 12px; +} +.charge-card-head { + display: flex; align-items: center; gap: 10px; + padding-bottom: 10px; margin-bottom: 6px; + border-bottom: 1px solid var(--rule); +} +.charge-preview { + width: 44px; height: 44px; + border: 1px solid var(--ink-soft); + overflow: hidden; + flex-shrink: 0; +} +.charge-name { + flex: 1; + font-family: 'IM Fell English', serif; font-style: italic; + font-size: 14px; color: var(--ink); +} +.empty { + font-style: italic; color: var(--ink-mute); + padding: 12px; font-size: 14px; +} + +/* Device picker */ +.device-grid { display: flex; flex-direction: column; gap: 6px; } +.device-grid details { + border: 1px solid var(--rule); + background: rgba(232, 220, 184, 0.4); +} +.device-grid summary { + padding: 6px 10px; + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-size: 12px; letter-spacing: 0.08em; + color: var(--ink-mute); cursor: pointer; + list-style: none; +} +.device-grid summary::-webkit-details-marker { display: none; } +.device-grid summary::before { content: '▸ '; } +.device-grid details[open] summary::before { content: '▾ '; } + +.device-buttons { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); + gap: 4px; + padding: 8px; +} +.device-btn { + display: flex; flex-direction: column; align-items: center; gap: 2px; + padding: 6px 4px; + background: rgba(255, 248, 220, 0.6); + border: 1px solid var(--rule); + cursor: pointer; + font-family: 'IM Fell English', serif; font-size: 10px; + color: var(--ink-soft); + transition: all 120ms; +} +.device-btn:hover { background: var(--parchment-soft); transform: translate(0, -1px); } +.device-btn.on { + background: var(--ink); color: var(--parchment-soft); + border-color: var(--ink); +} + +/* Glossary */ +.glossary { margin: 0; } +.glossary > div { + padding: 8px 0; + border-bottom: 1px dotted var(--rule); +} +.glossary > div:last-child { border-bottom: 0; } +.glossary dt { + font-family: 'IM Fell English SC', 'IM Fell English', serif; + font-size: 12px; letter-spacing: 0.08em; + color: var(--ink); margin-bottom: 3px; +} +.glossary dd { + margin: 0; + font-family: 'EB Garamond', serif; + font-size: 14px; line-height: 1.5; + color: var(--ink-soft); +} +.glossary em { color: var(--gold); font-style: italic; } diff --git a/timeline-scratch/src/Arthuriana/ArthurianStudio.jsx b/timeline-scratch/src/Arthuriana/ArthurianStudio.jsx new file mode 100644 index 0000000..8cb6a44 --- /dev/null +++ b/timeline-scratch/src/Arthuriana/ArthurianStudio.jsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { Shield, TINCTURES, CHARGES } from './ArthurianHeraldry.jsx'; +import { describeBlazon } from './ArthurianPanel.jsx'; +import './ArthurianStudio.css'; + +const TINCTURE_KEYS = ['or','argent','gules','azure','sable','vert','purpure','murrey','sanguine','tenne']; + +const FIELD_MODES = [ + { value: 'plain', label: 'Plain' }, + { value: 'division', label: 'Divided' }, + { value: 'pattern', label: 'Patterned' }, +]; +const DIVISIONS = ['per pale','per fess','per bend','per bend sinister','per chevron','per saltire','quarterly']; +const PATTERNS = ['chequy','lozengy','bendy','paly','barry','ermine','vair']; + +const ORDINARIES = ['(none)','chief','pale','fess','bend','bend sinister','cross','saltire','chevron','pile','bordure']; + +const ARRANGEMENTS = [ + { value: '', label: 'Centered (or 2-1 if many)' }, + { value: 'in pale', label: 'In pale (column)' }, + { value: 'in fess', label: 'In fess (row)' }, + { value: 'in chief', label: 'In chief (along the top)' }, + { value: 'in bend', label: 'In bend (diagonal)' }, + { value: 'in saltire', label: 'In saltire (crossed)' }, +]; + +const ATTITUDES = ['(none)','rampant','passant']; + +const PRESETS = { + blank: { field: 'argent', charges: [] }, + arthur: { field: 'azure', charges: [{ device: 'crown', tincture: 'or', count: 3, arrangement: 'in pale' }] }, + lancelot: { field: 'argent', ordinary: { type: 'bend', tincture: 'gules' } }, + gawain: { field: 'purpure', charges: [{ device: 'pentangle', tincture: 'or' }] }, + galahad: { field: 'argent', ordinary: { type: 'cross', tincture: 'gules' } }, + saracen: { field: { pattern: 'chequy', tinctures: ['or', 'sable'] }, charges: [] }, +}; + +const CHARGE_CATS = { + Beasts: ['lion','dragon','eagle','griffin','unicorn','boar','stag','bear','serpent','fish','toad','raven'], + Plants: ['rose','fleur-de-lis','trefoil','oak'], + Symbols: ['cross','cross-patty','mullet','crescent','sun','moon','pentangle','crown'], + Arms: ['sword','arrow','tower','castle','key','harp','chalice','horseshoe','hand','arm'], +}; + +function TinctureSwatch({ value, onChange, size = 28 }) { + return ( +
+ {TINCTURE_KEYS.map((t) => ( +
+ ); +} + +export function ArthurianStudio() { + const [blazon, setBlazon] = useState({ field: 'azure', charges: [{ device: 'crown', tincture: 'or', count: 3, arrangement: 'in pale' }] }); + + const fieldMode = typeof blazon.field === 'string' ? 'plain' + : blazon.field?.division ? 'division' + : blazon.field?.pattern ? 'pattern' + : 'plain'; + const fieldT1 = typeof blazon.field === 'string' ? blazon.field : (blazon.field?.tinctures?.[0] || 'azure'); + const fieldT2 = typeof blazon.field === 'object' ? (blazon.field?.tinctures?.[1] || 'or') : 'or'; + const fieldDivision = blazon.field?.division || 'per pale'; + const fieldPattern = blazon.field?.pattern || 'chequy'; + + const setField = (next) => setBlazon((b) => ({ ...b, field: next })); + const setOrdinary = (next) => setBlazon((b) => ({ ...b, ordinary: next })); + const setCharges = (next) => setBlazon((b) => ({ ...b, charges: next })); + + const switchFieldMode = (mode) => { + if (mode === 'plain') setField(fieldT1); + if (mode === 'division') setField({ division: fieldDivision, tinctures: [fieldT1, fieldT2] }); + if (mode === 'pattern') setField({ pattern: fieldPattern, tinctures: [fieldT1, fieldT2] }); + }; + + const charges = blazon.charges || []; + const addCharge = () => setCharges([...charges, { device: 'lion', tincture: 'or', count: 1 }]); + const updateCharge = (i, patch) => setCharges(charges.map((c, j) => j === i ? { ...c, ...patch } : c)); + const removeCharge = (i) => setCharges(charges.filter((_, j) => j !== i)); + + return ( +
+ + ); +} diff --git a/timeline-scratch/src/Arthuriana/characters.js b/timeline-scratch/src/Arthuriana/characters.js new file mode 100644 index 0000000..25f6380 --- /dev/null +++ b/timeline-scratch/src/Arthuriana/characters.js @@ -0,0 +1,832 @@ +// characters.js — Arthurian character database. +// +// Each entry has: +// id, name, title, faction, group ('round-table' | 'pendragon' | 'orkney' | 'cornwall' | 'enchanter' | 'foe' | 'lady' | 'other') +// blazon (heraldry blazon object — see ArthurianHeraldry.jsx) +// relations: [{ to: id, type: 'family'|'marriage'|'mentor'|'fellowship'|'rival'|'quest' }] +// sources: [{ source: 'Malory', text: '...' }] — one entry per source they appear in +// +// Heraldic attributions follow traditional armorials where they exist (Le Morte d'Arthur, +// 13th-c. Continental armorials, Sir Gawain & the Green Knight, etc.) and are invented +// in the same idiom where they don't. + +export const CHARACTERS = [ + // ─── PENDRAGON LINE ────────────────────────────────────────────────────── + { + id: 'arthur', name: 'Arthur Pendragon', title: 'High King of the Britons', group: 'pendragon', + blazon: { field: 'azure', charges: [{ device: 'crown', tincture: 'or', count: 3, arrangement: 'in pale' }] }, + relations: [ + { to: 'uther', type: 'family' }, { to: 'igraine', type: 'family' }, + { to: 'guinevere', type: 'marriage' }, { to: 'morgan', type: 'family' }, + { to: 'morgause', type: 'family' }, { to: 'mordred', type: 'family' }, + { to: 'kay', type: 'family' }, { to: 'ector', type: 'family' }, + { to: 'merlin', type: 'mentor' }, { to: 'lancelot', type: 'fellowship' }, + { to: 'gawain', type: 'fellowship' }, { to: 'galahad', type: 'fellowship' }, + { to: 'percival', type: 'fellowship' }, { to: 'bedivere', type: 'fellowship' }, + { to: 'tristan', type: 'fellowship' }, { to: 'bors', type: 'fellowship' }, + { to: 'lamorak', type: 'fellowship' }, { to: 'pellinore', type: 'fellowship' }, + { to: 'palamedes', type: 'fellowship' }, { to: 'gareth', type: 'fellowship' }, + { to: 'gaheris', type: 'fellowship' }, { to: 'agravain', type: 'fellowship' }, + { to: 'yvain', type: 'fellowship' }, { to: 'geraint', type: 'fellowship' }, + { to: 'lady-lake', type: 'mentor' }, { to: 'mark', type: 'rival' }, + { to: 'accolon', type: 'rival' }, { to: 'meleagant', type: 'rival' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'In the Historia Regum Britanniae (c.1136), Arthur is first cast as a continental war-king: he subdues the Saxons at Mount Badon, crosses the sea to conquer Ireland, Iceland, Norway, and Gaul, and is on the very edge of Rome when Mordred\'s betrayal calls him home. Geoffrey\'s Arthur is more emperor than mystic — there is no Round Table, no Grail, and Guinevere flees to a nunnery without much fuss.' }, + { source: 'Wace', text: 'Wace\'s Roman de Brut (1155) translates Geoffrey into Norman French octosyllabics and adds, almost in passing, the Round Table itself — invented so that none of Arthur\'s lords might claim precedence over another. The piece of furniture eats the legend whole.' }, + { source: 'Chrétien de Troyes', text: 'In the late-12th-century romances Arthur recedes: he is the still center, the courteous host at Camelot or Caerleon from which younger, hotter knights ride out on individual aventures. He is rarely the protagonist of his own story anymore.' }, + { source: 'Vulgate Cycle', text: 'The 13th-century Vulgate makes Arthur the son of an adulterous union, blesses him with the Grail company, and damns him through his own incestuous get on Morgause. He is a tragic Christian king whose Round Table is doomed from the night Mordred is conceived.' }, + { source: 'Malory', text: 'Malory\'s Le Morte d\'Arthur (1485) fuses all of this into the canonical English shape: sword in stone, marriage to Guinevere, the long fellowship, the Grail Quest that hollows out the company, the affair of Lancelot, and the final field at Camlann where father and son end each other. He is given to the three queens and borne to Avalon.' }, + { source: 'Spenser', text: 'In The Faerie Queene (1590) Prince Arthur is a young, pre-coronation knight in search of Gloriana, the Faerie Queene — a chivalric ideal pulled out of legend to flatter Elizabeth I. He is allegory more than man.' }, + { source: 'Tennyson', text: 'The Idylls of the King (1859–85) cast him as a moral idea wronged by every body he loves: blameless, faintly cold, the still center of a court that corrodes around him.' }, + { source: 'Mark Twain', text: 'A Connecticut Yankee in King Arthur\'s Court (1889) presents him as a credulous, decent provincial — proud, capable of nobility, but a king of an age the Yankee finds barbaric. Twain plainly likes him and plainly disdains his world.' }, + { source: 'T.H. White', text: 'The Once and Future King (1938–58) gives us the most human Arthur in English: Wart, the small boy taught by Merlyn to think like a fish, a bird, an ant. He grows into a king who genuinely believes Might must serve Right, and who cannot bring himself to hate Lancelot or Guinevere even when he must.' }, + { source: 'Tolkien', text: 'The Fall of Arthur (composed in the 1930s, published 2013) is an unfinished alliterative poem in Old English meter. Tolkien\'s Arthur is in Saxony fighting the heathen when Mordred\'s treason recalls him; the verse leaves him at sea, the crossing to Avalon never written.' }, + ], + }, + { + id: 'uther', name: 'Uther Pendragon', title: 'King of Britain', group: 'pendragon', + blazon: { field: 'or', charges: [{ device: 'dragon', tincture: 'gules', count: 2, arrangement: 'in pale' }] }, + relations: [ + { to: 'arthur', type: 'family' }, { to: 'igraine', type: 'marriage' }, + { to: 'gorlois', type: 'rival' }, { to: 'merlin', type: 'fellowship' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'Uther is the youngest of three royal brothers (Constans, Aurelius Ambrosius, Uther). He takes the crown after Aurelius is poisoned, fights Saxons, and — at a Christmas feast in London — falls so violently in love with Gorlois\'s wife Igraine that Merlin must transform him to gain her bed at Tintagel. Arthur is conceived that night; Gorlois is killed in the same hour.' }, + { source: 'Wace', text: 'Wace follows Geoffrey closely but lingers on the sorcery: it is Merlin\'s herbs and craft that change Uther, not divine providence. The deception is more clearly a sin.' }, + { source: 'Vulgate Cycle', text: 'The Vulgate hardens Uther into a flawed but legitimate Christian king whose lust nonetheless engenders a sacred line. His death is dignified; his ghost is not.' }, + { source: 'Malory', text: 'Malory opens with him: "It befell in the days of Uther Pendragon..." He dies within the first chapters, leaving the infant Arthur to be hidden by Merlin and raised by Ector.' }, + { source: 'T.H. White', text: 'White hardly bothers with Uther — the story has already moved on by the time Wart pulls the sword. He is mentioned mostly as a fact, the way one might mention a grandfather one never met.' }, + ], + }, + { + id: 'igraine', name: 'Igraine', title: 'Duchess of Cornwall, Queen of Britain', group: 'lady', + blazon: { field: { division: 'per pale', tinctures: ['azure', 'sable'] }, charges: [{ device: 'crescent', tincture: 'argent' }] }, + relations: [ + { to: 'arthur', type: 'family' }, { to: 'uther', type: 'marriage' }, + { to: 'gorlois', type: 'marriage' }, { to: 'morgan', type: 'family' }, + { to: 'morgause', type: 'family' }, { to: 'elaine-garlot', type: 'family' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'Geoffrey introduces her as the most beautiful woman in Britain. She is the unwitting instrument of Arthur\'s begetting and survives Uther; Geoffrey grants her no further scenes.' }, + { source: 'Malory', text: 'Malory gives her real grief and real bewilderment: when she sees the man who comes to her in Gorlois\'s shape, then learns hours later that Gorlois was already dead, she keeps the secret for years out of fear of being thought mad.' }, + { source: 'Vulgate Cycle', text: 'The Vulgate dwells on her three daughters by Gorlois (Morgan, Morgause, Elaine of Garlot) — making her the matriarch of an entire enchantress line that will haunt her son.' }, + ], + }, + { + id: 'gorlois', name: 'Gorlois', title: 'Duke of Cornwall', group: 'cornwall', + blazon: { field: 'sable', charges: [{ device: 'boar', tincture: 'argent' }] }, + relations: [ + { to: 'igraine', type: 'marriage' }, { to: 'uther', type: 'rival' }, + { to: 'morgan', type: 'family' }, { to: 'morgause', type: 'family' }, { to: 'elaine-garlot', type: 'family' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'A loyal duke who pulls his wife out of Uther\'s court to save her from the king. He is killed sallying from a besieged castle in the very hour Uther, disguised, lies with his wife.' }, + { source: 'Malory', text: 'Malory keeps the tragedy crisp: Gorlois rides out at midnight; Merlin\'s glamour falls on Uther in the same moment; both men cannot be Igraine\'s husband and yet, for one night, both are.' }, + ], + }, + { + id: 'ector', name: 'Sir Ector', title: 'Foster-father of Arthur', group: 'other', + blazon: { field: { division: 'per fess', tinctures: ['argent', 'azure'] }, charges: [{ device: 'oak', tincture: 'vert' }] }, + relations: [{ to: 'arthur', type: 'family' }, { to: 'kay', type: 'family' }, { to: 'merlin', type: 'fellowship' }], + sources: [ + { source: 'Malory', text: 'A good country knight to whom Merlin commits the infant Arthur. He raises Arthur as a younger brother to his own son Kay. When the boy pulls the sword, Ector kneels at once, calls him liege, and weeps. Malory uses him to anchor the whole story in honest, undramatic love.' }, + { source: 'T.H. White', text: 'In White he is Sir Ector of the Forest Sauvage — a bluff, gout-prone English squire who calls Arthur "Wart," frets about taxes, and never quite gets over the fact that the boy he raised is the king.' }, + ], + }, + { + id: 'kay', name: 'Sir Kay', title: 'Seneschal of Britain', group: 'round-table', + blazon: { field: 'argent', charges: [{ device: 'key', tincture: 'sable', count: 2, arrangement: 'in saltire' }] }, + relations: [{ to: 'arthur', type: 'family' }, { to: 'ector', type: 'family' }, { to: 'bedivere', type: 'fellowship' }], + sources: [ + { source: 'Mabinogion', text: 'In Culhwch and Olwen, Cai is a giant-tall hero who can hold his breath nine nights, never sleep, and grow as tall as the tallest tree. He is one of the first warriors named at Arthur\'s court — older than Camelot.' }, + { source: 'Chrétien de Troyes', text: 'Chrétien turns him into a comic boor: sharp-tongued, jealous, the seneschal you don\'t invite. The Welsh hero has become a snob in a hall.' }, + { source: 'Malory', text: 'Malory keeps Chrétien\'s edge but softens it with brotherhood: Kay is rude, brave, often wrong, and Arthur loves him anyway because Kay\'s mother nursed Arthur as a baby. He dies fighting in Gaul, far from home.' }, + { source: 'T.H. White', text: 'White\'s Kay is a teenager — sulky, awkward, jealous of his foster brother\'s strange ease — exactly the older sibling who has been told he is not the chosen one.' }, + ], + }, + { + id: 'merlin', name: 'Merlin', title: 'Prophet, Enchanter', group: 'enchanter', + blazon: { field: 'azure', charges: [{ device: 'mullet', tincture: 'or', count: 3, arrangement: '2-1' }] }, + relations: [ + { to: 'arthur', type: 'mentor' }, { to: 'uther', type: 'fellowship' }, + { to: 'nimue', type: 'rival' }, { to: 'lady-lake', type: 'fellowship' }, + { to: 'morgan', type: 'rival' }, { to: 'vortigern', type: 'rival' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'Geoffrey welds two earlier figures — the Welsh bard Myrddin Wyllt and the fatherless prophet boy Ambrosius — into a single Merlinus Ambrosius. He prophesies the red and white dragons under Vortigern\'s tower, moves Stonehenge from Ireland, and engineers Arthur\'s conception. Then he vanishes from the Historia.' }, + { source: 'Vulgate Cycle', text: 'The Vulgate\'s Estoire de Merlin gives him a demon father (so he is born to know all things past) and a baptismal mother (so he is born to know all things future). His fall is in love: he teaches Nimue every craft until she uses the last spell to seal him in a tower of air.' }, + { source: 'Malory', text: 'Malory keeps the Vulgate end: Nimue ("Nyneve") wearies of his desire, learns his magic, and traps him under a stone in Cornwall. He warns Arthur of his own coming end and goes to it anyway.' }, + { source: 'Spenser', text: 'In The Faerie Queene Merlin is alive in a cavern beneath the earth, still shaping prophecy for Britomart. He is more force of nature than wizard.' }, + { source: 'Tennyson', text: '"Merlin and Vivien" is one of the cruelest Idylls: a brilliant old man worn down by a young woman\'s flattery until he tells her the binding charm, and is sealed in an oak forever.' }, + { source: 'T.H. White', text: 'White\'s Merlyn lives backwards in time, which is why he is so often confused and so often right. He teaches Wart to be a fish, a hawk, an ant, a badger — and then disappears, having done what he could.' }, + ], + }, + + // ─── ORKNEY CLAN ───────────────────────────────────────────────────────── + { + id: 'morgause', name: 'Morgause', title: 'Queen of Orkney', group: 'orkney', + blazon: { field: 'sable', charges: [{ device: 'crescent', tincture: 'argent', count: 3, arrangement: '2-1' }] }, + relations: [ + { to: 'lot', type: 'marriage' }, { to: 'arthur', type: 'family' }, + { to: 'mordred', type: 'family' }, { to: 'gawain', type: 'family' }, + { to: 'gaheris', type: 'family' }, { to: 'gareth', type: 'family' }, + { to: 'agravain', type: 'family' }, { to: 'igraine', type: 'family' }, + { to: 'morgan', type: 'family' }, { to: 'lamorak', type: 'marriage' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'The Vulgate is where the incest enters: Morgause comes to Arthur\'s court not knowing he is her brother, sleeps with him, and goes home pregnant with Mordred. The doom of the Round Table is conceived in one night.' }, + { source: 'Malory', text: 'Malory keeps the incest and adds an extraordinary later scene: her son Gaheris finds her in bed with Lamorak (decades younger) and strikes off her head, leaving Lamorak alive to be hunted down later by his brothers.' }, + { source: 'T.H. White', text: 'White makes her a witch-queen of Lothian, scheming, neglectful, occasionally tender — the engine of the Orkney faction\'s rage. The boys love her too much and badly.' }, + ], + }, + { + id: 'lot', name: 'King Lot of Orkney', title: 'King of Lothian and Orkney', group: 'orkney', + blazon: { field: { division: 'per pale', tinctures: ['sable', 'argent'] } }, + relations: [ + { to: 'morgause', type: 'marriage' }, { to: 'gawain', type: 'family' }, + { to: 'gaheris', type: 'family' }, { to: 'gareth', type: 'family' }, + { to: 'agravain', type: 'family' }, { to: 'arthur', type: 'rival' }, + { to: 'pellinore', type: 'rival' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'A loyal lord of Lothian under Arthur, given Orkney as part of Arthur\'s northern reorganization.' }, + { source: 'Malory', text: 'Malory makes him one of the eleven rebel kings who refuse the boy Arthur. He is killed in battle by King Pellinore — a death his sons (the Orkney brothers) will not forgive, no matter how many years pass.' }, + ], + }, + { + id: 'gawain', name: 'Sir Gawain', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'purpure', charges: [{ device: 'pentangle', tincture: 'or' }] }, + relations: [ + { to: 'arthur', type: 'family' }, { to: 'morgause', type: 'family' }, { to: 'lot', type: 'family' }, + { to: 'gaheris', type: 'family' }, { to: 'gareth', type: 'family' }, { to: 'agravain', type: 'family' }, + { to: 'mordred', type: 'family' }, { to: 'lancelot', type: 'fellowship' }, + { to: 'green-knight', type: 'rival' }, { to: 'lamorak', type: 'rival' }, + { to: 'pellinore', type: 'rival' }, { to: 'percival', type: 'fellowship' }, + { to: 'galahad', type: 'fellowship' }, { to: 'yvain', type: 'fellowship' }, + ], + sources: [ + { source: 'Mabinogion', text: 'As Gwalchmei ("hawk of May") he is one of the very first Arthurian heroes named — a sun-figure whose strength waxes till noon. The Welsh root of all later Gawains.' }, + { source: 'Chrétien de Troyes', text: 'Chrétien\'s Gauvain is the model courtier: handsome, courteous, second only to the protagonist of whatever romance he\'s in. He is rarely the protagonist himself, but he is always there.' }, + { source: 'Sir Gawain and the Green Knight', text: 'The Pearl Poet (c.1400) gives him the most beautiful single test in English literature: a year and a day to ride to a green chapel and offer his neck to a green axeman. He carries a shield with a pentangle on a purple field — a "endeles knot" of five fives — and he fails by a sash. He survives, and learns, and is ashamed.' }, + { source: 'Malory', text: 'Malory keeps the courtly polish but adds the family curse: Gawain is the one who, after Lancelot accidentally kills Gareth in the rescue of Guinevere, hounds Lancelot across the channel until his own wound (taken from Lancelot before, and re-opened on landing) kills him. He dies asking Arthur\'s forgiveness for his hatred.' }, + { source: 'T.H. White', text: 'White paints him with a Scots accent and a temper — capable of nobility, fiercely loyal to his brothers, the one who can never quite let a grudge go.' }, + ], + }, + { + id: 'gareth', name: 'Sir Gareth Beaumains', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'vert', charges: [{ device: 'fleur-de-lis', tincture: 'or', count: 3, arrangement: 'in pale' }] }, + relations: [ + { to: 'morgause', type: 'family' }, { to: 'lot', type: 'family' }, { to: 'arthur', type: 'family' }, + { to: 'gawain', type: 'family' }, { to: 'gaheris', type: 'family' }, { to: 'agravain', type: 'family' }, + { to: 'mordred', type: 'family' }, { to: 'lancelot', type: 'fellowship' }, + { to: 'lyonesse', type: 'marriage' }, { to: 'ironside', type: 'rival' }, + ], + sources: [ + { source: 'Malory', text: 'The youngest Orkney brother arrives at Camelot in disguise and asks only food and a year in the kitchens. Kay nicknames him "Beaumains" — beautiful hands — for mockery. He earns his spurs by escorting the proud lady Lynette to rescue her sister Lyonesse from the Red Knight of the Red Lands. He becomes the kindest man at the table. Lancelot, fighting unarmed in the rescue of Guinevere, kills him by mistake. It is the wound the fellowship never recovers from.' }, + { source: 'T.H. White', text: 'White makes Gareth the moral center of the Orkney boys — the brother who refuses to hate Lancelot even as Agravain plots, and who dies precisely because of his refusal.' }, + ], + }, + { + id: 'gaheris', name: 'Sir Gaheris', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'sable', charges: [{ device: 'mullet', tincture: 'argent', count: 3, arrangement: 'in chief' }] }, + relations: [ + { to: 'morgause', type: 'family' }, { to: 'lot', type: 'family' }, { to: 'arthur', type: 'family' }, + { to: 'gawain', type: 'family' }, { to: 'gareth', type: 'family' }, { to: 'agravain', type: 'family' }, + { to: 'mordred', type: 'family' }, { to: 'lamorak', type: 'rival' }, + ], + sources: [ + { source: 'Malory', text: 'The brother who finds his mother Morgause in bed with Lamorak and beheads her on the spot, sparing the knight only so the family can hunt him down later. He is killed beside Gareth in the same disaster — Lancelot, unarmed and trying to save Guinevere, cuts him down without seeing him.' }, + ], + }, + { + id: 'agravain', name: 'Sir Agravain', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'sable', charges: [{ device: 'arrow', tincture: 'argent' }] }, + relations: [ + { to: 'morgause', type: 'family' }, { to: 'lot', type: 'family' }, { to: 'arthur', type: 'family' }, + { to: 'gawain', type: 'family' }, { to: 'gareth', type: 'family' }, { to: 'gaheris', type: 'family' }, + { to: 'mordred', type: 'family' }, { to: 'lancelot', type: 'rival' }, + { to: 'lamorak', type: 'rival' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'A jealous, sharp-tongued knight, more politician than warrior. He is the one who first puts into the air the open accusation that Lancelot is sleeping with the queen.' }, + { source: 'Malory', text: 'Agravain and Mordred together force the issue: they catch Lancelot in the queen\'s chamber and trigger the war. Lancelot kills Agravain on the spot. Every later death — Gareth, Gaheris, Gawain, Arthur — traces back to that night and that ambush.' }, + ], + }, + { + id: 'mordred', name: 'Sir Mordred', title: 'Knight of the Round Table, Usurper', group: 'foe', + blazon: { field: { division: 'per saltire', tinctures: ['sable', 'argent'] } }, + relations: [ + { to: 'arthur', type: 'family' }, { to: 'morgause', type: 'family' }, + { to: 'gawain', type: 'family' }, { to: 'gareth', type: 'family' }, + { to: 'gaheris', type: 'family' }, { to: 'agravain', type: 'family' }, + { to: 'agravain', type: 'fellowship' }, { to: 'guinevere', type: 'rival' }, + { to: 'lancelot', type: 'rival' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'In Geoffrey he is Arthur\'s nephew, not yet his son — left as regent and seducer of Guinevere while Arthur is in Gaul. He crowns himself king, and the two armies meet on the Camlann.' }, + { source: 'Vulgate Cycle', text: 'The Vulgate makes him the son of Arthur and Morgause — incest as the original sin of the realm. Every line of the cycle bends toward that final field.' }, + { source: 'Malory', text: 'Malory keeps the Vulgate parentage. Mordred is born for the destruction of his father\'s reign; Arthur tries to drown him as an infant, fails, and the boy survives to fulfill the prophecy exactly.' }, + { source: 'T.H. White', text: 'White\'s Mordred is a thin pale boy with a club foot, sick with his mother\'s teaching — a study in how a child raised on grievance becomes a terrorist of his father\'s house.' }, + ], + }, + + // ─── ROUND TABLE — GREATS ──────────────────────────────────────────────── + { + id: 'lancelot', name: 'Sir Lancelot du Lac', title: 'First Knight of the Round Table', group: 'round-table', + blazon: { field: 'argent', ordinary: { type: 'bend', tincture: 'gules' }, charges: [{ device: 'bend', tincture: 'gules' }] }, + relations: [ + { to: 'arthur', type: 'fellowship' }, { to: 'guinevere', type: 'marriage' }, + { to: 'elaine-corbenic', type: 'marriage' }, { to: 'galahad', type: 'family' }, + { to: 'lady-lake', type: 'family' }, { to: 'bors', type: 'family' }, + { to: 'lionel', type: 'family' }, { to: 'ector-marais', type: 'family' }, + { to: 'gawain', type: 'rival' }, { to: 'mordred', type: 'rival' }, + { to: 'agravain', type: 'rival' }, { to: 'meleagant', type: 'rival' }, + { to: 'elaine-astolat', type: 'rival' }, { to: 'percival', type: 'fellowship' }, + ], + sources: [ + { source: 'Chrétien de Troyes', text: 'Lancelot enters European literature in Le Chevalier de la Charrette (c.1180), Chrétien\'s romance of the queen\'s rescue. He is so consumed by love of Guinevere that he hesitates two steps before stepping into the shameful cart that will carry him to her — and Chrétien spends a hundred lines on those two steps.' }, + { source: 'Vulgate Cycle', text: 'The Vulgate makes him the whole structure: raised by the Lady of the Lake, the greatest knight, the queen\'s lover, the one barred from the Grail because of her, and the father, by deception with Elaine of Corbenic, of the knight who will succeed where he cannot.' }, + { source: 'Malory', text: 'Malory\'s Lancelot is both the best and the worst — the knight who can break Camelot by being in it. He survives Arthur. He goes to a hermitage with Bors, dies in odor of sanctity, and Ector (his brother) gives one of the great eulogies in English: thou wert the truest lover of a sinful man that ever loved woman.' }, + { source: 'Tennyson', text: 'Tennyson\'s Lancelot is the broken honorable man — wracked, eloquent, doomed by a single love.' }, + { source: 'T.H. White', text: 'White\'s Lancelot is the "Ill-Made Knight" — physically ugly, ferociously gifted, certain he is damned, in love with Arthur and with Guinevere in ways that don\'t resolve.' }, + ], + }, + { + id: 'guinevere', name: 'Queen Guinevere', title: 'Queen of Britain', group: 'lady', + blazon: { field: 'gules', charges: [{ device: 'crown', tincture: 'or' }, { device: 'rose', tincture: 'argent', count: 2, arrangement: 'in chief' }] }, + relations: [ + { to: 'arthur', type: 'marriage' }, { to: 'lancelot', type: 'marriage' }, + { to: 'mordred', type: 'rival' }, { to: 'meleagant', type: 'rival' }, + { to: 'morgan', type: 'rival' }, { to: 'lady-lake', type: 'fellowship' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'Geoffrey\'s Guanhumara is Roman-Briton noble; she takes Mordred as consort during Arthur\'s absence, then flees to a Caerleon nunnery when he returns. Geoffrey gives her no inner life.' }, + { source: 'Chrétien de Troyes', text: 'Chrétien is the first to make her an active object of courtly love — Lancelot risks his soul, his honor, and a series of obstacles to reach her bed. She is sharp-tongued and unafraid of giving orders.' }, + { source: 'Malory', text: 'Malory\'s queen is generous and jealous in equal measure, repeatedly endangering Lancelot through fits of suspicion that turn out to be true. At the end she takes the veil at Amesbury and refuses to see Lancelot one last time, "for as much as I am the cause of all this war." She dies abbess.' }, + { source: 'Tennyson', text: '"Guinevere" — perhaps the bitterest of the Idylls — has her prostrate before Arthur at Almesbury while he forgives her in such terms as to ruin them both.' }, + { source: 'T.H. White', text: 'White is unusually fair to her: he refuses the Victorian habit of blaming the queen for the kingdom\'s fall, and gives her real intelligence, real boredom, real love.' }, + ], + }, + { + id: 'galahad', name: 'Sir Galahad', title: 'Knight of the Round Table, Grail Knight', group: 'round-table', + blazon: { field: 'argent', ordinary: { type: 'cross', tincture: 'gules' } }, + relations: [ + { to: 'lancelot', type: 'family' }, { to: 'elaine-corbenic', type: 'family' }, + { to: 'pelles', type: 'family' }, { to: 'percival', type: 'quest' }, + { to: 'bors', type: 'quest' }, { to: 'arthur', type: 'fellowship' }, + { to: 'dindrane', type: 'quest' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'The Queste del Saint Graal (c.1225) introduces Galahad as the prophesied Good Knight — conceived when Elaine of Corbenic, enchanted to look like Guinevere, lies with Lancelot. He sits in the Siege Perilous, achieves the Grail at Sarras, asks God to let him die, and is taken up. He is the Grail Quest\'s only complete success.' }, + { source: 'Malory', text: 'Malory keeps every beat of the Queste but makes Galahad more bearable by sheer plainness. He arrives in red armor, breaks the swords of others by being more worthy, and dies in Sarras with the Grail in his eye.' }, + { source: 'Tennyson', text: '"Sir Galahad" is Tennyson\'s most-quoted Idyll — the perfect virginal knight whose "strength is as the strength of ten."' }, + ], + }, + { + id: 'percival', name: 'Sir Percival', title: 'Knight of the Round Table, Grail Knight', group: 'round-table', + blazon: { field: 'vert', ordinary: { type: 'cross', tincture: 'or' } }, + relations: [ + { to: 'pellinore', type: 'family' }, { to: 'lamorak', type: 'family' }, + { to: 'dindrane', type: 'family' }, { to: 'galahad', type: 'quest' }, + { to: 'bors', type: 'quest' }, { to: 'arthur', type: 'fellowship' }, + { to: 'gawain', type: 'fellowship' }, { to: 'blanchefleur', type: 'marriage' }, + ], + sources: [ + { source: 'Chrétien de Troyes', text: 'Perceval, ou Le Conte du Graal (c.1190) gives the world the Grail — but only at a feast in a wounded king\'s hall, where Perceval, raised in the forest and told not to ask too many questions, watches the procession in silence and so fails. Chrétien died with the romance unfinished.' }, + { source: 'Wolfram von Eschenbach', text: 'Parzival (c.1210) is the German answer: a vast romance in which Parzival redeems his failure, learns compassion, becomes Grail King. Wagner read it.' }, + { source: 'Mabinogion', text: 'In the Welsh Peredur the Grail is a head on a platter; the romance reads older and stranger, closer to the otherworld.' }, + { source: 'Vulgate Cycle', text: 'The Vulgate makes him secondary to Galahad — a worthy Grail companion, but not the chosen knight. He dies a hermit.' }, + { source: 'Malory', text: 'Malory follows the Vulgate but keeps Percival\'s rustic, watchful innocence intact. He is one of the three to reach Sarras, and the only one of the three to come back, briefly, to tell Camelot what they saw.' }, + ], + }, + { + id: 'bors', name: 'Sir Bors de Ganis', title: 'Knight of the Round Table, Grail Knight', group: 'round-table', + blazon: { field: 'gules', charges: [{ device: 'lion', tincture: 'or', attitude: 'rampant', count: 3, arrangement: '2-1' }] }, + relations: [ + { to: 'lancelot', type: 'family' }, { to: 'lionel', type: 'family' }, + { to: 'ector-marais', type: 'family' }, { to: 'galahad', type: 'quest' }, + { to: 'percival', type: 'quest' }, { to: 'arthur', type: 'fellowship' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'Bors is the third Grail companion and the only one to return to Camelot alive. He is the steady moral keel — the knight who will refuse to lie even when his cousin Lancelot needs him to. His Quest features an agonizing choice: rescue his brother Lionel from torture, or save a maiden from rape. He chooses the maiden. Lionel survives, and almost kills him for it.' }, + { source: 'Malory', text: 'Malory loves Bors. He is the witness — the one who sees, judges, and goes on. He carries Lancelot\'s ashes into a hermitage after the king and queen are dead.' }, + ], + }, + { + id: 'lionel', name: 'Sir Lionel', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'azure', charges: [{ device: 'lion', tincture: 'or', attitude: 'rampant' }] }, + relations: [ + { to: 'bors', type: 'family' }, { to: 'lancelot', type: 'family' }, + { to: 'ector-marais', type: 'family' }, { to: 'arthur', type: 'fellowship' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'Bors\'s brother. Captured by enemy knights in the Quest and tortured nearly to death. When Bors rides past to save a maiden instead of him, Lionel survives in such a rage that he tries to kill his brother in single combat.' }, + { source: 'Malory', text: 'Malory keeps the fratricidal moment and makes it terrifying — a hermit and a knight throw themselves between the brothers and are killed for it. The two are reconciled only by miracle.' }, + ], + }, + { + id: 'ector-marais', name: 'Sir Ector de Maris', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'argent', ordinary: { type: 'bend', tincture: 'gules' }, charges: [{ device: 'mullet', tincture: 'gules', count: 3, arrangement: 'in bend' }] }, + relations: [ + { to: 'lancelot', type: 'family' }, { to: 'bors', type: 'family' }, + { to: 'lionel', type: 'family' }, { to: 'arthur', type: 'fellowship' }, + ], + sources: [ + { source: 'Malory', text: 'Not to be confused with old Sir Ector who raised Arthur. This Ector is Lancelot\'s half-brother — also of the Lake. He gives the great eulogy at Lancelot\'s death, the longest sustained piece of lyrical prose in Malory: "Thou wert never matched of earthly knight\'s hand…"' }, + ], + }, + { + id: 'bedivere', name: 'Sir Bedivere', title: 'Knight of the Round Table, Marshal of Britain', group: 'round-table', + blazon: { field: 'gules', charges: [{ device: 'chalice', tincture: 'or' }] }, + relations: [{ to: 'arthur', type: 'fellowship' }, { to: 'kay', type: 'fellowship' }, { to: 'lucan', type: 'family' }], + sources: [ + { source: 'Mabinogion', text: 'As Bedwyr he is the oldest companion — paired with Cai (Kay), the two earliest names at Arthur\'s side in the Welsh tradition.' }, + { source: 'Geoffrey of Monmouth', text: 'A high officer in Arthur\'s continental campaigns. Killed in Gaul fighting Romans.' }, + { source: 'Malory', text: 'Malory keeps him alive for the very end. After Camlann, Arthur asks him three times to throw Excalibur back into the lake. Bedivere lies twice — the sword is too beautiful to lose — and only on the third asking does he obey. He is the last man at Arthur\'s side as the barge of queens carries the king away.' }, + { source: 'Tennyson', text: '"The Passing of Arthur" gives Bedivere two of the most famous lines in nineteenth-century English: he describes Excalibur being caught by an arm clothed in white samite.' }, + ], + }, + { + id: 'tristan', name: 'Sir Tristan', title: 'Knight of the Round Table, of Lyonesse', group: 'round-table', + blazon: { field: 'vert', charges: [{ device: 'lion', tincture: 'or', attitude: 'rampant' }] }, + relations: [ + { to: 'isolde-ireland', type: 'marriage' }, { to: 'isolde-hands', type: 'marriage' }, + { to: 'mark', type: 'family' }, { to: 'mark', type: 'rival' }, + { to: 'arthur', type: 'fellowship' }, { to: 'lancelot', type: 'fellowship' }, + { to: 'palamedes', type: 'rival' }, { to: 'morholt', type: 'rival' }, + ], + sources: [ + { source: 'Other early sources', text: 'The Tristan story is older than Arthurian romance — Welsh Drystan, Pictish, Cornish. It enters French romance in the 12th century via Béroul, Thomas of Britain, and the verse fragments survive only as scraps.' }, + { source: 'Vulgate Cycle', text: 'The Prose Tristan (c.1230) folds him fully into the Arthurian world, making him a member of the Round Table and a foil to Lancelot — two adulterous knights, two doomed loves.' }, + { source: 'Malory', text: 'Malory\'s Book of Sir Tristram is the longest single book in the Morte. He drinks the love potion meant for King Mark and Isolde on the ship from Ireland and never recovers. He dies, in Malory, of treachery — Mark stabs him in the back while he is harping for Isolde.' }, + { source: 'Tennyson', text: '"The Last Tournament" gives him a sardonic late hour: he is no longer the romantic hero but a man who has outlived his own story.' }, + ], + }, + { + id: 'lamorak', name: 'Sir Lamorak de Galis', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'sable', charges: [{ device: 'fess', tincture: 'or' }, { device: 'mullet', tincture: 'or', count: 3, arrangement: 'in chief' }] }, + relations: [ + { to: 'pellinore', type: 'family' }, { to: 'percival', type: 'family' }, + { to: 'dindrane', type: 'family' }, { to: 'morgause', type: 'marriage' }, + { to: 'gawain', type: 'rival' }, { to: 'gaheris', type: 'rival' }, + { to: 'agravain', type: 'rival' }, { to: 'mordred', type: 'rival' }, + { to: 'arthur', type: 'fellowship' }, + ], + sources: [ + { source: 'Malory', text: 'Reputedly the third-best knight of the Round Table, after Lancelot and Tristan. His father Pellinore killed King Lot of Orkney in battle long before, and the Orkney brothers never forgave it. Lamorak\'s affair with Morgause — Lot\'s widow — was the spark: Gaheris beheaded her in bed and the brothers ambushed and killed Lamorak weeks later, four against one.' }, + ], + }, + { + id: 'pellinore', name: 'King Pellinore', title: 'King of the Isles, Knight of the Round Table', group: 'round-table', + blazon: { field: { division: 'per pale', tinctures: ['or', 'vert'] }, charges: [{ device: 'serpent', tincture: 'sable' }] }, + relations: [ + { to: 'lamorak', type: 'family' }, { to: 'percival', type: 'family' }, + { to: 'dindrane', type: 'family' }, { to: 'lot', type: 'rival' }, + { to: 'arthur', type: 'fellowship' }, { to: 'gawain', type: 'rival' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'Pellinore hunts the Questing Beast — Glatisant — through forest after forest. The beast has the head of a serpent, body of a leopard, hindquarters of a lion, feet of a hart, and a noise in its belly as of thirty couple hounds. The hunt was a quest no one could finish; it passed from Pellinore to Palamedes.' }, + { source: 'Malory', text: 'Malory keeps the Beast and adds the political crime: Pellinore killed Lot of Orkney in the rebel-kings\' war. He is later killed in turn by Gawain — vengeance taking a generation.' }, + { source: 'T.H. White', text: 'White\'s Pellinore is comic and beloved — a befuddled, exhausted middle-aged king who pursues the Beast because he doesn\'t know what else to do, eventually falls in love with the Beast itself, and is rescued only when it cures his loneliness.' }, + ], + }, + { + id: 'palamedes', name: 'Sir Palamedes', title: 'Knight of the Round Table, the Saracen', group: 'round-table', + blazon: { field: { pattern: 'chequy', tinctures: ['or', 'sable'] } }, + relations: [ + { to: 'arthur', type: 'fellowship' }, { to: 'tristan', type: 'rival' }, + { to: 'isolde-ireland', type: 'rival' }, { to: 'lancelot', type: 'fellowship' }, + { to: 'safir', type: 'family' }, { to: 'segwarides', type: 'family' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'A great Saracen knight who comes to Logres unbaptized and falls hopelessly in love with Isolde of Ireland. The Prose Tristan made him Tristan\'s great rival in love. He inherits the hunt of the Questing Beast from Pellinore.' }, + { source: 'Malory', text: 'Malory loves him — gives him real interior life, real grief, and finally has him baptized by Galahad in the Quest of the Grail. He kills the Questing Beast at last.' }, + ], + }, + { + id: 'safir', name: 'Sir Safir', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: { pattern: 'chequy', tinctures: ['argent', 'azure'] } }, + relations: [{ to: 'palamedes', type: 'family' }, { to: 'segwarides', type: 'family' }, { to: 'arthur', type: 'fellowship' }], + sources: [ + { source: 'Malory', text: 'Palamedes\'s brother. A solid Saracen knight, also eventually baptized.' }, + ], + }, + { + id: 'segwarides', name: 'Sir Segwarides', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: { pattern: 'chequy', tinctures: ['gules', 'argent'] } }, + relations: [ + { to: 'palamedes', type: 'family' }, { to: 'safir', type: 'family' }, + { to: 'tristan', type: 'rival' }, { to: 'arthur', type: 'fellowship' }, + ], + sources: [ + { source: 'Malory', text: 'The third Saracen brother. Tristan once seduced his wife — a minor scandal in a book that has bigger ones.' }, + ], + }, + { + id: 'yvain', name: 'Sir Yvain', title: 'Knight of the Round Table, of the Lion', group: 'round-table', + blazon: { field: 'argent', charges: [{ device: 'lion', tincture: 'gules', attitude: 'rampant' }] }, + relations: [ + { to: 'arthur', type: 'fellowship' }, { to: 'gawain', type: 'fellowship' }, + { to: 'laudine', type: 'marriage' }, { to: 'morgan', type: 'family' }, + ], + sources: [ + { source: 'Chrétien de Troyes', text: 'Yvain, ou Le Chevalier au Lion (c.1180) is the strangest of Chrétien\'s romances: Yvain marries the widow of a knight he killed, forgets to come home, goes mad in the forest, and is restored by — among other things — a lion he saves from a serpent. The lion follows him for the rest of the romance.' }, + { source: 'Mabinogion', text: 'The Welsh Owain ap Urien is the historical kernel: a 6th-century king of Rheged. The romance retains a Welsh flavor of fountains and otherworlds.' }, + { source: 'Malory', text: 'Malory keeps him a member of the Round Table but tells little of his own story; he is mostly seen riding with Gawain.' }, + ], + }, + { + id: 'geraint', name: 'Sir Geraint / Erec', title: 'Knight of the Round Table, of Cornwall', group: 'round-table', + blazon: { field: 'or', charges: [{ device: 'lion', tincture: 'gules', attitude: 'rampant' }] }, + relations: [ + { to: 'arthur', type: 'fellowship' }, { to: 'enide', type: 'marriage' }, + { to: 'gawain', type: 'fellowship' }, { to: 'kay', type: 'rival' }, + ], + sources: [ + { source: 'Chrétien de Troyes', text: 'Erec et Enide (c.1170) is the earliest Chrétien romance and the first European novel of married love. Erec falls into uxoriousness, hears his wife weeping over how her love has ruined him, and forces them both on a long humiliating road to test her and himself.' }, + { source: 'Mabinogion', text: 'The Welsh Geraint son of Erbin is a Cornish lord, fully redrafting the romance into a Welsh idiom.' }, + { source: 'Tennyson', text: '"Geraint and Enid" pulls it into Victorian poetry — the same story made gentler and more sentimental.' }, + ], + }, + { + id: 'enide', name: 'Enide', title: 'Lady, Wife of Geraint', group: 'lady', + blazon: { field: 'argent', charges: [{ device: 'rose', tincture: 'gules', count: 3, arrangement: 'in pale' }] }, + relations: [{ to: 'geraint', type: 'marriage' }], + sources: [ + { source: 'Chrétien de Troyes', text: 'Enide\'s great test is forbidden speech: her husband orders her not to speak as they ride, and she must break the order every time she sees danger threaten him. The romance is the cumulation of those small disobediences into a real love.' }, + ], + }, + { + id: 'caradoc', name: 'Sir Caradoc', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: { division: 'per pale', tinctures: ['gules', 'vert'] }, charges: [{ device: 'tower', tincture: 'argent' }] }, + relations: [{ to: 'arthur', type: 'fellowship' }, { to: 'gawain', type: 'fellowship' }], + sources: [ + { source: 'Other early sources', text: 'The Caradoc romances make him the knight of the magic horn — a chastity test that no lady at Arthur\'s court but his own wife can pass. It is one of the funnier moments in early French Arthuriana.' }, + ], + }, + { + id: 'dindrane', name: 'Lady Dindrane', title: 'Sister of Percival', group: 'lady', + blazon: { field: 'argent', ordinary: { type: 'cross', tincture: 'sable' }, charges: [{ device: 'rose', tincture: 'gules' }] }, + relations: [ + { to: 'percival', type: 'family' }, { to: 'lamorak', type: 'family' }, + { to: 'pellinore', type: 'family' }, { to: 'galahad', type: 'quest' }, + { to: 'bors', type: 'quest' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'Percival\'s sister joins the three Grail companions on the ship of Solomon. They come to a castle where the lady of the place is dying of leukemia and only the blood of a virgin princess will cure her. Dindrane gives the blood — a silver bowl full — and dies. They lay her body on the ship and it sails to Sarras alone.' }, + { source: 'Malory', text: 'Malory tells it more briefly but keeps the central image: the silver bowl, the sister\'s consent, the empty boat. She is the only woman to "achieve" the Grail.' }, + ], + }, + { + id: 'blanchefleur', name: 'Blanchefleur', title: 'Lady, beloved of Percival', group: 'lady', + blazon: { field: 'azure', charges: [{ device: 'rose', tincture: 'argent' }] }, + relations: [{ to: 'percival', type: 'marriage' }], + sources: [ + { source: 'Chrétien de Troyes', text: 'The lady of Beaurepaire whose castle Perceval relieves in his first great deed. Their love is the first thing that begins to teach him what compassion is for.' }, + ], + }, + { + id: 'lyonesse', name: 'Lady Lyonesse', title: 'Lady of Castle Dangerous', group: 'lady', + blazon: { field: 'azure', charges: [{ device: 'sun', tincture: 'or' }] }, + relations: [{ to: 'gareth', type: 'marriage' }], + sources: [ + { source: 'Malory', text: 'The proud lady Gareth wins by beating the Red Knight of the Red Lands. Her sister Lynette mocks Gareth for half the romance before consenting to call him a knight.' }, + ], + }, + + // ─── CORNWALL / TRISTAN ────────────────────────────────────────────────── + { + id: 'mark', name: 'King Mark', title: 'King of Cornwall', group: 'cornwall', + blazon: { field: 'sable', charges: [{ device: 'lion', tincture: 'or', attitude: 'passant' }] }, + relations: [ + { to: 'tristan', type: 'family' }, { to: 'tristan', type: 'rival' }, + { to: 'isolde-ireland', type: 'marriage' }, { to: 'arthur', type: 'rival' }, + ], + sources: [ + { source: 'Other early sources', text: 'In Béroul\'s Tristan (c.1170) Mark is a complicated man — sometimes the cuckold, sometimes a king genuinely trying to be just, sometimes a tyrant. The earlier the source, the more sympathetic.' }, + { source: 'Malory', text: 'Malory turns him into a near-villain: cunning, petty, vindictive. He murders Tristan from behind while Tristan is playing the harp for Isolde. It is one of the few deaths in Malory presented as straightforwardly base.' }, + ], + }, + { + id: 'isolde-ireland', name: 'Isolde of Ireland', title: 'Queen of Cornwall', group: 'lady', + blazon: { field: 'vert', charges: [{ device: 'harp', tincture: 'or' }] }, + relations: [ + { to: 'tristan', type: 'marriage' }, { to: 'mark', type: 'marriage' }, + { to: 'morholt', type: 'family' }, { to: 'palamedes', type: 'rival' }, + ], + sources: [ + { source: 'Other early sources', text: 'In Béroul she is a queen of formidable wit and self-possession; in Thomas of Britain the doomed love is everything.' }, + { source: 'Malory', text: 'Malory keeps her dignity. She dies on Tristan\'s body, the second great death-by-love in the Morte. (Lancelot\'s is the first.)' }, + ], + }, + { + id: 'isolde-hands', name: 'Isolde of the White Hands', title: 'Lady of Brittany', group: 'lady', + blazon: { field: 'argent', charges: [{ device: 'hand', tincture: 'argent' }] }, + relations: [{ to: 'tristan', type: 'marriage' }], + sources: [ + { source: 'Other early sources', text: 'Tristan, in exile from Cornwall, marries her in Brittany — she has the same name as the woman he can\'t have. He never sleeps with her. When Tristan lies dying, she lies about the color of the sails of the rescue ship, and he dies of grief minutes before Isolde of Ireland arrives.' }, + ], + }, + { + id: 'morholt', name: 'Sir Morholt', title: 'Champion of Ireland', group: 'foe', + blazon: { field: 'azure', charges: [{ device: 'arm', tincture: 'argent' }] }, + relations: [{ to: 'tristan', type: 'rival' }, { to: 'isolde-ireland', type: 'family' }], + sources: [ + { source: 'Other early sources', text: 'The Irish champion who comes to Cornwall to demand tribute. Young Tristan, in his first deed, kills him in single combat — but Morholt\'s sword leaves a poisoned shard in Tristan\'s side that only Morholt\'s niece Isolde of Ireland can heal.' }, + ], + }, + + // ─── ENCHANTERS, FAY, GRAIL ────────────────────────────────────────────── + { + id: 'morgan', name: 'Morgan le Fay', title: 'Queen of Gore, Enchantress', group: 'enchanter', + blazon: { field: 'sable', charges: [{ device: 'moon', tincture: 'argent' }, { device: 'mullet', tincture: 'argent', count: 3, arrangement: 'in chief' }] }, + relations: [ + { to: 'arthur', type: 'family' }, { to: 'arthur', type: 'rival' }, + { to: 'morgause', type: 'family' }, { to: 'igraine', type: 'family' }, + { to: 'merlin', type: 'mentor' }, { to: 'merlin', type: 'rival' }, + { to: 'urien', type: 'marriage' }, { to: 'yvain', type: 'family' }, + { to: 'accolon', type: 'marriage' }, { to: 'guinevere', type: 'rival' }, + { to: 'lady-lake', type: 'rival' }, { to: 'green-knight', type: 'fellowship' }, + ], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'Geoffrey introduces her in the Vita Merlini (c.1150) not as a villain but as the chief of the nine sisters of Avalon, a healer. Arthur is brought to her after Camlann to be cured.' }, + { source: 'Vulgate Cycle', text: 'The Vulgate turns her against her brother — a pupil of Merlin gone wrong, a witch who steals Excalibur\'s scabbard, sleeps with knights to set traps for her brother, and is forever scheming. Yet she is also at the barge that bears him to Avalon at the end.' }, + { source: 'Malory', text: 'Malory keeps the duality. She tries to kill Arthur via her lover Accolon, fails, and is exiled. Decades later, when he is dying at Camlann, she is the first of three queens on the black barge to receive him.' }, + { source: 'Sir Gawain and the Green Knight', text: 'In the Pearl Poet she is the secret author of the whole trick: it is Morgan who sent the Green Knight to Camelot, hoping to frighten Guinevere to death.' }, + { source: 'T.H. White', text: 'White separates her sharply from Morgause and makes her a sad, beautiful, malicious fairy of the wet woods.' }, + ], + }, + { + id: 'nimue', name: 'Nimue / Vivien', title: 'Lady of the Lake', group: 'enchanter', + blazon: { field: 'azure', charges: [{ device: 'fish', tincture: 'argent' }] }, + relations: [ + { to: 'merlin', type: 'rival' }, { to: 'merlin', type: 'mentor' }, + { to: 'arthur', type: 'fellowship' }, { to: 'lady-lake', type: 'family' }, + { to: 'pelleas', type: 'marriage' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'The young enchantress who learns Merlin\'s craft from him, falls neither in love nor out, and uses his last great spell to seal him in a cave or air or oak. Her name shifts: Nimue, Niniane, Viviane.' }, + { source: 'Malory', text: 'Malory makes her become the Lady of the Lake after her predecessor\'s death — and a kinder figure than the Vulgate. She protects Arthur at several turns, marries Pelleas, and is one of the three queens on the barge to Avalon.' }, + { source: 'Tennyson', text: '"Merlin and Vivien" is the great Victorian portrait of female cunning corroding male wisdom. Tennyson is not subtle about whose side he is on.' }, + ], + }, + { + id: 'lady-lake', name: 'The Lady of the Lake', title: 'Mistress of Avalon', group: 'enchanter', + blazon: { field: 'azure', charges: [{ device: 'sword', tincture: 'argent' }] }, + relations: [ + { to: 'arthur', type: 'mentor' }, { to: 'merlin', type: 'fellowship' }, + { to: 'lancelot', type: 'family' }, { to: 'nimue', type: 'family' }, + { to: 'morgan', type: 'rival' }, { to: 'balin', type: 'rival' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'The Lady of the Lake raises Lancelot from infancy in her underwater kingdom. She gives Arthur Excalibur from the lake. The Vulgate often distinguishes her from Nimue/Viviane, who succeeds to the title.' }, + { source: 'Malory', text: 'In Malory the original Lady of the Lake comes to Camelot to demand Balin\'s head; Balin beheads her instead, on the spot, in Arthur\'s hall. The title passes to Nimue. The lake remains.' }, + ], + }, + { + id: 'pelles', name: 'King Pelles', title: 'The Fisher King, Keeper of the Grail', group: 'enchanter', + blazon: { field: 'gules', charges: [{ device: 'chalice', tincture: 'or' }, { device: 'fish', tincture: 'argent', arrangement: 'in chief', count: 2 }] }, + relations: [ + { to: 'elaine-corbenic', type: 'family' }, { to: 'galahad', type: 'family' }, + { to: 'pellam', type: 'family' }, { to: 'percival', type: 'mentor' }, + { to: 'lancelot', type: 'fellowship' }, + ], + sources: [ + { source: 'Chrétien de Troyes', text: 'The Fisher King — wounded so he can only fish, waiting for the right question to be asked by the right knight so the wasted land around his castle can heal. Perceval fails to ask.' }, + { source: 'Vulgate Cycle', text: 'Pelles is the Grail-keeper at Corbenic, father of Elaine, grandfather of Galahad. He plots, with his daughter, to bring Lancelot to her bed so the Grail-knight may be conceived.' }, + { source: 'Malory', text: 'Malory keeps Pelles courtly and grave. He is the source of the Grail line on the mother\'s side.' }, + ], + }, + { + id: 'pellam', name: 'King Pellam', title: 'The Maimed King', group: 'enchanter', + blazon: { field: 'azure', charges: [{ device: 'chalice', tincture: 'or' }] }, + relations: [{ to: 'pelles', type: 'family' }, { to: 'balin', type: 'rival' }], + sources: [ + { source: 'Malory', text: 'It is Balin\'s "Dolorous Stroke" that wounds Pellam in the thigh with the Spear of Longinus — and that wound creates the Wasteland that the Grail Quest must heal. The blow is one of the first sins of the early Round Table.' }, + ], + }, + { + id: 'elaine-corbenic', name: 'Elaine of Corbenic', title: 'Mother of Galahad', group: 'lady', + blazon: { field: 'argent', charges: [{ device: 'chalice', tincture: 'gules' }] }, + relations: [ + { to: 'pelles', type: 'family' }, { to: 'galahad', type: 'family' }, + { to: 'lancelot', type: 'marriage' }, { to: 'guinevere', type: 'rival' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'Enchanted by her father\'s craft to look like Guinevere, she lies with Lancelot at Corbenic. Galahad is conceived. When she later returns to Camelot openly — wearing her own face — Guinevere discovers everything, and Lancelot goes mad in the forest for two years.' }, + { source: 'Malory', text: 'Malory tells the story softly. She is a tragic figure, not a schemer — used by her father, faithful to a lover who can never love her back.' }, + ], + }, + { + id: 'elaine-astolat', name: 'Elaine of Astolat', title: 'Lily Maid of Astolat', group: 'lady', + blazon: { field: 'argent', charges: [{ device: 'fleur-de-lis', tincture: 'argent' }] }, + relations: [{ to: 'lancelot', type: 'rival' }], + sources: [ + { source: 'Malory', text: 'A young noblewoman who falls in love with Lancelot at a tournament. He cannot return it. She wills herself to die, leaves a letter in her own dead hand, and is floated down to Camelot in a barge of silks. Arthur reads the letter aloud and Guinevere weeps for the first time over Lancelot.' }, + { source: 'Tennyson', text: '"The Lady of Shalott" is the same girl in a tower. Tennyson made her image — the boat, the mirror cracking — the most-quoted in Arthurian Victoriana.' }, + ], + }, + { + id: 'elaine-garlot', name: 'Elaine of Garlot', title: 'Half-sister of Arthur', group: 'lady', + blazon: { field: 'azure', charges: [{ device: 'fleur-de-lis', tincture: 'argent', count: 3, arrangement: '2-1' }] }, + relations: [ + { to: 'arthur', type: 'family' }, { to: 'morgan', type: 'family' }, + { to: 'morgause', type: 'family' }, { to: 'igraine', type: 'family' }, + ], + sources: [ + { source: 'Vulgate Cycle', text: 'The third daughter of Igraine and Gorlois — the least storied. Married to King Nentres of Garlot; mother of minor knights. She is the sister who does not become an enchantress.' }, + ], + }, + + // ─── FOES, RIVALS, MISCELLANEA ─────────────────────────────────────────── + { + id: 'vortigern', name: 'Vortigern', title: 'British King, Treacherous', group: 'foe', + blazon: { field: 'sable', charges: [{ device: 'dragon', tincture: 'argent' }] }, + relations: [{ to: 'merlin', type: 'rival' }], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'The 5th-century king who, in Geoffrey, invites Hengest and the Saxons in as mercenaries against the Picts and so loses Britain to them. His tower collapses every night until the fatherless prophet boy (the young Merlin) explains the red and white dragons fighting in the pool below.' }, + ], + }, + { + id: 'accolon', name: 'Sir Accolon of Gaul', title: 'Knight, Lover of Morgan', group: 'foe', + blazon: { field: 'gules', charges: [{ device: 'sword', tincture: 'or' }] }, + relations: [{ to: 'morgan', type: 'marriage' }, { to: 'arthur', type: 'rival' }], + sources: [ + { source: 'Malory', text: 'Morgan steals Excalibur and gives it to her lover Accolon, then arranges for him and Arthur — disarmed and unsuspecting — to be set against each other. Arthur fights nearly to death before recognizing his own sword, gets it back, and forgives Accolon, who dies of his wounds. Morgan is exiled but not punished.' }, + ], + }, + { + id: 'meleagant', name: 'Sir Meleagant', title: 'Prince of Gore', group: 'foe', + blazon: { field: 'gules', charges: [{ device: 'tower', tincture: 'sable' }] }, + relations: [ + { to: 'guinevere', type: 'rival' }, { to: 'lancelot', type: 'rival' }, + { to: 'arthur', type: 'rival' }, { to: 'bagdemagus', type: 'family' }, + ], + sources: [ + { source: 'Chrétien de Troyes', text: 'The kidnapper-prince of Le Chevalier de la Charrette. He carries Guinevere off to a kingdom from which no traveler returns; Lancelot pursues her across the Sword Bridge and rescues her. The whole structure of courtly love in French romance rests on this kidnapping.' }, + { source: 'Malory', text: 'Malory translates it briskly and ends with Lancelot cleaving Meleagant\'s helm to the chin.' }, + ], + }, + { + id: 'bagdemagus', name: 'King Bagdemagus', title: 'King of Gore', group: 'foe', + blazon: { field: 'gules', ordinary: { type: 'chevron', tincture: 'or' } }, + relations: [{ to: 'meleagant', type: 'family' }, { to: 'arthur', type: 'fellowship' }], + sources: [ + { source: 'Malory', text: 'A decent old king, father of the villainous Meleagant. He is repeatedly used by Malory as the example of how a worthy father can have a worthless son.' }, + ], + }, + { + id: 'balin', name: 'Sir Balin le Sauvage', title: 'Knight of the Two Swords', group: 'other', + blazon: { field: 'argent', charges: [{ device: 'sword', tincture: 'sable', count: 2, arrangement: 'in saltire' }] }, + relations: [ + { to: 'balan', type: 'family' }, { to: 'arthur', type: 'fellowship' }, + { to: 'lady-lake', type: 'rival' }, { to: 'pellam', type: 'rival' }, + ], + sources: [ + { source: 'Malory', text: 'A knight cursed by every act of his own justice. He beheads the Lady of the Lake in Arthur\'s own hall. He carries two swords thereafter — one always doomed. He delivers the Dolorous Stroke that wounds King Pellam and creates the Wasteland. Then he and his beloved brother Balan, riding to a strange isle, fail to recognize each other and kill each other in single combat.' }, + ], + }, + { + id: 'balan', name: 'Sir Balan', title: 'Brother of Balin', group: 'other', + blazon: { field: 'argent', charges: [{ device: 'sword', tincture: 'sable' }] }, + relations: [{ to: 'balin', type: 'family' }], + sources: [ + { source: 'Malory', text: 'The other half of the tragedy. They die in each other\'s arms, recognizing too late, and are buried in a single grave with an inscription naming both.' }, + ], + }, + { + id: 'green-knight', name: 'The Green Knight', title: 'Sir Bertilak de Hautdesert', group: 'foe', + blazon: { field: 'vert', charges: [{ device: 'oak', tincture: 'or' }] }, + relations: [{ to: 'gawain', type: 'rival' }, { to: 'morgan', type: 'fellowship' }], + sources: [ + { source: 'Sir Gawain and the Green Knight', text: 'A green giant rides into Camelot at Christmas with an axe and offers a blow exchanged for a blow a year later. Gawain takes the bargain. The Green Knight is also (it transpires) the courteous Sir Bertilak of Hautdesert, transformed by Morgan le Fay\'s sorcery — and the whole test is hers, not his.' }, + ], + }, + { + id: 'ironside', name: 'Sir Ironside, the Red Knight of the Red Lands', title: 'Foe of Gareth', group: 'foe', + blazon: { field: 'gules', charges: [{ device: 'sun', tincture: 'or' }] }, + relations: [{ to: 'gareth', type: 'rival' }, { to: 'arthur', type: 'fellowship' }], + sources: [ + { source: 'Malory', text: 'A knight whose strength waxes till noon and wanes after. Beaumains (Gareth) holds him through the morning, then strikes him down in the afternoon. Ironside surrenders and joins the Round Table — one of Malory\'s many redemption-by-defeat moments.' }, + ], + }, + { + id: 'pelleas', name: 'Sir Pelleas', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'azure', charges: [{ device: 'rose', tincture: 'or' }] }, + relations: [{ to: 'nimue', type: 'marriage' }, { to: 'ettarde', type: 'rival' }, { to: 'arthur', type: 'fellowship' }], + sources: [ + { source: 'Malory', text: 'Pelleas loves the lady Ettarde, who scorns him; Gawain, sent to plead his case, sleeps with her instead. Nimue takes pity, enchants Ettarde to love Pelleas (who now despises her), and marries Pelleas herself.' }, + { source: 'Tennyson', text: '"Pelleas and Ettarre" is one of the bleaker Idylls — Pelleas survives only to become a wandering disenchanted knight, witness to the collapse of his own ideals.' }, + ], + }, + { + id: 'lucan', name: 'Sir Lucan the Butler', title: 'Knight of the Round Table', group: 'round-table', + blazon: { field: 'gules', charges: [{ device: 'chalice', tincture: 'argent' }] }, + relations: [{ to: 'bedivere', type: 'family' }, { to: 'arthur', type: 'fellowship' }], + sources: [ + { source: 'Malory', text: 'Brother to Bedivere. Survives Camlann mortally wounded; tries to lift the dying Arthur and his own guts spill out from the effort. He dies in the king\'s arms — one of the small, awful images that ends the Morte.' }, + ], + }, + { + id: 'urien', name: 'King Urien of Gore', title: 'King of Rheged', group: 'other', + blazon: { field: 'or', charges: [{ device: 'raven', tincture: 'sable' }] }, + relations: [{ to: 'morgan', type: 'marriage' }, { to: 'yvain', type: 'family' }, { to: 'arthur', type: 'fellowship' }], + sources: [ + { source: 'Mabinogion', text: 'A historical 6th-century king of Rheged, father of the historical Owain. The Welsh poets remember him.' }, + { source: 'Malory', text: 'Married to Morgan. She tries to kill him in his sleep with his own sword; their son Yvain catches her, and she leaves Camelot for good.' }, + ], + }, + { + id: 'taliesin', name: 'Taliesin', title: 'Chief Bard of the Island', group: 'enchanter', + blazon: { field: 'azure', charges: [{ device: 'harp', tincture: 'or' }] }, + relations: [{ to: 'arthur', type: 'fellowship' }, { to: 'merlin', type: 'fellowship' }, { to: 'bran', type: 'fellowship' }], + sources: [ + { source: 'Mabinogion', text: 'The 6th-century historical poet, almost immediately legendary. In Preiddeu Annwfn ("The Spoils of Annwn") he sails with Arthur to the Otherworld to steal a magic cauldron — and only seven return. The poem is older than any Round Table.' }, + ], + }, + { + id: 'bran', name: 'Bran the Blessed', title: 'Giant-King of Britain', group: 'enchanter', + blazon: { field: 'sable', charges: [{ device: 'raven', tincture: 'argent' }] }, + relations: [{ to: 'taliesin', type: 'fellowship' }], + sources: [ + { source: 'Mabinogion', text: 'From the Second Branch: a giant Welsh king whose head, after his death in Ireland, is buried at the White Tower of London facing south, to guard Britain from invasion forever. Some scholars trace the Grail (the wounded king, the dish that feeds) back to Bran\'s cauldron.' }, + ], + }, + { + id: 'lot-king', name: 'King Bors the Elder', title: 'King of Gaul, Father of Bors', group: 'other', + blazon: { field: 'gules', charges: [{ device: 'lion', tincture: 'or', attitude: 'passant', count: 2, arrangement: 'in pale' }] }, + relations: [{ to: 'bors', type: 'family' }, { to: 'lionel', type: 'family' }, { to: 'arthur', type: 'fellowship' }], + sources: [ + { source: 'Vulgate Cycle', text: 'King of Gaunes; father of Bors and Lionel. Dies in the wars after Arthur\'s coronation, leaving his small sons to be raised by the Lady of the Lake alongside Lancelot.' }, + ], + }, + { + id: 'cador', name: 'Sir Cador of Cornwall', title: 'Duke of Cornwall after Gorlois', group: 'cornwall', + blazon: { field: 'sable', ordinary: { type: 'fess', tincture: 'argent' } }, + relations: [{ to: 'arthur', type: 'fellowship' }, { to: 'mark', type: 'rival' }], + sources: [ + { source: 'Geoffrey of Monmouth', text: 'A loyal duke of Cornwall in Geoffrey, one of Arthur\'s chief continental commanders. By the later romances he has faded almost entirely.' }, + ], + }, + { + id: 'ettarde', name: 'Lady Ettarde', title: 'Lady, beloved of Pelleas', group: 'lady', + blazon: { field: 'purpure', charges: [{ device: 'rose', tincture: 'argent' }] }, + relations: [{ to: 'pelleas', type: 'rival' }, { to: 'gawain', type: 'rival' }], + sources: [ + { source: 'Malory', text: 'Scorns Pelleas. Sleeps with Gawain. Cursed by Nimue\'s enchantment to love Pelleas hopelessly while Pelleas now despises her. She dies of it.' }, + ], + }, + { + id: 'laudine', name: 'Laudine', title: 'Lady of the Fountain', group: 'lady', + blazon: { field: 'azure', charges: [{ device: 'sun', tincture: 'argent' }] }, + relations: [{ to: 'yvain', type: 'marriage' }], + sources: [ + { source: 'Chrétien de Troyes', text: 'Yvain kills her husband and, within days, marries her — a transition Chrétien handles with such bald irony that critics have argued about it for eight centuries.' }, + ], + }, +]; + +// ─── REALMS ────────────────────────────────────────────────────────────────── +export const REALM_BY_ID = { + arthur: 'logres', guinevere: 'logres', merlin: 'other', + kay: 'logres', bedivere: 'logres', lucan: 'logres', pelleas: 'logres', caradoc: 'logres', + ector: 'logres', vortigern: 'logres', uther: 'logres', igraine: 'logres', + 'elaine-astolat': 'logres', lyonesse: 'logres', ironside: 'logres', + tristan: 'cornwall', mark: 'cornwall', gorlois: 'cornwall', cador: 'cornwall', + yvain: 'wales', geraint: 'wales', + pellinore: 'wales', lamorak: 'wales', percival: 'wales', dindrane: 'wales', + bran: 'wales', taliesin: 'wales', enide: 'wales', + bagdemagus: 'wales', meleagant: 'wales', blanchefleur: 'wales', 'green-knight': 'wales', + gawain: 'north', gareth: 'north', gaheris: 'north', agravain: 'north', + lot: 'north', morgause: 'north', mordred: 'north', urien: 'north', ettarde: 'north', + balin: 'north', balan: 'north', + 'isolde-ireland': 'ireland', morholt: 'ireland', + lancelot: 'france', bors: 'france', lionel: 'france', 'ector-marais': 'france', + 'lot-king': 'france', accolon: 'france', 'isolde-hands': 'france', laudine: 'france', + galahad: 'other', palamedes: 'other', safir: 'other', segwarides: 'other', + morgan: 'other', nimue: 'other', 'lady-lake': 'other', + pelles: 'other', pellam: 'other', 'elaine-corbenic': 'other', 'elaine-garlot': 'other', +}; + +export const REALMS = [ + { id: 'logres', label: 'LOGRES', width: 70 }, + { id: 'north', label: 'THE NORTH', width: 56 }, + { id: 'other', label: 'OTHER REALMS', width: 56 }, + { id: 'france', label: 'GAUL & BRITTANY', width: 56 }, + { id: 'cornwall', label: 'CORNWALL', width: 20 }, + { id: 'wales', label: 'WALES', width: 77 }, + { id: 'ireland', label: 'IRELAND', width: 25 }, +]; + +export const KNIGHT_ORDER_BY_REALM = { + logres: ['kay', 'bedivere', 'lucan', 'caradoc', 'pelleas'], + cornwall: ['tristan'], + wales: ['pellinore', 'lamorak', 'percival', 'yvain', 'geraint'], + north: ['gawain', 'agravain', 'gaheris', 'gareth'], + ireland: [], + france: ['lancelot', 'bors', 'lionel', 'ector-marais'], + other: ['galahad', 'palamedes', 'safir', 'segwarides'], +}; + +export const REALM_LABEL = Object.fromEntries(REALMS.map(r => [r.id, r.label])); diff --git a/timeline-scratch/src/ContributorPortalApp.jsx b/timeline-scratch/src/ContributorPortalApp.jsx index 43d2d70..31e2efa 100644 --- a/timeline-scratch/src/ContributorPortalApp.jsx +++ b/timeline-scratch/src/ContributorPortalApp.jsx @@ -55,7 +55,7 @@ function AuthenticatedPortal() { let cancelled = false; const getTokenForSupabase = () => getToken({ template: 'supabase' }); const email = clerkUser?.primaryEmailAddress?.emailAddress; - const displayName = clerkUser?.fullName || clerkUser?.firstName || null; + const displayName = clerkUser?.username || clerkUser?.fullName || clerkUser?.firstName || null; ensureUserExists(getTokenForSupabase, userId, email, displayName) .then(() => checkUserRole(getTokenForSupabase, userId)) @@ -81,7 +81,7 @@ function AuthenticatedPortal() { }), []); const email = clerkUser?.primaryEmailAddress?.emailAddress; - const displayName = clerkUser?.fullName || clerkUser?.firstName || null; + const displayName = clerkUser?.username || clerkUser?.fullName || clerkUser?.firstName || null; return ( <> diff --git a/timeline-scratch/src/components/SiteNavPanel.jsx b/timeline-scratch/src/components/SiteNavPanel.jsx index b8a8f4d..645bc6e 100644 --- a/timeline-scratch/src/components/SiteNavPanel.jsx +++ b/timeline-scratch/src/components/SiteNavPanel.jsx @@ -26,6 +26,7 @@ const NAV_ITEMS = [ { key: 'biblical-atlas', href: './biblical-places.html', label: 'Biblical atlas' }, { key: 'timeline', href: './index.html', label: 'Timeline component' }, { key: 'contributor-portal', href: './contributor-portal.html', label: 'Contributor portal' }, + { key: 'arthuriana', href: './arthuriana.html', label: 'Arthuriana' }, ]; export function SiteNavPanel({ open, onClose, activeKey }) { diff --git a/timeline-scratch/src/main-arthuriana-studio.jsx b/timeline-scratch/src/main-arthuriana-studio.jsx new file mode 100644 index 0000000..acb13d2 --- /dev/null +++ b/timeline-scratch/src/main-arthuriana-studio.jsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ArthurianStudio } from './Arthuriana/ArthurianStudio.jsx'; + +createRoot(document.getElementById('root')).render( + + + +); diff --git a/timeline-scratch/src/main-arthuriana.jsx b/timeline-scratch/src/main-arthuriana.jsx new file mode 100644 index 0000000..988de19 --- /dev/null +++ b/timeline-scratch/src/main-arthuriana.jsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ArthurianApp } from './Arthuriana/ArthurianApp.jsx'; + +createRoot(document.getElementById('root')).render( + + + +); diff --git a/timeline-scratch/src/services/adminService.js b/timeline-scratch/src/services/adminService.js index 90dd338..6ff93da 100644 --- a/timeline-scratch/src/services/adminService.js +++ b/timeline-scratch/src/services/adminService.js @@ -31,7 +31,7 @@ export async function ensureUserExists(getToken, clerkUserId, email, displayName if (existing) { const updates = {}; - if (!existing.display_name && displayName) updates.display_name = displayName; + if (displayName && displayName !== existing.display_name) updates.display_name = displayName; if (!existing.email && email) updates.email = email; if (Object.keys(updates).length > 0) { await supabase diff --git a/timeline-scratch/vite.config.js b/timeline-scratch/vite.config.js index f2f9923..1577fa1 100644 --- a/timeline-scratch/vite.config.js +++ b/timeline-scratch/vite.config.js @@ -27,6 +27,8 @@ export default defineConfig({ 'african-kingdoms': resolve(__dirname, 'african-kingdoms.html'), 'first-century-church': resolve(__dirname, 'first-century-church.html'), 'contributor-portal': resolve(__dirname, 'contributor-portal.html'), + 'arthuriana': resolve(__dirname, 'arthuriana.html'), + 'arthuriana-studio': resolve(__dirname, 'arthuriana-studio.html'), }, }, }, From d605e6b4677a34ed455d3f9c948e5118bdbf4e0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 21:31:27 +0000 Subject: [PATCH 2/6] Emblazon Windhover falcon on Arthuriana shield logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the generic gold cross-hatch on the topbar shield with a white falcon silhouette traced from the Windhover logo — beak facing left, wing sweeping to the upper-right, tail fanning to the lower-right, all coordinates verified inside the heater shield's curved boundary. https://claude.ai/code/session_013yG27kBU5JeKdX29ZwV1kv --- timeline-scratch/src/Arthuriana/ArthurianApp.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx index efe14b2..79bf012 100644 --- a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx +++ b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx @@ -43,7 +43,8 @@ export function ArthurianApp() {
- + {/* Windhover falcon: beak left, wing sweeping upper-right, tail lower-right */} +
Arthuriana
From 6e0849f43c4d910d412e5e780470e0741964c91c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 22:15:14 +0000 Subject: [PATCH 3/6] Use Windhover PNG (inverted white) as heraldic charge on Arthuriana shield Replaces the hand-traced SVG path (which didn't read as a bird at small scale) with the actual Windhover_BLK.png imported at build time, rendered white via SVG feColorMatrix inversion, and clipped to the shield interior. Vite processes the asset correctly at build time. https://claude.ai/code/session_013yG27kBU5JeKdX29ZwV1kv --- .../src/Arthuriana/ArthurianApp.jsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx index 79bf012..ccc4061 100644 --- a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx +++ b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx @@ -1,4 +1,5 @@ import { useState, useMemo } from 'react'; +import falconSrc from '../../../resources/logos/Windhover_BLK.png'; import { CHARACTERS } from './characters.js'; import { Shield } from './ArthurianHeraldry.jsx'; import { Graph, REL_TYPES } from './ArthurianGraph.jsx'; @@ -42,9 +43,22 @@ export function ArthurianApp() {
+ + + + + + + + - {/* Windhover falcon: beak left, wing sweeping upper-right, tail lower-right */} - +
Arthuriana
From 2e51eb4f946b1a23dcd3e19bbf9106d33dd38116 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 23:17:33 +0000 Subject: [PATCH 4/6] Increase Arthuriana brand shield size, keep bird pixel-size fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVG grows from 34×40 to 44×52; the image element shrinks from 78×57 to 60×44 viewBox units so the bird stays at ~26×19 px while the shield gains ~5 px of red border on each side. https://claude.ai/code/session_013yG27kBU5JeKdX29ZwV1kv --- timeline-scratch/src/Arthuriana/ArthurianApp.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx index ccc4061..b00b851 100644 --- a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx +++ b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx @@ -42,7 +42,7 @@ export function ArthurianApp() {
- + @@ -54,8 +54,8 @@ export function ArthurianApp() { From 9e25ed79b0c9593e6ec0320babd80d17a35151e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 23:50:51 +0000 Subject: [PATCH 5/6] Reduce Arthuriana brand shield by ~10%, keep falcon pixel-size fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shield: 44×52 → 40×47 px. Image element: 60×44 → 66×48 viewBox units to hold the falcon at the same ~26×19 px while the shield shrinks. https://claude.ai/code/session_013yG27kBU5JeKdX29ZwV1kv --- timeline-scratch/src/Arthuriana/ArthurianApp.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx index b00b851..bb3c2f9 100644 --- a/timeline-scratch/src/Arthuriana/ArthurianApp.jsx +++ b/timeline-scratch/src/Arthuriana/ArthurianApp.jsx @@ -42,7 +42,7 @@ export function ArthurianApp() {
- + @@ -54,8 +54,8 @@ export function ArthurianApp() { From ae05a8fac9d3e7a1a811aa2dca06c8c77f8d8231 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 01:19:54 +0000 Subject: [PATCH 6/6] Respect relation filter in character detail panel Disabled relation types now hide their buckets in the panel to match the graph, which already hides those edges. Fixes inconsistency noted in code review. https://claude.ai/code/session_013yG27kBU5JeKdX29ZwV1kv --- timeline-scratch/src/Arthuriana/ArthurianPanel.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/timeline-scratch/src/Arthuriana/ArthurianPanel.jsx b/timeline-scratch/src/Arthuriana/ArthurianPanel.jsx index 0fa8ab5..421ba68 100644 --- a/timeline-scratch/src/Arthuriana/ArthurianPanel.jsx +++ b/timeline-scratch/src/Arthuriana/ArthurianPanel.jsx @@ -101,6 +101,7 @@ export function Panel({ character, allCharacters, onClose, onNavigate, focusedRe const seen = new Set(); const buckets = {}; rels.forEach(r => { + if (focusedRelTypes && focusedRelTypes[r.type] === false) return; const k = r.to + '|' + r.type; if (seen.has(k)) return; seen.add(k);