Files
colony-archive/game.html
T

2464 lines
98 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Session — Orison Reach</title>
<style>
:root {
--void: #171513;
--void-soft: #211e1a;
--panel: #2a2520;
--paper: #e8dcc4;
--paper-deep: #cbbd9e;
--ink: #2c231c;
--text: #e9dfca;
--muted: #a89d89;
--brass: #b88a43;
--rust: #94452f;
--moss: #70794e;
--oxygen: #86a5a4;
--line: rgba(232, 220, 196, .18);
--shadow: 0 22px 70px rgba(0,0,0,.42);
--serif: Georgia, "Times New Roman", serif;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 12% 8%, rgba(134,165,164,.08), transparent 26rem),
linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.014) 1px, transparent 1px),
var(--void);
background-size: auto, 28px 28px, 28px 28px, auto;
font-family: var(--sans);
line-height: 1.7;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: .16;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
mix-blend-mode: soft-light;
z-index: 10;
}
a { color: inherit; }
.shell { width: min(1180px, calc(100% - 36px)); margin: 0 auto; }
nav {
position: sticky;
top: 0;
z-index: 5;
border-bottom: 1px solid var(--line);
background: rgba(23,21,19,.86);
backdrop-filter: blur(14px);
}
nav .shell { display: flex; gap: 1.6rem; overflow-x: auto; align-items: center; justify-content: space-between; }
nav .nav-left { display: flex; gap: 1.6rem; align-items: center; }
nav a {
padding: 1rem 0;
text-decoration: none;
white-space: nowrap;
font: 650 .72rem/1 var(--mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
nav a:hover { color: var(--paper); }
.edit-toggle {
display: flex;
align-items: center;
gap: .6rem;
padding: .5rem .9rem;
border: 1px solid var(--line);
background: transparent;
font: 700 .65rem/1 var(--mono);
letter-spacing: .14em;
text-transform: uppercase;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.edit-toggle.locked { color: var(--muted); }
.edit-toggle.locked:hover { color: var(--brass); border-color: rgba(184,138,67,.4); }
.edit-toggle.unlocked { color: var(--moss); border-color: rgba(112,121,78,.5); }
.edit-toggle.unlocked:hover { color: var(--rust); border-color: rgba(148,69,47,.4); }
h1, h2, h3 { font-family: var(--serif); font-weight: 600; letter-spacing: -.02em; margin: 0; }
h2 { font-size: clamp(2.2rem, 5vw, 4.2rem); line-height: 1.05; color: var(--paper); }
h3 { font-size: clamp(1.35rem, 2.4vw, 2rem); line-height: 1.25; color: var(--paper); }
.eyebrow {
color: var(--brass);
font: 700 .74rem/1.2 var(--mono);
letter-spacing: .22em;
text-transform: uppercase;
}
main { padding: 4rem 0 7rem; }
.game-header { margin-bottom: 3.5rem; }
.game-header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
flex-wrap: wrap;
margin-top: .6rem;
}
.game-header-title { flex: 1; min-width: 0; }
.game-meta-row {
display: flex;
align-items: center;
gap: .8rem;
flex-wrap: wrap;
margin-top: .8rem;
}
.mood-badge {
font: 700 .62rem/1 var(--mono);
letter-spacing: .12em;
text-transform: uppercase;
padding: .28rem .6rem;
border: 1px solid;
}
.mood-badge.light { color: var(--brass); border-color: rgba(184,138,67,.4); }
.mood-badge.dark { color: var(--rust); border-color: rgba(148,69,47,.4); }
.game-dates { font: .72rem/1 var(--mono); color: var(--muted); letter-spacing: .06em; }
.game-desc { color: #c2b7a4; max-width: 72ch; margin-top: 1rem; font-size: 1rem; }
.game-header-actions { display: flex; gap: .7rem; flex-wrap: wrap; }
.btn {
display: inline-flex;
align-items: center;
gap: .45rem;
padding: .6rem 1.2rem;
border: 1px solid var(--line);
background: transparent;
color: var(--brass);
font: 700 .68rem/1 var(--mono);
letter-spacing: .14em;
text-transform: uppercase;
cursor: pointer;
text-decoration: none;
transition: background .15s, border-color .15s;
white-space: nowrap;
}
.btn:hover { background: rgba(184,138,67,.1); border-color: var(--brass); }
.btn.sm { padding: .38rem .75rem; font-size: .62rem; }
.btn.ghost { color: var(--muted); }
.btn.ghost:hover { color: var(--text); border-color: rgba(232,220,196,.3); background: rgba(232,220,196,.05); }
.btn.danger { color: var(--rust); }
.btn.danger:hover { background: rgba(148,69,47,.15); border-color: var(--rust); }
.btn.primary { background: var(--brass); color: var(--void); border-color: var(--brass); }
.btn.primary:hover { background: #cca055; }
/* Timeline */
.timeline-section { margin-bottom: 3.5rem; }
.tl-wrap {
overflow: hidden;
position: relative;
padding: 0;
}
/* Callout timeline canvas */
.tl-canvas {
position: relative;
width: 100%;
min-width: 400px;
}
.tl-rail-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: rgba(232,220,196,.25);
}
.tl-stem {
position: absolute;
width: 1px;
background: rgba(232,220,196,.14);
}
.tl-evt-dot {
position: absolute;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.tl-evt-dot.light { background: var(--brass); }
.tl-evt-dot.dark { background: var(--rust); }
.tl-evt-dot.anchor-dot {
background: transparent !important;
border: 2px solid;
}
.tl-evt-dot.anchor-dot.light { border-color: var(--brass); }
.tl-evt-dot.anchor-dot.dark { border-color: var(--rust); }
.tl-evt-dot.disc-icon {
background: transparent !important;
border: none !important;
border-radius: 0;
font-size: 15px;
line-height: 1;
width: auto;
height: auto;
filter: grayscale(1) sepia(1) saturate(1.5) brightness(0.9);
}
.disc-emoji {
filter: grayscale(1) sepia(1) saturate(1.5) brightness(0.9);
font-size: .9em;
vertical-align: middle;
}
.tl-lbl {
position: absolute;
max-width: 148px;
text-align: center;
}
.tl-lbl-name {
font: 600 .64rem/1.25 var(--mono);
letter-spacing: .04em;
color: var(--text);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.tl-lbl-name.is-anchor { color: var(--paper); font-weight: 700; }
.tl-lbl-date {
font: .56rem/1.1 var(--mono);
color: var(--muted);
margin-top: .12rem;
white-space: nowrap;
}
/* Bar rows (projects + cast) */
.tl-sublabel {
font: 700 .6rem/1 var(--mono);
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
margin: 1rem 0 .35rem;
}
.tl-rows { display: flex; flex-direction: column; gap: 5px; }
.tl-row { position: relative; height: 22px; min-width: 400px; }
.tl-bar {
position: absolute;
height: 20px;
top: 1px;
border-radius: 2px;
min-width: 4px;
overflow: hidden;
display: flex;
align-items: center;
padding: 0 14px;
}
.tl-bar.project-bar { background: var(--moss); opacity: .85; }
.tl-bar.project-bar.dark { background: var(--rust); opacity: .7; }
/* Ragged edges — fixed 24px fade so text stays readable on long bars */
.tl-bar.open-right {
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
opacity: 1 !important;
}
.tl-bar.open-left {
-webkit-mask-image: linear-gradient(to left, black calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to left, black calc(100% - 24px), transparent 100%);
opacity: 1 !important;
}
.tl-bar.open-both {
-webkit-mask-image: linear-gradient(to right, transparent 0px, black 24px, black calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to right, transparent 0px, black 24px, black calc(100% - 24px), transparent 100%);
opacity: 1 !important;
}
.tl-bar-text {
font: 600 .58rem/1 var(--mono);
color: rgba(255,255,255,.82);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
pointer-events: none;
}
.divider { width: min(100%, 980px); margin: 4.2rem auto; opacity: .9; display: block; }
/* Chronicle sections — editorial, open layout */
.chronicle-section { margin-bottom: 6rem; }
.chronicle-header-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
flex-wrap: wrap;
padding-bottom: 2.5rem;
}
.chronicle-eyebrow {
color: var(--brass);
font: 700 .74rem/1.2 var(--mono);
letter-spacing: .22em;
text-transform: uppercase;
margin-bottom: .4rem;
}
.chronicle-title {
font-family: var(--serif);
font-size: clamp(2.2rem, 5vw, 4.2rem);
font-weight: 600;
color: var(--paper);
letter-spacing: -.02em;
line-height: 1.05;
margin: 0;
}
/* Entries — open, no box */
.entry-list { display: flex; flex-direction: column; }
.entry {
padding: 2.2rem 0;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: 1fr auto;
gap: 0 2rem;
align-items: start;
}
.entry:first-child { border-top: 1px solid var(--line); }
.entry-body { min-width: 0; }
.entry-eyebrow {
font: 700 .7rem/1.4 var(--mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--brass);
margin-bottom: .6rem;
}
.entry-eyebrow .sep { color: var(--muted); opacity: .6; margin: 0 .25em; }
.entry-eyebrow .e-dark { color: var(--rust); }
.entry-eyebrow .e-muted { color: var(--muted); }
.entry-eyebrow .e-teal { color: var(--oxygen); }
.entry-eyebrow .e-anchor { color: var(--brass); opacity: .7; }
.entry-title {
font-family: var(--serif);
font-size: clamp(1.5rem, 3vw, 2.4rem);
font-weight: 600;
color: var(--paper);
letter-spacing: -.02em;
line-height: 1.05;
margin: 0 0 .75rem;
}
.entry-desc {
color: #c2b7a4;
font-size: 1rem;
line-height: 1.7;
max-width: 72ch;
}
.entry-desc strong { color: var(--paper); font-weight: 600; }
.entry-desc em { font-style: italic; color: #d4c9b4; }
.entry-desc .cast-mention {
color: var(--oxygen);
text-decoration: none;
border-bottom: 1px dotted rgba(134,165,164,.45);
cursor: pointer;
}
.entry-desc .cast-mention:hover { color: var(--paper); }
.entry-desc .cast-mention.unknown { color: var(--muted); border-bottom-color: transparent; }
.entry-actions { display: flex; gap: .4rem; flex-shrink: 0; padding-top: .35rem; }
.cast-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
vertical-align: middle;
margin-right: .4em;
flex-shrink: 0;
}
.entry-env {
border: 1px solid rgba(232,220,196,.22);
box-shadow: var(--shadow);
background: var(--panel);
padding: 6px;
}
.entry-env img { width: 100%; display: block; }
/* Image below text (default) */
.entry-body.env-after .entry-env { margin-top: 1.2rem; max-width: 540px; }
/* Image to the right — text left, image right */
.entry-body.env-right {
display: grid;
grid-template-columns: 1fr 280px;
column-gap: 2rem;
align-items: start;
}
.entry-body.env-right > *:not(.entry-env) { grid-column: 1; }
.entry-body.env-right .entry-env { grid-column: 2; grid-row: 1 / 10; position: sticky; top: 5.3rem; }
/* Image to the left — image left, text right */
.entry-body.env-left {
display: grid;
grid-template-columns: 280px 1fr;
column-gap: 2rem;
align-items: start;
}
.entry-body.env-left > *:not(.entry-env) { grid-column: 2; }
.entry-body.env-left .entry-env { grid-column: 1; grid-row: 1 / 10; position: sticky; top: 5.3rem; }
@media (max-width: 700px) {
.entry-body.env-left, .entry-body.env-right { display: block; }
.entry-body.env-left .entry-env,
.entry-body.env-right .entry-env { position: relative; top: auto; margin-top: 1rem; }
}
.empty-section {
padding: 2rem 0;
color: var(--muted);
font: italic 1rem/1.6 var(--serif);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(23,21,19,.88);
backdrop-filter: blur(6px);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal {
background: var(--void-soft);
border: 1px solid rgba(232,220,196,.3);
padding: 2rem 2rem 1.6rem;
width: min(620px, 100%);
max-height: 90vh;
overflow-y: auto;
}
.modal-title {
font-family: var(--serif);
font-size: 1.5rem;
font-weight: 600;
color: var(--paper);
margin: 0 0 1.6rem;
}
.form-section {
border-top: 1px solid var(--line);
margin-top: 1.4rem;
padding-top: 1.2rem;
}
.form-section-title {
font: 700 .68rem/1 var(--mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 1rem;
}
.form-group { margin-bottom: 1.2rem; }
.form-label {
display: block;
font: 700 .67rem/1 var(--mono);
letter-spacing: .1em;
text-transform: uppercase;
color: var(--brass);
margin-bottom: .42rem;
}
.form-label span { color: var(--rust); margin-left: .2rem; }
input[type="text"], input[type="password"], input[type="date"], textarea, select {
width: 100%;
background: var(--panel);
border: 1px solid rgba(232,220,196,.2);
color: var(--text);
padding: .65rem .85rem;
font-family: var(--sans);
font-size: .94rem;
line-height: 1.5;
appearance: none;
-webkit-appearance: none;
}
select option { background: var(--panel); }
textarea { resize: vertical; min-height: 80px; }
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--brass); }
.date-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.mood-row { display: flex; gap: 1rem; }
.mood-radio { display: flex; align-items: center; gap: .5rem; cursor: pointer; }
.mood-radio input { width: auto; }
.mood-radio span { font: .82rem/1 var(--mono); color: var(--text); }
.form-hint { font: .7rem/1.4 var(--mono); color: var(--muted); margin-top: .35rem; }
.form-error { font: .78rem/1.4 var(--mono); color: var(--rust); margin-top: .5rem; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: .7rem;
margin-top: 1.6rem;
padding-top: 1.2rem;
border-top: 1px solid var(--line);
}
footer { border-top: 1px solid var(--line); padding: 2.5rem 0 4rem; color: var(--muted); }
footer .shell { display: flex; justify-content: space-between; gap: 2rem; flex-wrap: wrap; }
footer p { margin: 0; font: .72rem/1.7 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
/* Card draw */
.card-overlay {
position: fixed;
inset: 0;
background: rgba(23,21,19,.92);
backdrop-filter: blur(8px);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
/* Card scales to fit viewport in both dimensions */
.card-frame {
position: relative;
width: min(360px, 88vw, calc(88vh * 9 / 13));
aspect-ratio: 9 / 13;
background: url('assets/card-frame.png') center / cover no-repeat;
flex-shrink: 0;
}
.card-image-bg {
position: absolute;
inset: 16% 13% 13%;
object-fit: cover;
opacity: .35;
pointer-events: none;
}
/* Inner content area sits on the lined paper, inside the ornate border */
.card-inner {
position: absolute;
top: 16%;
left: 17%;
right: 17%;
bottom: 13%;
display: flex;
flex-direction: column;
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.card-type-label {
font: 700 .55rem/1 var(--mono);
letter-spacing: .22em;
text-transform: uppercase;
color: #8a5c2c;
text-align: center;
margin-bottom: .3rem;
}
.card-title {
font-family: var(--serif);
font-size: 1.3rem;
font-weight: 700;
color: #160d04;
letter-spacing: -.01em;
line-height: 1.1;
text-align: center;
margin-bottom: .4rem;
}
.card-question {
font: italic .75rem/1.45 var(--serif);
color: #2c1a0e;
text-align: center;
margin-bottom: .3rem;
}
.card-divider {
width: 35%;
height: 1px;
background: rgba(90,50,20,.3);
margin: .2rem auto .45rem;
}
.card-options { display: flex; flex-direction: column; gap: .45rem; }
.card-option {
font: .75rem/1.45 var(--serif);
color: #1e0e04;
padding-left: 1.8rem;
position: relative;
}
.card-option-label {
position: absolute;
left: 0;
font: 700 .6rem/1.6 var(--mono);
letter-spacing: .06em;
color: #7a4c1e;
}
.card-dismiss {
display: block;
width: 100%;
background: rgba(90,50,20,.07);
border: 1px solid rgba(90,50,20,.55);
color: #3d2008;
font: 700 .58rem/1 var(--mono);
letter-spacing: .18em;
text-transform: uppercase;
padding: .5rem;
cursor: pointer;
transition: background .15s, border-color .15s;
margin-top: .5rem;
}
.card-dismiss:hover { background: rgba(90,50,20,.18); border-color: rgba(90,50,20,.8); }
/* Timeline click chooser */
.tl-chooser {
position: fixed;
z-index: 200;
background: var(--panel);
border: 1px solid rgba(232,220,196,.3);
box-shadow: 0 8px 28px rgba(0,0,0,.55);
display: flex;
flex-direction: column;
gap: 1px;
transform: translateX(-50%);
}
.tl-chooser-btn {
background: transparent;
border: none;
color: var(--brass);
font: 700 .68rem/1 var(--mono);
letter-spacing: .12em;
text-transform: uppercase;
padding: .65rem 1.2rem;
cursor: pointer;
white-space: nowrap;
text-align: left;
}
.tl-chooser-btn:hover { background: rgba(184,138,67,.12); color: var(--paper); }
/* Truths */
.truth-list { display:flex; flex-direction:column; gap:.2rem; margin-top:.6rem; }
.truth-item {
display:flex; align-items:baseline; gap:.75rem;
font:italic 1rem/1.7 var(--serif); color:var(--paper);
}
.truth-item-n {
font:700 .62rem/1.7 var(--mono); letter-spacing:.1em;
color:var(--brass); flex-shrink:0; opacity:.7;
}
.truth-item-text { flex:1; }
/* Project sub-timeline row: dashed rail above, bar below */
.tl-row.project-tl {
height: 36px;
overflow: visible;
}
/* Dashed sub-rail at pin level */
.tl-row.project-tl::before {
content: '';
position: absolute;
left: 0; right: 0;
top: 7px;
border-top: 1px dashed rgba(232,220,196,.2);
z-index: 0;
pointer-events: none;
}
/* Project bar sits in lower half */
.tl-row.project-tl .tl-bar {
top: 16px;
height: 18px;
}
/* Pins sit on the dashed rail */
.tl-project-evt-dot {
position: absolute;
width: 10px; height: 10px;
border-radius: 50%;
top: 7px;
transform: translate(-50%, -50%);
z-index: 3; cursor: pointer;
border: 2px solid var(--void);
box-shadow: 0 1px 3px rgba(0,0,0,.5);
}
.tl-project-evt-dot.light { background: var(--paper); border-color: var(--brass); }
.tl-project-evt-dot.dark { background: var(--rust); border-color: var(--void); }
.tl-bar-add-overlay {
position: absolute; top: 16px; bottom: 0;
z-index: 2; cursor: crosshair; background: transparent;
}
.tl-connector {
position: absolute;
width: 0;
border-left: 1px dashed rgba(232,220,196,.22);
transform: translateX(-50%);
pointer-events: none;
z-index: 0;
}
.project-events {
margin-top:.8rem; padding-top:.8rem; border-top:1px solid var(--line);
}
.project-events-label {
font:700 .62rem/1 var(--mono); letter-spacing:.12em; text-transform:uppercase;
color:var(--muted); margin-bottom:.5rem;
}
.project-events-list { display:flex; flex-direction:column; gap:.3rem; }
.project-event-link {
font:.85rem/1.4 var(--sans); color:var(--muted); text-decoration:none;
display:flex; align-items:baseline; gap:.5rem;
}
.project-event-link:hover { color:var(--paper); }
.project-event-date { font:.62rem/1 var(--mono); color:var(--muted); flex-shrink:0; }
/* Autocomplete dropdown */
.ac-wrap { position: relative; }
.ac-dropdown {
position: absolute;
bottom: calc(100% + 4px);
left: 0; right: 0;
background: var(--panel);
border: 1px solid rgba(232,220,196,.3);
box-shadow: 0 -12px 32px rgba(0,0,0,.5);
max-height: 220px;
overflow-y: auto;
z-index: 200;
}
.ac-item {
padding: .55rem .85rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: .12rem;
border-bottom: 1px solid rgba(232,220,196,.07);
font: .88rem/1.3 var(--sans);
color: var(--text);
}
.ac-item:last-child { border-bottom: none; }
.ac-item:hover, .ac-item.ac-sel { background: rgba(184,138,67,.12); color: var(--paper); }
.ac-sub { font: .62rem/1 var(--mono); color: var(--muted); }
.ac-empty { padding: .55rem .85rem; font: .72rem/1 var(--mono); color: var(--muted); font-style: italic; }
/* Keyword links inside rendered descriptions */
.kw-link { color: var(--brass); text-decoration: none; border-bottom: 1px solid rgba(184,138,67,.35); cursor: pointer; font-weight: 600; }
.kw-link:hover { color: var(--paper); }
/* Glossary usage pills */
.kw-usage { font: .62rem/1 var(--mono); color: var(--muted); letter-spacing: .06em; text-transform: uppercase; text-decoration: none; border-bottom: 1px dotted rgba(168,157,137,.4); }
.kw-usage:hover { color: var(--text); }
.kw-usage-row { display: flex; gap: .7rem; flex-wrap: wrap; margin-top: .75rem; }
.loading-msg { padding: 6rem 0; text-align: center; font: .9rem/1 var(--mono); color: var(--muted); }
.error-msg { padding: 4rem; text-align: center; }
.error-msg h3 { color: var(--rust); margin-bottom: .5rem; }
.error-msg p { font: .9rem/1.5 var(--mono); color: var(--muted); }
@media (max-width: 640px) {
.entry { grid-template-columns: 1fr; }
.entry-actions { justify-content: flex-start; }
}
[x-cloak] { display: none !important; }
</style>
</head>
<body x-data="gameApp()" x-cloak>
<nav aria-label="Session navigation">
<div class="shell">
<div class="nav-left">
<a href="index.html">Archive</a>
<a href="games.html">Sessions</a>
<a x-show="game" x-text="game && game.title" style="color:var(--paper);pointer-events:none;"></a>
</div>
<div style="display:flex;align-items:center;gap:.6rem">
<select x-show="game && (cards.length || customDecks.length)"
x-model="activeDeckId"
style="background:transparent;border:1px solid var(--line);color:var(--muted);
font:650 .65rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
padding:.45rem .6rem;cursor:pointer;appearance:none">
<option value="">Default Cards</option>
<template x-for="d in customDecks" :key="d.id">
<option :value="d.id" x-text="d.name" style="background:var(--panel)"></option>
</template>
</select>
<button
x-show="game && (cards.length || customDecks.length)"
class="edit-toggle locked"
style="border-color:rgba(184,138,67,.35)"
@click="drawCard()"
>Draw Card</button>
<button
x-show="game"
class="edit-toggle"
:class="editMode ? 'unlocked' : 'locked'"
@click="editMode ? lock() : openUnlock()"
>
<span x-text="editMode ? '⊙ Editing' : '⊘ Read-only'"></span>
</button>
</div>
</div>
</nav>
<div class="loading-msg" x-show="loading">Loading session…</div>
<div class="error-msg" x-show="!loading && error">
<h3 x-text="error"></h3>
<p>Check the URL or return to <a href="games.html" style="color:var(--brass)">all sessions</a>.</p>
</div>
<main class="shell" x-show="!loading && !error && game">
<!-- Game header -->
<div class="game-header">
<div class="eyebrow">Colony Chronicle // Session Record</div>
<div class="game-header-top">
<div class="game-header-title">
<h2 x-text="game && game.title"></h2>
<div class="game-meta-row">
<span class="mood-badge" :class="game && game.mood" x-text="game && (game.mood === 'dark' ? '◉' : '○')"></span>
<span class="game-dates" x-text="game && (game.timeline_start + ' → ' + game.timeline_end)"></span>
</div>
<p class="game-desc" x-show="game && game.description" x-text="game && game.description"></p>
</div>
<div class="game-header-actions" x-show="editMode">
<button class="btn sm" @click="openEditGame()">Edit Session Details</button>
</div>
<div class="game-meta-row" x-show="customDecks.length" style="margin-top:1rem;gap:.6rem">
<span style="font:700 .65rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted)">Deck</span>
<select x-model="activeDeckId" @change="saveDeckToGame()" style="background:var(--panel);border:1px solid var(--line);color:var(--text);font:700 .65rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:.35rem .7rem;cursor:pointer;appearance:none">
<option value="">Default Cards</option>
<template x-for="d in customDecks" :key="d.id">
<option :value="d.id" x-text="d.name"></option>
</template>
</select>
</div>
</div>
</div>
<!-- Truths -->
<div class="section" x-show="(game && game.truths && game.truths.length) || editMode" style="margin-bottom:3rem">
<div class="section-header">
<div class="section-label">Truths</div>
<button class="btn sm" x-show="editMode" @click="openEditGame()">Edit</button>
</div>
<div class="truth-list" x-show="game && game.truths && game.truths.length">
<template x-for="(t, i) in (game && game.truths || [])" :key="i">
<div class="truth-item">
<span class="truth-item-n" x-text="String(i+1).padStart(2,'0')"></span>
<span class="truth-item-text" x-text="t"></span>
</div>
</template>
</div>
<div x-show="editMode && (!game || !game.truths || !game.truths.length)" class="empty-section">
No truths established. Add the absolute rules of your setting in Edit Session Details.
</div>
</div>
<!-- Timeline -->
<div class="timeline-section">
<div class="chronicle-eyebrow" style="margin-bottom:.8rem">Colony Chronicle // Timeline</div>
<div class="tl-wrap">
<!-- Callout canvas for events -->
<div class="tl-canvas"
:style="`height:${tlHeight()}px;${editMode ? 'cursor:crosshair' : ''}`"
@click="handleTimelineClick($event)">
<!-- Rail -->
<div class="tl-rail-line" :style="`top: ${tlRailY()}px`"></div>
<!-- All callout items (anchors + events) -->
<template x-for="e in tlItems" :key="e.id">
<div>
<!-- Stem -->
<div class="tl-stem" :style="tlStemStyle(e)"></div>
<!-- Dot -->
<div
class="tl-evt-dot"
:class="[e.mood || (game && game.mood) || 'light', e.isAnchor ? 'anchor-dot' : '', e.discipline ? 'disc-icon' : '']"
:style="tlDotStyle(e)"
:title="e.title + ' — ' + e.date"
x-text="e.discipline ? disciplineIcon(e.discipline) : ''"
></div>
<!-- Label — links to entry section -->
<a class="tl-lbl" :style="tlLabelStyle(e)" :href="e.isAnchor ? '#section-events' : '#entry-' + e.id" style="text-decoration:none">
<div class="tl-lbl-name" :class="e.isAnchor ? 'is-anchor' : ''" x-text="e.title"></div>
<div class="tl-lbl-date" x-text="e.date"></div>
</a>
</div>
</template>
</div>
<!-- Project bars -->
<div x-show="projects().length > 0">
<div class="tl-sublabel">Projects</div>
<div class="tl-rows">
<template x-for="item in projects()" :key="item.id">
<div class="tl-row project-tl">
<a
class="tl-bar project-bar"
:class="[item.mood || (game && game.mood), !item.end_date ? 'open-right' : '']"
:style="`left:${posPercent(item.date)}%;width:${barWidth(item.date,item.end_date)}%`"
:title="item.title + ' — ' + (item.status || 'active')"
:href="'#entry-' + item.id"
style="text-decoration:none"
>
<span class="tl-bar-text" x-text="item.title"></span>
</a>
<!-- Crosshair zone: click to add event at that date (edit mode) -->
<div x-show="editMode"
class="tl-bar-add-overlay"
:style="`left:${posPercent(item.date)}%;width:${barWidth(item.date,item.end_date)}%`"
@click="openAddEventToProject(item, $event)"></div>
<!-- Event dots for this project -->
<template x-for="ev in eventsForProject(item.id)" :key="ev.id">
<a class="tl-project-evt-dot"
:class="ev.mood || (game && game.mood) || 'light'"
:style="`left:${spreadPct(ev.id, ev.date)}%`"
:href="'#entry-' + ev.id"
:title="ev.title + ' — ' + ev.date"
@click.stop></a>
</template>
</div>
</template>
</div>
</div>
<!-- Cast bars -->
<div x-show="cast().length > 0">
<div class="tl-sublabel">Cast</div>
<div class="tl-rows">
<template x-for="(item, idx) in cast()" :key="item.id">
<div class="tl-row">
<a
class="tl-bar"
:class="{ 'open-right': !item.end_date, 'open-left': item.date === (game && game.timeline_start) }"
:style="`left:${posPercent(item.date)}%;width:${barWidth(item.date,item.end_date)}%;background:${castColor(idx)};opacity:.82`"
:title="item.title + (item.end_date ? ' — died ' + item.end_date : ' — active')"
:href="'#entry-' + item.id"
style="text-decoration:none"
>
<span class="tl-bar-text" x-text="item.title"></span>
</a>
</div>
</template>
</div>
</div>
<!-- Vertical connectors: main rail → project pin -->
<template x-for="line in projectConnectors()" :key="line.id">
<div class="tl-connector"
:style="`left:${line.pct}%;top:${line.top}px;height:${line.height}px`">
</div>
</template>
</div><!-- /.tl-wrap -->
</div>
<img class="divider" src="assets/divider.svg" alt="" />
<!-- Events -->
<div class="chronicle-section" x-show="allEventsForDisplay().length > 0 || editMode" id="section-events">
<div class="chronicle-header-row">
<div>
<div class="chronicle-eyebrow">Colony Chronicle</div>
<h2 class="chronicle-title">Events</h2>
</div>
<button class="btn sm" x-show="editMode" @click="openAddItem('event')">+ Add Event</button>
</div>
<div class="entry-list">
<template x-for="item in allEventsForDisplay()" :key="item.id">
<div class="entry" :id="'entry-' + item.id">
<div class="entry-body" :class="item.env ? 'env-' + (item.env_layout || 'after') : ''">
<div class="entry-eyebrow">
<span x-text="item.date"></span>
<span x-show="item.isAnchor"><span class="sep"> // </span><span class="e-anchor" x-text="item.anchorType === 'start' ? 'Opening' : 'Closing'"></span></span>
<span x-show="projectForEvent(item)"><span class="sep"> // </span><a :href="'#entry-' + item.project_id" style="color:var(--oxygen);text-decoration:none" x-text="projectForEvent(item)?.title"></a></span>
<span x-show="item.discipline"><span class="sep"> // </span><span class="disc-emoji" x-text="disciplineIcon(item.discipline)"></span> <span x-text="item.discipline"></span></span>
<span x-show="item.mood"><span class="sep"> // </span><span :class="item.mood === 'dark' ? 'e-dark' : ''" x-text="item.mood === 'dark' ? '◉' : '○'"></span></span>
</div>
<div class="entry-title" x-text="item.title"></div>
<div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div>
<div class="entry-env" x-show="item.env || deckCache[item.id]">
<img
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
:alt="item.title" loading="lazy"
/>
</div>
</div>
<div class="entry-actions" x-show="editMode && !item.isAnchor">
<button class="btn sm ghost" @click="openEditItem(item)">Edit</button>
<button class="btn sm danger" @click="deleteItem(item.id)">Delete</button>
</div>
</div>
</template>
</div>
<div x-show="allEventsForDisplay().length === 0 && editMode" class="empty-section">
Events are point-in-time occurrences — discoveries, incidents, decisions. Enter edit mode and add the first one to begin the chronicle.
</div>
</div>
<img class="divider" src="assets/divider.svg" alt="" />
<!-- Projects -->
<div class="chronicle-section" x-show="projects().length > 0 || editMode" id="section-projects">
<div class="chronicle-header-row">
<div>
<div class="chronicle-eyebrow">Colony Chronicle</div>
<h2 class="chronicle-title">Projects</h2>
</div>
<button class="btn sm" x-show="editMode" @click="openAddItem('project')">+ Add Project</button>
</div>
<div class="entry-list">
<template x-for="item in projects()" :key="item.id">
<div class="entry" :id="'entry-' + item.id">
<div class="entry-body" :class="item.env ? 'env-' + (item.env_layout || 'after') : ''">
<div class="entry-eyebrow">
<span x-text="item.date"></span>
<span x-show="item.end_date"> → <span x-text="item.end_date"></span></span>
<span x-show="!item.end_date" class="e-muted"> → ongoing</span>
<span class="sep"> // </span>
<span :class="(item.status || 'active') === 'completed' ? 'e-teal' : (item.status === 'abandoned' ? 'e-muted' : '')" x-text="item.status || 'active'"></span>
<span x-show="item.discipline"><span class="sep"> // </span><span class="disc-emoji" x-text="disciplineIcon(item.discipline)"></span> <span x-text="item.discipline"></span></span>
</div>
<div class="entry-title" x-text="item.title"></div>
<div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div>
<div class="project-events" x-show="eventsForProject(item.id).length > 0">
<div class="project-events-label">Events in this project</div>
<div class="project-events-list">
<template x-for="ev in eventsForProject(item.id)" :key="ev.id">
<a class="project-event-link" :href="'#entry-' + ev.id">
<span class="project-event-date" x-text="ev.date"></span>
<span x-text="ev.title"></span>
</a>
</template>
</div>
</div>
<div class="entry-env" x-show="item.env || deckCache[item.id]">
<img
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
:alt="item.title" loading="lazy"
/>
</div>
</div>
<div class="entry-actions" x-show="editMode">
<button class="btn sm" @click="openAddEventToProject(item, null)">+ Event</button>
<button class="btn sm ghost" @click="openEditItem(item)">Edit</button>
<button class="btn sm danger" @click="deleteItem(item.id)">Delete</button>
</div>
</div>
</template>
</div>
<div x-show="projects().length === 0 && editMode" class="empty-section">
Projects span periods of time — construction efforts, research programmes, social movements.
</div>
</div>
<img class="divider" src="assets/divider.svg" alt="" />
<!-- Cast -->
<div class="chronicle-section" x-show="cast().length > 0 || editMode" id="section-cast">
<div class="chronicle-header-row">
<div>
<div class="chronicle-eyebrow">Colony Chronicle</div>
<h2 class="chronicle-title">Cast</h2>
</div>
<button class="btn sm" x-show="editMode" @click="openAddItem('person')">+ Add Cast Member</button>
</div>
<div class="entry-list">
<template x-for="(item, idx) in cast()" :key="item.id">
<div class="entry" :id="'entry-' + item.id">
<div class="entry-body" :class="item.env ? 'env-' + (item.env_layout || 'after') : ''">
<div class="entry-eyebrow">
<span class="cast-dot" :style="`background:${castColor(idx)}`"></span>
Arrived <span x-text="item.date"></span>
<span x-show="item.end_date"><span class="sep"> // </span><span class="e-dark">Died <span x-text="item.end_date"></span></span></span>
<span x-show="!item.end_date"><span class="sep"> // </span>Active</span>
</div>
<div class="entry-title" x-text="item.title"></div>
<div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div>
<div class="kw-usage-row" x-show="castUsages(item.title).length > 0">
<template x-for="ref in castUsages(item.title)" :key="ref.id">
<a class="kw-usage" :href="'#entry-' + ref.id" x-text="ref.title"></a>
</template>
</div>
<div class="entry-env" x-show="item.env || deckCache[item.id]">
<img
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
:alt="item.title" loading="lazy"
/>
</div>
</div>
<div class="entry-actions" x-show="editMode">
<button class="btn sm ghost" @click="openEditItem(item)">Edit</button>
<button class="btn sm danger" @click="deleteItem(item.id)">Delete</button>
</div>
</div>
</template>
</div>
<div x-show="cast().length === 0 && editMode" class="empty-section">
Use <code style="font-family:var(--mono);font-size:.9em">@"Name"</code> in descriptions to reference cast members. They appear here and cross-link throughout the chronicle.
</div>
</div>
<img class="divider" src="assets/divider.svg" alt="" />
<!-- Glossary -->
<div class="chronicle-section" x-show="allKeywords().length > 0 || editMode" id="section-glossary">
<div class="chronicle-header-row">
<div>
<div class="chronicle-eyebrow">Colony Chronicle</div>
<h2 class="chronicle-title">Glossary</h2>
</div>
<button class="btn sm" x-show="editMode" @click="openAddKeyword()">+ Define Term</button>
</div>
<div class="entry-list">
<template x-for="kw in allKeywords()" :key="kw.term">
<div class="entry">
<div class="entry-body" :class="item.env ? 'env-' + (item.env_layout || 'after') : ''">
<div class="entry-title" x-text="kw.term"></div>
<div class="entry-desc" x-show="kw.definition" x-html="renderDesc(kw.definition)"></div>
<div class="entry-desc" x-show="!kw.definition" style="font-style:italic;color:var(--muted)">No definition yet.</div>
<div class="kw-usage-row" x-show="keywordUsages(kw.term).length > 0">
<template x-for="item in keywordUsages(kw.term)" :key="item.id">
<a class="kw-usage" :href="'#entry-' + item.id" x-text="item.title"></a>
</template>
</div>
</div>
<div class="entry-actions" x-show="editMode">
<button class="btn sm ghost" @click="openEditKeyword(kw)" x-text="kw.definition ? 'Edit' : 'Define'"></button>
<button class="btn sm danger" x-show="kw.id" @click="deleteKeyword(kw)">Delete</button>
</div>
</div>
</template>
</div>
<div x-show="allKeywords().length === 0 && editMode" class="empty-section">
Bold text becomes a keyword — write <code style="font-family:var(--mono);font-size:.9em">**term**</code> in any description to register it here.
</div>
</div>
</main>
<!-- Card modal -->
<div class="card-overlay" x-show="showCard" @click.self="showCard = false" @keydown.escape.window="showCard = false">
<div class="card-frame" x-show="drawnCard">
<!-- Custom card image shown as subtle background within the inner frame -->
<img x-show="drawnCard?.cardImage" class="card-image-bg"
:src="'assets/deck-images/' + drawnCard?.cardImage" alt="" />
<div class="card-inner">
<div class="card-content">
<div class="card-type-label" x-text="drawnCard && drawnCard.type === 'question' ? 'Question' : 'Action'"></div>
<div class="card-title" x-text="drawnCard && drawnCard.title"></div>
<div class="card-question" x-show="drawnCard && drawnCard.question" x-text="drawnCard && drawnCard.question"></div>
<div class="card-divider"></div>
<div class="card-options">
<div class="card-option">
<span class="card-option-label">A —</span>
<span x-html="drawnCard && drawnCard.a"></span>
</div>
<div class="card-option">
<span class="card-option-label">B —</span>
<span x-html="drawnCard && drawnCard.b"></span>
</div>
</div>
</div>
<button class="card-dismiss" @click="showCard = false">Dismiss</button>
</div>
</div>
</div>
<!-- Live update toast -->
<div x-show="liveUpdate"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform translate-y-2"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-end="opacity-0"
style="position:fixed;bottom:1.5rem;right:1.5rem;z-index:400;background:var(--panel);border:1px solid rgba(112,121,78,.5);color:var(--moss);font:700 .62rem/1 var(--mono);letter-spacing:.16em;text-transform:uppercase;padding:.55rem 1rem;pointer-events:none">
Chronicle updated
</div>
<!-- Timeline click chooser — dismiss on outside click via transparent backdrop -->
<div x-show="tlChooser.show"
style="position:fixed;inset:0;z-index:199"
@click="tlChooser.show = false"
@keydown.escape.window="tlChooser.show = false"></div>
<div class="tl-chooser"
x-show="tlChooser.show"
:style="`left:${tlChooser.x}px;top:${tlChooser.y}px`"
@click.stop>
<button class="tl-chooser-btn" @click="openAddFromTimeline('event')">+ Event at <span x-text="tlChooser.date"></span></button>
<button class="tl-chooser-btn" @click="openAddFromTimeline('project')">+ Project from <span x-text="tlChooser.date"></span></button>
</div>
<footer>
<div class="shell">
<p>Orison Reach Public Archive<br />Civilian access edition</p>
<p>Knowledge builds tomorrow<br />Work keeps it standing</p>
</div>
</footer>
<!-- Modal — all sections use x-show (not x-if) so Alpine bindings are always live -->
<div class="modal-overlay" x-show="showModal" @click="closeModal()" @keydown.escape.window="closeModal()">
<div class="modal" @click.stop>
<div class="modal-title" x-text="modalTitle()"></div>
<!-- Unlock -->
<div x-show="modalType === 'unlock'">
<div class="form-group">
<label class="form-label">Password <span>*</span></label>
<input type="password" x-model="activeForm.password" @keydown.enter="unlock()" />
</div>
<div class="form-error" x-show="formError" x-text="formError"></div>
<div class="modal-actions">
<button class="btn ghost" @click="closeModal()">Cancel</button>
<button class="btn primary" @click="unlock()" :disabled="saving">
<span x-text="saving ? 'Checking…' : 'Unlock'"></span>
</button>
</div>
</div>
<!-- Edit Game -->
<div x-show="modalType === 'editGame'">
<div class="form-group">
<label class="form-label">Title <span>*</span></label>
<input type="text" x-model="activeForm.title" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea x-model="activeForm.description"></textarea>
</div>
<div class="form-group">
<label class="form-label">Overall Mood</label>
<div class="mood-row">
<label class="mood-radio"><input type="radio" x-model="activeForm.mood" value="light" /><span>Light</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.mood" value="dark" /><span>Dark</span></label>
</div>
</div>
<div class="date-row">
<div class="form-group">
<label class="form-label">Timeline Start <span>*</span></label>
<input type="date" x-model="activeForm.timeline_start" />
</div>
<div class="form-group">
<label class="form-label">Timeline End <span>*</span></label>
<input type="date" x-model="activeForm.timeline_end" />
</div>
</div>
<div class="form-section">
<div class="form-section-title">Opening Event</div>
<div class="form-group">
<label class="form-label">Title</label>
<input type="text" x-model="activeForm.start_title" placeholder="Where does this story begin?" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea x-model="activeForm.start_description" placeholder="The circumstances of the opening moment…" style="min-height:60px"></textarea>
</div>
<div class="form-group">
<label class="form-label">Mood</label>
<div class="mood-row">
<label class="mood-radio"><input type="radio" x-model="activeForm.start_mood" value="" /><span>Inherit</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.start_mood" value="light" /><span>Light</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.start_mood" value="dark" /><span>Dark</span></label>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Closing Event</div>
<div class="form-group">
<label class="form-label">Title</label>
<input type="text" x-model="activeForm.end_title" placeholder="Where does this story end?" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea x-model="activeForm.end_description" placeholder="The circumstances of the closing moment…" style="min-height:60px"></textarea>
</div>
<div class="form-group">
<label class="form-label">Mood</label>
<div class="mood-row">
<label class="mood-radio"><input type="radio" x-model="activeForm.end_mood" value="" /><span>Inherit</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.end_mood" value="light" /><span>Light</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.end_mood" value="dark" /><span>Dark</span></label>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Card Deck</div>
<div class="form-group">
<label class="form-label">Default deck for this session</label>
<select x-model="activeForm.deck_id">
<option value="">Default Cards (built-in)</option>
<template x-for="d in customDecks" :key="d.id">
<option :value="d.id" x-text="d.name"></option>
</template>
</select>
<div class="form-hint">Players will draw from this deck by default. Can be overridden per-session in the nav.</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Truths</div>
<p style="font:.82rem/1.5 var(--sans);color:var(--muted);margin:0 0 .8rem">Absolute rules of the setting. Nothing created during play can contradict them.</p>
<div style="display:flex;flex-direction:column;gap:.4rem;margin-bottom:.8rem">
<template x-for="(t, i) in (activeForm.truths || [])" :key="i">
<div style="display:flex;align-items:center;gap:.6rem;padding:.5rem .8rem;border-left:2px solid var(--brass);background:rgba(184,138,67,.05)">
<span style="flex:1;font:italic .9rem/1.5 var(--serif);color:var(--paper)" x-text="t"></span>
<button class="btn sm danger" @click="activeForm.truths.splice(i,1)">✕</button>
</div>
</template>
</div>
<div style="display:flex;gap:.6rem">
<input type="text" x-model="newTruth" placeholder="e.g. There is no faster-than-light travel."
@keydown.enter.prevent="if(newTruth.trim()){(activeForm.truths=activeForm.truths||[]).push(newTruth.trim());newTruth=''}"
style="flex:1" />
<button class="btn sm" @click="if(newTruth.trim()){(activeForm.truths=activeForm.truths||[]).push(newTruth.trim());newTruth=''}">Add</button>
</div>
</div>
<div class="form-error" x-show="formError" x-text="formError"></div>
<div class="modal-actions">
<button class="btn ghost" @click="closeModal()">Cancel</button>
<button class="btn primary" @click="saveGame()" :disabled="saving">
<span x-text="saving ? 'Saving…' : 'Save'"></span>
</button>
</div>
</div>
<!-- Add/Edit Item (event / project / person) -->
<div x-show="modalType === 'item'">
<div class="form-group">
<label class="form-label" x-text="activeForm.type === 'person' ? 'Name *' : 'Title *'"></label>
<input type="text" x-model="activeForm.title"
:placeholder="activeForm.type === 'event' ? 'What happened?' : activeForm.type === 'project' ? 'Project name' : 'Character name'" />
</div>
<!-- Event: single date + optional project -->
<div x-show="activeForm.type === 'event'">
<div class="form-group">
<label class="form-label">Date <span>*</span></label>
<input type="date" x-model="activeForm.date" :min="game && game.timeline_start" :max="game && game.timeline_end" />
</div>
<div class="form-group" x-show="projects().length">
<label class="form-label">Project (optional)</label>
<select x-model="activeForm.project_id">
<option value="">— Standalone event —</option>
<template x-for="p in projects()" :key="p.id">
<option :value="p.id" x-text="p.title"></option>
</template>
</select>
</div>
</div>
<!-- Project: start + end + status -->
<div x-show="activeForm.type === 'project'">
<div class="date-row">
<div class="form-group">
<label class="form-label">Start Date <span>*</span></label>
<input type="date" x-model="activeForm.date" :min="game && game.timeline_start" :max="game && game.timeline_end" />
</div>
<div class="form-group">
<label class="form-label">End Date</label>
<input type="date" x-model="activeForm.end_date" :min="activeForm.date" :max="game && game.timeline_end" />
<div class="form-hint">Leave blank if ongoing.</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select x-model="activeForm.status">
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="abandoned">Abandoned</option>
</select>
</div>
</div>
<!-- Person: arrival + death -->
<div class="date-row" x-show="activeForm.type === 'person'">
<div class="form-group">
<label class="form-label">Arrived <span>*</span></label>
<input type="date" x-model="activeForm.date" :min="game && game.timeline_start" :max="game && game.timeline_end" />
</div>
<div class="form-group">
<label class="form-label">Died</label>
<input type="date" x-model="activeForm.end_date" :min="activeForm.date" :max="game && game.timeline_end" />
<div class="form-hint">Leave blank if alive.</div>
</div>
</div>
<!-- Mood + discipline + environment (not for persons) -->
<div x-show="activeForm.type !== 'person'">
<div class="date-row">
<div class="form-group">
<label class="form-label">Mood</label>
<select x-model="activeForm.mood">
<option value="">Inherit</option>
<option value="light">○ Light</option>
<option value="dark">◉ Dark</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Discipline</label>
<select x-model="activeForm.discipline"
@change="if(!activeForm.env) { activeForm.env = randomEnv($event.target.value); if(activeForm.env) activeForm.env_layout = randomEnvLayout() }">
<option value="">None</option>
<option value="science">🔬 Science</option>
<option value="engineering">⚙️ Engineering</option>
<option value="labour">👷 Labour</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Environment Image</label>
<select x-model="activeForm.env">
<option value="">None</option>
<optgroup label="Central Environments">
<option value="env-grand-hall">Grand Hall</option>
<option value="env-workshop">Workshop</option>
<option value="env-greenhouse">Greenhouse</option>
<option value="env-library">Library</option>
<option value="env-reactor">Reactor</option>
<option value="env-observatory">Observatory</option>
</optgroup>
<optgroup label="Engineering">
<option value="eng-blueprint">Blueprint Room</option>
<option value="eng-scaffold">Scaffolding</option>
<option value="eng-survey">Survey Exterior</option>
<option value="eng-planning-room">Planning Room</option>
<option value="eng-facility">Industrial Facility</option>
<option value="eng-construction">Construction Site</option>
</optgroup>
<optgroup label="Science">
<option value="sci-botanist">Botanist at Work</option>
<option value="sci-specimen-hall">Specimen Hall</option>
<option value="sci-laboratory">Laboratory</option>
<option value="sci-telescope">Telescope Room</option>
<option value="sci-excavation">Excavation Site</option>
<option value="sci-weather-station">Weather Station</option>
<option value="sci-specimen-tanks">Specimen Tanks</option>
<option value="sci-aquatic-lab">Aquatic Lab</option>
<option value="sci-growth-chamber">Growth Chamber</option>
<option value="sci-herbarium">Herbarium</option>
<option value="sci-globe-room">Globe Room</option>
<option value="sci-paleontology">Paleontology</option>
</optgroup>
<optgroup label="Labour">
<option value="lab-pipeline">Pipeline</option>
<option value="lab-tunnel">Tunnel</option>
<option value="lab-crane-lift">Crane Lift</option>
<option value="lab-shift-change">Shift Change</option>
<option value="lab-pour">Concrete Pour</option>
<option value="lab-solar-panels">Solar Panels</option>
<option value="lab-machinery">Machinery</option>
<option value="lab-transport">Transport</option>
<option value="lab-excavator">Excavator</option>
<option value="lab-scaffold">Scaffolding</option>
<option value="lab-canteen">Canteen</option>
<option value="lab-archway">Archway</option>
</optgroup>
<optgroup label="Atmospheric">
<option value="atm-corridor">Corridor</option>
<option value="atm-study">Study at Night</option>
<option value="atm-dome-rain">Dome in Rain</option>
<option value="atm-dawn">Dawn Exterior</option>
<option value="atm-sunset-city">Sunset City</option>
<option value="atm-moonrise">Moonrise</option>
<option value="atm-dust-storm">Dust Storm</option>
<option value="atm-reading-room">Reading Room</option>
<option value="atm-breakroom">Breakroom</option>
<option value="atm-cemetery">Cemetery</option>
<option value="atm-festival">Festival</option>
<option value="atm-children-play">Children Playing</option>
<option value="atm-night-observatory">Night Observatory</option>
<option value="atm-garden-light">Garden Light</option>
<option value="atm-lantern-hall">Lantern Hall</option>
<option value="atm-blizzard">Blizzard</option>
</optgroup>
</select>
</div>
<div class="form-group" x-show="activeForm.env">
<label class="form-label">Image Layout</label>
<div style="display:flex;gap:1.4rem">
<label class="mood-radio"><input type="radio" x-model="activeForm.env_layout" value="after" /><span>Below</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.env_layout" value="left" /><span>Left</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.env_layout" value="right" /><span>Right</span></label>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<div class="ac-wrap">
<textarea x-model="activeForm.description"
:placeholder="activeForm.type === 'person' ? 'Role, background, notes…' : 'What happened, why it matters… Use @Name or **keyword**'"
@input="handleInput($event, 'description')"
@keydown="handleKeydown($event)"
@blur="$nextTick(() => acShow = false)"></textarea>
<div class="ac-dropdown" x-show="acShow && acField === 'description'" @mousedown.prevent>
<template x-for="(r, i) in acResults()" :key="i">
<div class="ac-item" :class="i === acIndex ? 'ac-sel' : ''" @click="selectAcResult(r)">
<span x-text="acMode === 'mention' ? r.title : r.term"></span>
<span class="ac-sub" x-show="acMode === 'keyword' && r.definition" x-text="r.definition"></span>
</div>
</template>
<div class="ac-empty" x-show="acResults().length === 0">
<span x-show="acMode === 'mention'">No matching cast members</span>
<span x-show="acMode === 'keyword'">New keyword — registered on save</span>
</div>
</div>
</div>
</div>
<div class="form-error" x-show="formError" x-text="formError"></div>
<div class="modal-actions">
<button class="btn ghost" @click="closeModal()">Cancel</button>
<button class="btn primary" @click="saveItem()" :disabled="saving">
<span x-text="saving ? 'Saving…' : (editingId ? 'Save Changes' : 'Add to Timeline')"></span>
</button>
</div>
</div>
<!-- New cast members detected after save -->
<div x-show="modalType === 'castPrompt'">
<p style="color:var(--text);margin:0 0 1.4rem;font-size:.95rem;line-height:1.6">
These names were mentioned but aren't in the cast yet:
</p>
<div style="display:flex;flex-direction:column;gap:.8rem">
<template x-for="name in pendingCastNames" :key="name">
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.6rem 0;border-bottom:1px solid var(--line)">
<span style="font:600 1.1rem/1 var(--serif);color:var(--paper)" x-text="name"></span>
<button class="btn sm" @click="addPendingCast(name)">Add to Cast</button>
</div>
</template>
</div>
<div class="modal-actions">
<button class="btn ghost" @click="closeModal()">Skip</button>
</div>
</div>
<!-- Keyword definition -->
<div x-show="modalType === 'keyword'">
<div class="form-group">
<label class="form-label">Term</label>
<input type="text" x-model="activeForm.term" :readonly="!!editingId" :style="editingId ? 'opacity:.6' : ''" placeholder="Phase Crystal" />
</div>
<div class="form-group">
<label class="form-label">Definition</label>
<textarea x-model="activeForm.definition" placeholder="What does this term mean in this world?" style="min-height:100px"></textarea>
</div>
<div class="form-error" x-show="formError" x-text="formError"></div>
<div class="modal-actions">
<button class="btn ghost" @click="closeModal()">Cancel</button>
<button class="btn primary" @click="saveKeyword()" :disabled="saving">
<span x-text="saving ? 'Saving…' : 'Save'"></span>
</button>
</div>
</div>
</div>
</div>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
<script>
// Cast color palette — distinct, readable on dark backgrounds
const CAST_PALETTE = [
'#86a5a4','#c49a3c','#b05c3a','#7d9a5a',
'#8e7bbf','#c4876a','#4f9e9c','#a87c57',
'#9b7ab8','#5a8ea0','#b8834a','#6e9e6e'
]
function gameApp() {
return {
slug: new URLSearchParams(location.search).get('slug'),
game: null,
items: [],
keywords: [],
editMode: false,
loading: true,
error: null,
showModal: false,
modalType: null,
activeForm: {},
editingId: null,
formError: null,
saving: false,
tlItems: [],
_tlRows: { top: 1, bot: 1 },
// Image decks
deckImages: [],
deckCache: {},
customDecks: [], // full deck objects with cards
activeDeckId: '', // '' = default cards.json
// Live updates
liveUpdate: false,
// Card draw
cards: [],
drawnCard: null,
recentCardIds: [],
showCard: false,
// Timeline click chooser
tlChooser: { show: false, x: 0, y: 0, date: '' },
// Truths
newTruth: '',
// Autocomplete
acMode: null,
acQuery: '',
acIndex: 0,
acShow: false,
acField: null,
acEl: null,
// New cast prompt
pendingCastNames: [],
getPassword() {
return sessionStorage.getItem('game-pw-' + this.slug) || ''
},
authHeaders() {
return {
'Content-Type': 'application/json',
'X-Game-Password': this.getPassword()
}
},
async init() {
if (!this.slug) {
this.error = 'No session specified'
this.loading = false
return
}
this.loadCards()
this.loadDecks()
this.connectLive()
const ok = await this.loadGame()
if (!ok) { this.loading = false; return }
await Promise.all([this.loadItems(), this.loadKeywords()])
this.loading = false
if (this.getPassword()) {
const valid = await this.verifyPassword(this.getPassword())
if (valid) this.editMode = true
else sessionStorage.removeItem('game-pw-' + this.slug)
}
},
async loadGame() {
try {
const r = await fetch('/api/games/' + this.slug)
if (!r.ok) { this.error = 'Session not found'; return false }
this.game = await r.json()
document.title = this.game.title + ' — Orison Reach'
if (this.game.deck_id) this.activeDeckId = this.game.deck_id
return true
} catch {
this.error = 'Network error'
return false
}
},
async loadItems() {
try {
const r = await fetch('/api/games/' + this.slug + '/items')
this.items = r.ok ? await r.json() : []
} catch { this.items = [] }
this.tlItems = this.computeLayout()
this.buildDeckCache()
},
async loadDecks() {
try {
const r = await fetch('/api/decks')
if (!r.ok) return
const decks = await r.json()
this.customDecks = decks
this.deckImages = decks.flatMap(d => (d.images || []).map(img => ({
filename: img.filename, keywords: img.keywords || []
})))
this.buildDeckCache()
} catch {}
},
buildDeckCache() {
if (!this.deckImages.length) return
const cache = {}
for (const item of this.items) {
if (!item.env) cache[item.id] = this.pickDeckImage(item)
}
this.deckCache = cache
},
// Deterministic seed from item ID so image never changes on re-render
idSeed(id) {
return id.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 0)
},
pickDeckImage(item) {
if (!this.deckImages.length) return null
const tags = new Set()
if (item.mood) tags.add(item.mood.toLowerCase())
if (item.discipline) tags.add(item.discipline.toLowerCase())
for (const m of (item.description || '').matchAll(/\*\*([^*\n]+)\*\*/g))
tags.add(m[1].trim().toLowerCase())
const scored = this.deckImages.map(img => ({
img,
score: img.keywords.filter(k => tags.has(k.toLowerCase())).length + 1
}))
const total = scored.reduce((s, x) => s + x.score, 0)
let pick = (this.idSeed(item.id) % 10000) / 10000 * total
for (const { img, score } of scored) {
pick -= score
if (pick <= 0) return img.filename
}
return scored[0].img.filename
},
connectLive() {
if (!this.slug) return
const es = new EventSource('/api/games/' + this.slug + '/live')
es.onmessage = async (e) => {
if (e.data === 'connected') return
// Don't reload while a modal is open — pick it up when they close
if (this.showModal) return
await Promise.all([this.loadGame(), this.loadItems(), this.loadKeywords()])
this.liveUpdate = true
setTimeout(() => this.liveUpdate = false, 2500)
}
es.onerror = () => {
es.close()
setTimeout(() => this.connectLive(), 5000)
}
},
async loadCards() {
try {
const r = await fetch('/assets/cards.json')
this.cards = r.ok ? await r.json() : []
} catch { this.cards = [] }
},
drawCard() {
// Custom deck selected?
if (this.activeDeckId) {
const deck = this.customDecks.find(d => d.id === this.activeDeckId)
const pool = deck?.cards || []
if (!pool.length) return
const hasCast = this.cast().length > 0
const hasKeyword = this.allKeywords().length > 0
const hasProject = this.projects().length > 0
const meets = c => {
if (!c.requires?.length) return true
return c.requires.every(r =>
r==='cast'?hasCast:r==='keyword'?hasKeyword:r==='project'?hasProject:true)
}
const eligible = pool.filter(meets)
const avail = eligible.filter(c => !this.recentCardIds.includes(c.id))
const src = avail.length ? avail : eligible
if (!src.length) return
const picked = src[Math.floor(Math.random() * src.length)]
const resolved = this.resolveDynamic(picked)
// Pick random card image if the card has images
if (picked.images?.length) {
const img = picked.images[Math.floor(Math.random() * picked.images.length)]
resolved.cardImage = img.filename
}
this.drawnCard = resolved
this.recentCardIds = [...this.recentCardIds.slice(-9), picked.id]
this.showCard = true
return
}
if (!this.cards.length) return
const hasCast = this.cast().length > 0
const hasKeyword = this.allKeywords().length > 0
const hasProject = this.projects().length > 0
const meets = c => {
if (!c.requires) return true
return c.requires.every(r =>
r === 'cast' ? hasCast : r === 'keyword' ? hasKeyword : r === 'project' ? hasProject : true
)
}
const eligible = this.cards.filter(meets)
const available = eligible.filter(c => !this.recentCardIds.includes(c.id))
const pool = available.length > 0 ? available : eligible
if (!pool.length) return
const idx = Math.floor(Math.random() * pool.length)
this.drawnCard = this.resolveDynamic(pool[idx])
this.recentCardIds = [...this.recentCardIds.slice(-9), pool[idx].id]
this.showCard = true
},
resolveDynamic(card) {
const pick = arr => arr.length ? arr[Math.floor(Math.random() * arr.length)] : null
const castList = [...this.cast()]
const kwList = this.allKeywords()
const projectList = this.projects()
let usedCastIds = []
const resolve = text => text
.replace(/\{cast\}/g, () => {
const avail = castList.filter(c => !usedCastIds.includes(c.id))
const p = pick(avail.length ? avail : castList)
if (p) { usedCastIds.push(p.id); return `<em>${p.title.replace(/&/g,'&amp;').replace(/</g,'&lt;')}</em>` }
return 'a cast member'
})
.replace(/\{keyword\}/g, () => {
const p = pick(kwList)
return p ? `**${p.term}**` : 'an existing keyword'
})
.replace(/\{project\}/g, () => {
const p = pick(projectList)
return p ? `"${p.title}"` : 'an active project'
})
return { ...card, a: resolve(card.a), b: resolve(card.b) }
},
async loadKeywords() {
try {
const r = await fetch('/api/games/' + this.slug + '/keywords')
this.keywords = r.ok ? await r.json() : []
} catch { this.keywords = [] }
},
async verifyPassword(pw) {
try {
const r = await fetch('/api/games/' + this.slug + '/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
})
return r.ok
} catch { return false }
},
async unlock() {
if (!this.activeForm.password) { this.formError = 'Enter password'; return }
this.saving = true
this.formError = null
const ok = await this.verifyPassword(this.activeForm.password)
this.saving = false
if (ok) {
sessionStorage.setItem('game-pw-' + this.slug, this.activeForm.password)
this.editMode = true
this.closeModal()
} else {
this.formError = 'Incorrect password'
}
},
lock() {
sessionStorage.removeItem('game-pw-' + this.slug)
this.editMode = false
},
// --- Timeline layout ---
computeLayout() {
if (!this.game) return []
const MIN_GAP = 14 // min % between labels on the same side
const ICON_SEP = 4 // min % between consecutive dots on the rail
const topTrack = [], botTrack = []
const allItems = [
{
id: '__start__', isAnchor: true,
title: this.game.start_title || this.game.timeline_start,
date: this.game.timeline_start,
mood: this.game.start_mood || this.game.mood || 'light',
pct: 0
},
...this.events().map(e => ({ ...e, pct: this.posPercent(e.date) })),
{
id: '__end__', isAnchor: true,
title: this.game.end_title || this.game.timeline_end,
date: this.game.timeline_end,
mood: this.game.end_mood || this.game.mood || 'light',
pct: 100
}
]
let prevPct = -ICON_SEP * 10
const placed = allItems.map((item, i) => {
// Spread dots that would visually overlap on the rail.
// User said exact position matters less than visual separation.
const pct = Math.max(item.pct, prevPct + ICON_SEP)
const side = i % 2 === 0 ? 'top' : 'bottom'
const track = side === 'top' ? topTrack : botTrack
// Find first row where this label fits
let row = 0
while (row < track.length && pct - track[row] < MIN_GAP) row++
while (track.length <= row) track.push(Number.NEGATIVE_INFINITY)
track[row] = pct
prevPct = pct
return { ...item, pct, side, row }
})
this._tlRows = {
top: Math.max(topTrack.length, 1),
bot: Math.max(botTrack.length, 1)
}
return placed
},
tlRailY() {
const ROW_H = 48, STEM = 16
return STEM + this._tlRows.top * ROW_H
},
tlHeight() {
const ROW_H = 48, STEM = 16
return this.tlRailY() + 1 + STEM + this._tlRows.bot * ROW_H
},
tlStemStyle(e) {
const ROW_H = 48, STEM = 16
const railY = this.tlRailY()
const h = STEM + e.row * ROW_H
if (e.side === 'top') {
return `left:${e.pct}%;top:${railY - h}px;height:${h}px;`
} else {
return `left:${e.pct}%;top:${railY + 1}px;height:${h}px;`
}
},
tlDotStyle(e) {
const railY = this.tlRailY()
if (e.discipline) {
return `left:${e.pct}%;top:${railY}px;`
}
const sz = e.isAnchor ? 13 : 9
return `left:${e.pct}%;top:${railY}px;width:${sz}px;height:${sz}px;`
},
randomEnv(discipline) {
if (Math.random() > 0.33) return ''
const pools = {
science: ['sci-botanist','sci-specimen-hall','sci-laboratory','sci-telescope','sci-excavation','sci-weather-station','sci-specimen-tanks','sci-aquatic-lab','sci-growth-chamber','sci-herbarium','sci-globe-room','sci-paleontology'],
engineering: ['eng-blueprint','eng-scaffold','eng-survey','eng-planning-room','eng-facility','eng-construction'],
labour: ['lab-pipeline','lab-tunnel','lab-crane-lift','lab-shift-change','lab-pour','lab-solar-panels','lab-machinery','lab-transport','lab-excavator','lab-scaffold','lab-canteen','lab-archway'],
default: ['env-grand-hall','env-workshop','env-greenhouse','env-library','env-reactor','env-observatory','atm-corridor','atm-study','atm-dome-rain','atm-dawn','atm-sunset-city','atm-moonrise','atm-dust-storm','atm-reading-room','atm-breakroom','atm-cemetery','atm-festival','atm-children-play','atm-night-observatory','atm-garden-light','atm-lantern-hall','atm-blizzard'],
}
const pool = pools[discipline] || pools.default
return pool[Math.floor(Math.random() * pool.length)]
},
randomEnvLayout() {
const r = Math.random()
return r < 0.34 ? 'left' : r < 0.67 ? 'right' : 'after'
},
disciplineIcon(disc) {
return { science: '🔬', engineering: '⚙️', labour: '👷' }[disc] || ''
},
tlLabelStyle(e) {
const ROW_H = 48, STEM = 16, LBL_H = 34
const railY = this.tlRailY()
const h = STEM + e.row * ROW_H
const xAlign = e.pct < 8 ? 'translateX(0)' : e.pct > 92 ? 'translateX(-100%)' : 'translateX(-50%)'
if (e.side === 'top') {
return `left:${e.pct}%;top:${railY - h - LBL_H}px;transform:${xAlign};`
} else {
return `left:${e.pct}%;top:${railY + 1 + h}px;transform:${xAlign};`
}
},
// --- Cast colors ---
castColor(idx) {
return CAST_PALETTE[idx % CAST_PALETTE.length]
},
// --- Description renderer (@mentions) ---
renderDesc(text) {
if (!text) return ''
const escaped = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/\n/g, '<br>')
return escaped
.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*([^*\n]+)\*\*/g, '<a href="#section-glossary" class="kw-link">$1</a>')
.replace(/_([^_\n]+)_/g, '<em>$1</em>')
.replace(/@&quot;([^&]+)&quot;/g, (match, name) => {
const decoded = name.replace(/&amp;/g, '&')
const member = this.cast().find(p => p.title.toLowerCase() === decoded.toLowerCase())
if (member) {
return `<a href="#entry-${member.id}" class="cast-mention">@${decoded}</a>`
}
return `<span class="cast-mention unknown">@${decoded}</span>`
})
},
// --- Timeline click to add ---
handleTimelineClick(event) {
if (!this.editMode || !this.game) return
// Ignore clicks on links (callout labels, bar links)
if (event.target.closest('a, button')) return
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const pct = Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100))
this.tlChooser = {
show: true,
x: event.clientX,
y: event.clientY + 14,
date: this.pctToDate(pct)
}
},
pctToDate(pct) {
const start = new Date(this.game.timeline_start).getTime()
const end = new Date(this.game.timeline_end).getTime()
return new Date(Math.round(start + (pct / 100) * (end - start))).toISOString().split('T')[0]
},
openAddFromTimeline(type) {
const date = this.tlChooser.date
this.tlChooser.show = false
this.openAddItem(type)
this.activeForm.date = date
},
// --- Autocomplete ---
handleInput(event, field) {
this.acEl = event.target
this.acField = field
const before = event.target.value.slice(0, event.target.selectionStart)
// @mention: @ followed by chars that aren't @, " or newline, to end of before-cursor
const mentionMatch = before.match(/@([^@"\n]*)$/)
if (mentionMatch) {
this.acMode = 'mention'
this.acQuery = mentionMatch[1]
this.acIndex = 0
this.acShow = true
return
}
// **keyword: odd number of ** means we're inside an unclosed one
const kwCount = (before.match(/\*\*/g) || []).length
if (kwCount % 2 === 1) {
const lastKw = before.lastIndexOf('**')
const query = before.slice(lastKw + 2)
if (!query.includes('\n')) {
this.acMode = 'keyword'
this.acQuery = query
this.acIndex = 0
this.acShow = true
return
}
}
this.acShow = false
this.acMode = null
},
handleKeydown(event) {
if (!this.acShow) return
const results = this.acResults()
if (event.key === 'ArrowDown') {
event.preventDefault()
this.acIndex = Math.min(this.acIndex + 1, results.length - 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
this.acIndex = Math.max(this.acIndex - 1, 0)
} else if (event.key === 'Enter' || event.key === 'Tab') {
if (results.length > 0) {
event.preventDefault()
this.selectAcResult(results[this.acIndex])
}
} else if (event.key === 'Escape') {
this.acShow = false
}
},
selectAcResult(result) {
if (!this.acEl || !this.acField) return
const val = this.acEl.value
const pos = this.acEl.selectionStart
const before = val.slice(0, pos)
const after = val.slice(pos)
let newBefore
if (this.acMode === 'mention') {
const atIdx = before.lastIndexOf('@')
newBefore = before.slice(0, atIdx) + `@"${result.title}"`
} else {
const kwIdx = before.lastIndexOf('**')
newBefore = before.slice(0, kwIdx) + `**${result.term}**`
}
this.activeForm[this.acField] = newBefore + after
this.acShow = false
this.$nextTick(() => {
if (this.acEl) {
this.acEl.focus()
this.acEl.setSelectionRange(newBefore.length, newBefore.length)
}
})
},
acResults() {
if (!this.acShow) return []
if (this.acMode === 'mention') {
return this.cast().filter(c =>
c.title.toLowerCase().includes(this.acQuery.toLowerCase())
).slice(0, 8)
}
if (this.acMode === 'keyword') {
return this.allKeywords().filter(k =>
k.term.toLowerCase().includes(this.acQuery.toLowerCase())
).slice(0, 8)
}
return []
},
// --- New cast prompt ---
findUnmatchedMentions(text) {
if (!text) return []
const found = [...(text.matchAll(/@"([^"]+)"/g))].map(m => m[1])
const castNames = new Set(this.cast().map(c => c.title.toLowerCase()))
return [...new Set(found.filter(n => !castNames.has(n.toLowerCase())))]
},
addPendingCast(name) {
this.pendingCastNames = this.pendingCastNames.filter(n => n !== name)
this.openAddItem('person')
this.activeForm.title = name
},
// --- Keywords ---
allKeywords() {
const extracted = new Map()
const descs = [
...this.items.map(i => i.description || ''),
this.game?.start_description || '',
this.game?.end_description || '',
this.game?.description || ''
]
for (const d of descs) {
for (const m of d.matchAll(/\*\*([^*\n]+)\*\*/g)) {
const term = m[1].trim()
if (term) extracted.set(term.toLowerCase(), term)
}
}
const result = []
const seen = new Set()
for (const [key, term] of extracted) {
const stored = this.keywords.find(k => k.term.toLowerCase() === key)
result.push({ term, definition: stored?.definition || '', id: stored?.id || null })
seen.add(key)
}
for (const k of this.keywords) {
if (!seen.has(k.term.toLowerCase())) {
result.push({ term: k.term, definition: k.definition, id: k.id })
}
}
return result.sort((a, b) => a.term.localeCompare(b.term))
},
// Spread pct: use the adjusted position from tlItems so connectors/pins
// align with the main-rail callout dot (which gets nudged to avoid overlap).
spreadPct(evId, fallbackDate) {
const item = this.tlItems.find(i => i.id === evId)
return item ? item.pct : this.posPercent(fallbackDate)
},
// Vertical connector lines from main-rail event dots down to project pins.
projectConnectors() {
if (!this.game) return []
const projs = this.projects()
if (!projs.length) return []
const SUBLABEL_H = 32, ROW_H = 36, PIN_LEVEL = 7
return this.events()
.filter(ev => ev.project_id)
.map(ev => {
const idx = projs.findIndex(p => p.id === ev.project_id)
if (idx === -1) return null
const pct = this.spreadPct(ev.id, ev.date)
const topY = this.tlRailY()
const pinY = this.tlHeight() + SUBLABEL_H + idx * ROW_H + PIN_LEVEL
return { id: ev.id, pct, top: topY, height: Math.max(0, pinY - topY) }
})
.filter(Boolean)
},
openAddEventToProject(project, clickEvent) {
const env = this.randomEnv(project.discipline)
this.activeForm = {
type: 'event', title: '', description: '',
mood: project.mood || '', date: '', end_date: '',
status: 'active', discipline: project.discipline || '',
env, env_layout: env ? this.randomEnvLayout() : 'after',
project_id: project.id
}
// Calculate date from click position on the bar
if (clickEvent) {
const rect = clickEvent.currentTarget.getBoundingClientRect()
const pct = Math.max(0, Math.min(100, ((clickEvent.clientX - rect.left) / rect.width) * 100))
const start = new Date(project.date).getTime()
const end = new Date(project.end_date || (this.game && this.game.timeline_end)).getTime()
this.activeForm.date = new Date(Math.round(start + (pct / 100) * (end - start))).toISOString().split('T')[0]
}
this.editingId = null
this.formError = null
this.modalType = 'item'
this.showModal = true
},
eventsForProject(projectId) {
return this.events().filter(e => e.project_id === projectId)
},
projectForEvent(item) {
if (!item.project_id) return null
return this.projects().find(p => p.id === item.project_id) || null
},
castUsages(name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`@"${escaped}"`, 'i')
return this.items.filter(i => re.test(i.description || ''))
},
keywordUsages(term) {
const re = new RegExp('\\*\\*' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\*\\*', 'i')
return this.items.filter(i => re.test(i.description || ''))
},
openAddKeyword() {
this.activeForm = { term: '', definition: '' }
this.editingId = null
this.formError = null
this.modalType = 'keyword'
this.showModal = true
},
openEditKeyword(kw) {
this.activeForm = { term: kw.term, definition: kw.definition || '' }
this.editingId = kw.id
this.formError = null
this.modalType = 'keyword'
this.showModal = true
},
async saveKeyword() {
if (!this.activeForm.term) { this.formError = 'Term is required'; return }
this.saving = true; this.formError = null
try {
const url = this.editingId
? '/api/games/' + this.slug + '/keywords/' + this.editingId
: '/api/games/' + this.slug + '/keywords'
const r = await fetch(url, {
method: this.editingId ? 'PUT' : 'POST',
headers: this.authHeaders(),
body: JSON.stringify(this.activeForm)
})
if (r.status === 401) { this.lock(); this.closeModal(); return }
const data = await r.json()
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
await this.loadKeywords()
this.closeModal()
} catch { this.formError = 'Network error' }
finally { this.saving = false }
},
async deleteKeyword(kw) {
if (!kw.id || !confirm(`Remove definition for "${kw.term}"?`)) return
try {
const r = await fetch('/api/games/' + this.slug + '/keywords/' + kw.id, {
method: 'DELETE', headers: this.authHeaders()
})
if (r.ok) await this.loadKeywords()
} catch {}
},
// --- Filtered lists ---
events() {
return this.items.filter(i => i.type === 'event').sort((a, b) => a.date.localeCompare(b.date))
},
projects() {
return this.items.filter(i => i.type === 'project').sort((a, b) => a.date.localeCompare(b.date))
},
cast() {
return this.items.filter(i => i.type === 'person').sort((a, b) => a.date.localeCompare(b.date))
},
allEventsForDisplay() {
const evts = this.events()
const result = []
if (this.game && this.game.start_title) {
result.push({
id: '__start__', isAnchor: true, anchorType: 'start',
title: this.game.start_title,
description: this.game.start_description,
mood: this.game.start_mood || this.game.mood,
date: this.game.timeline_start
})
}
result.push(...evts)
if (this.game && this.game.end_title) {
result.push({
id: '__end__', isAnchor: true, anchorType: 'end',
title: this.game.end_title,
description: this.game.end_description,
mood: this.game.end_mood || this.game.mood,
date: this.game.timeline_end
})
}
return result
},
// --- Timeline helpers ---
posPercent(date) {
if (!this.game || !date) return 0
const start = new Date(this.game.timeline_start).getTime()
const end = new Date(this.game.timeline_end).getTime()
const d = new Date(date).getTime()
if (end === start) return 0
return Math.max(0, Math.min(100, ((d - start) / (end - start)) * 100))
},
barWidth(startDate, endDate) {
const resolvedEnd = endDate || (this.game && this.game.timeline_end)
return Math.max(0.5, this.posPercent(resolvedEnd) - this.posPercent(startDate))
},
// --- Modals ---
openUnlock() {
this.activeForm = { password: '' }
this.formError = null
this.modalType = 'unlock'
this.showModal = true
},
openAddItem(type) {
const env = this.randomEnv(null)
this.activeForm = { type, title: '', description: '', mood: '', date: '', end_date: '', status: 'active', discipline: '', env, env_layout: env ? this.randomEnvLayout() : 'after', project_id: '' }
this.editingId = null
this.formError = null
this.modalType = 'item'
this.showModal = true
},
openEditItem(item) {
this.activeForm = Object.assign({ env: '', env_layout: 'after', project_id: '' }, item)
this.editingId = item.id
this.formError = null
this.modalType = 'item'
this.showModal = true
},
async saveDeckToGame() {
if (!this.game || !this.editMode) return
await fetch('/api/games/' + this.slug, {
method: 'PUT',
headers: this.authHeaders(),
body: JSON.stringify({ ...this.game, deck_id: this.activeDeckId })
})
},
openEditGame() {
this.activeForm = Object.assign({ deck_id: '', truths: [] }, this.game)
this.activeForm.truths = [...(this.game.truths || [])]
this.formError = null
this.modalType = 'editGame'
this.showModal = true
},
closeModal() {
this.showModal = false
this.modalType = null
this.activeForm = {}
this.editingId = null
this.formError = null
this.pendingCastNames = []
this.acShow = false
},
// --- CRUD ---
async saveItem() {
const f = this.activeForm
if (!f.title) { this.formError = 'Title is required'; return }
if (!f.date) { this.formError = 'Date is required'; return }
if (f.end_date && f.end_date < f.date) { this.formError = 'End date must be after start date'; return }
this.saving = true; this.formError = null
try {
const url = this.editingId
? '/api/games/' + this.slug + '/items/' + this.editingId
: '/api/games/' + this.slug + '/items'
const r = await fetch(url, {
method: this.editingId ? 'PUT' : 'POST',
headers: this.authHeaders(),
body: JSON.stringify(f)
})
if (r.status === 401) { this.lock(); this.closeModal(); return }
const data = await r.json()
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
await this.loadItems()
// After saving a non-person, prompt for any unmatched @mentions
if (f.type !== 'person') {
const unmatched = this.findUnmatchedMentions(f.description)
if (unmatched.length > 0) {
this.pendingCastNames = unmatched
this.modalType = 'castPrompt'
return
}
} else if (this.pendingCastNames.length > 0) {
this.modalType = 'castPrompt'
return
}
this.closeModal()
} catch { this.formError = 'Network error' }
finally { this.saving = false }
},
async deleteItem(id) {
if (!confirm('Delete this entry? This cannot be undone.')) return
try {
const r = await fetch('/api/games/' + this.slug + '/items/' + id, {
method: 'DELETE', headers: this.authHeaders()
})
if (r.status === 401) { this.lock(); return }
if (r.ok) await this.loadItems()
} catch {}
},
async saveGame() {
const f = this.activeForm
if (!f.title) { this.formError = 'Title is required'; return }
if (!f.timeline_start || !f.timeline_end) { this.formError = 'Both dates are required'; return }
this.saving = true; this.formError = null
try {
const r = await fetch('/api/games/' + this.slug, {
method: 'PUT',
headers: this.authHeaders(),
body: JSON.stringify(f)
})
if (r.status === 401) { this.lock(); this.closeModal(); return }
const data = await r.json()
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
this.game = data
document.title = data.title + ' — Orison Reach'
if (data.deck_id !== undefined) this.activeDeckId = data.deck_id
this.tlItems = this.computeLayout()
this.closeModal()
} catch { this.formError = 'Network error' }
finally { this.saving = false }
},
// --- Modal title ---
itemTypeLabel() {
return { event: 'Event', project: 'Project', person: 'Cast Member' }[this.activeForm.type] || 'Entry'
},
modalTitle() {
if (this.modalType === 'unlock') return 'Edit Access'
if (this.modalType === 'editGame') return 'Edit Session'
if (this.modalType === 'item') return (this.editingId ? 'Edit ' : 'Add ') + this.itemTypeLabel()
if (this.modalType === 'castPrompt') return 'New Characters Mentioned'
if (this.modalType === 'keyword') return this.editingId ? 'Edit Definition' : 'Define Term'
return ''
}
}
}
</script>
</body>
</html>