diff --git a/assets/styles.css b/assets/styles.css index 72eb6eb..acfe65a 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -102,11 +102,33 @@ button.data-status:hover { background: var(--surface-soft); } .characters-button strong { min-width: 18px; padding: 2px 5px; color: #111; background: var(--accent); border-radius: 10px; text-align: center; } .books-button { min-height: 34px; padding: 0 11px; border: 1px solid var(--line); color: var(--ink); background: var(--surface); font: 500 9px var(--font-mono); } .books-button:hover { color: var(--accent); border-color: var(--accent); } +.character-library-layer { + position: fixed; + z-index: 120; + inset: 0; + padding: 24px; + display: grid; + place-items: center; +} +.character-library-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: rgba(5, 7, 6, .78); + backdrop-filter: blur(8px); +} .character-library { - width: min(420px, calc(100vw - 24px)); max-height: calc(100vh - 100px); - position: absolute; z-index: 40; top: 53px; right: 0; overflow: auto; - padding: 18px; border: 1px solid var(--line-strong); background: #121513; - box-shadow: 0 24px 70px rgba(0,0,0,.55); + position: relative; + width: min(680px, 100%); + max-height: min(760px, calc(100vh - 48px)); + padding: 28px; + overflow: auto; + border: 1px solid var(--line-strong); + border-radius: 14px; + background: var(--surface); + box-shadow: 0 32px 100px rgba(0, 0, 0, .72); } .rules-data-settings { width: min(460px, calc(100vw - 24px)); @@ -156,23 +178,198 @@ button.data-status:hover { background: var(--surface-soft); } .table-invite-link a { display: block; overflow: hidden; color: var(--accent); font: 9px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } .table-invite-link a:hover { text-decoration: underline; } .master-console .table-invite-link, .master-console .table-invite-link a { min-width: 0; max-width: 100%; } -.library-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; } -.library-heading h2 { margin: 4px 0 0; font: 700 20px var(--font-display); } -.library-heading button { border: 0; color: var(--muted); background: transparent; font-size: 22px; } -.character-list { display: grid; gap: 7px; } -.character-list-item { display: grid; grid-template-columns: minmax(0,1fr) auto; border: 1px solid var(--line); background: var(--surface); } -.character-list-item.active { border-color: rgba(201,255,82,.45); background: var(--accent-soft); } -.character-switch { min-width: 0; padding: 11px; border: 0; color: var(--ink); background: transparent; text-align: left; } -.character-switch strong, .character-switch span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.character-switch strong { font-size: 12px; } -.character-switch span { margin-top: 4px; color: var(--muted); font: 8px var(--font-mono); } -.character-actions { display: flex; flex-direction: column; } -.character-actions button { flex: 1; padding: 0 8px; border: 0; border-left: 1px solid var(--line); color: var(--faint); background: transparent; font-size: 7px; } -.character-actions button:hover { color: var(--ink); background: var(--surface-soft); } -.library-primary-actions, .library-export-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 12px; } -.library-primary-actions button, .library-export-actions button { min-height: 38px; border: 1px solid var(--line-strong); color: var(--ink); background: var(--surface-soft); font-size: 9px; } -.library-primary-actions button:first-child { color: #111; border-color: var(--accent); background: var(--accent); } -.import-status { margin: 10px 0 0; color: var(--warning); font-size: 9px; } +.library-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 22px; +} +.library-heading h2 { + margin: 7px 0 0; + font: 800 28px var(--font-display); + letter-spacing: -.03em; +} +.library-heading p { + margin: 7px 0 0; + color: var(--muted); + font-size: 12px; +} +.library-close { + width: 40px; + height: 40px; + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--muted); + background: transparent; + font-size: 24px; +} +.library-close:hover { color: var(--ink); border-color: var(--accent); } +.character-list { + overflow: visible; + display: grid; + gap: 8px; +} +.character-list-item { + position: relative; + min-width: 0; + padding: 12px; + display: grid; + grid-template-columns: 48px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface-raised); +} +.character-list-item:has(.character-more-actions[open]) { + z-index: 5; +} +.character-list-item.active { + border-color: rgba(201, 255, 82, .5); + background: var(--accent-soft); +} +.library-character-portrait { + position: relative; + width: 48px; + height: 48px; + overflow: hidden; + display: grid; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 9px; + color: var(--accent); + background: var(--surface-soft); + font: 800 16px var(--font-display); +} +.library-character-portrait .portrait-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} +.library-character-copy, +.library-character-name { min-width: 0; } +.library-character-name { + display: flex; + align-items: center; + gap: 8px; +} +.library-character-copy strong, +.library-character-copy > span, +.library-character-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.library-character-copy strong { font: 700 14px var(--font-display); } +.library-character-copy > span { + margin-top: 5px; + color: var(--muted); + font: 10px var(--font-mono); +} +.library-character-copy small { + margin-top: 3px; + color: var(--faint); + font: 9px var(--font-mono); +} +.library-current-chip { + flex: 0 0 auto; + padding: 3px 6px; + border-radius: 999px; + color: #11140e; + background: var(--accent); + font: 700 7px var(--font-mono); +} +.character-actions { + display: grid; + grid-template-columns: 76px 64px; + align-items: stretch; + gap: 7px; +} +.character-switch, +.character-more-actions > summary { + min-height: 40px; + display: grid; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 8px; + color: var(--ink); + background: transparent; + font-size: 10px; + cursor: pointer; +} +.character-switch:not(:disabled):hover, +.character-more-actions > summary:hover { + color: #11140e; + border-color: var(--accent); + background: var(--accent); +} +.character-switch:disabled { color: var(--muted); cursor: default; } +.character-more-actions { position: relative; } +.character-more-actions > summary { list-style: none; } +.character-more-actions > summary::-webkit-details-marker { display: none; } +.character-more-actions > div { + position: absolute; + z-index: 4; + top: 46px; + right: 0; + width: 128px; + padding: 6px; + display: grid; + gap: 4px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--surface-soft); + box-shadow: 0 16px 36px rgba(0, 0, 0, .5); +} +.character-more-actions button { + min-height: 34px; + border: 0; + border-radius: 6px; + color: var(--ink); + background: transparent; + font-size: 10px; + text-align: left; +} +.character-more-actions button:hover { background: var(--surface-raised); } +.character-more-actions button.danger-action { color: var(--danger); } +.library-primary-actions { + margin-top: 16px; + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 8px; +} +.library-primary-actions button { min-height: 44px; } +.library-export-actions { + margin-top: 14px; + padding-top: 14px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + border-top: 1px solid var(--line); +} +.library-export-actions > span { + color: var(--faint); + font: 700 8px var(--font-mono); + letter-spacing: .1em; +} +.library-export-actions button { + min-height: 36px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 7px; + color: var(--muted); + background: transparent; + font-size: 9px; +} +.library-export-actions button:hover { color: var(--ink); border-color: var(--line-strong); } +.import-status { margin: 12px 0 0; color: var(--warning); font-size: 10px; } .page-navigation { width: min(1480px, 94vw); @@ -343,163 +540,277 @@ button.data-status:hover { background: var(--surface-soft); } } .dice-window-layer { position: fixed; - z-index: 100; + z-index: 120; inset: 0; padding: 24px; display: grid; - align-items: end; - justify-items: end; - pointer-events: none; + place-items: center; +} +.dice-window-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: rgba(5, 7, 6, .8); + backdrop-filter: blur(8px); } -.game-master-shell .dice-window-layer { justify-items: start; } .dice-window { - width: min(520px, 100%); + position: relative; + width: min(940px, 100%); max-height: calc(100vh - 48px); - padding: 24px; + padding: 28px; overflow: auto; - pointer-events: auto; border: 1px solid var(--line-strong); + border-radius: 14px; background: var(--surface); - box-shadow: 0 28px 90px rgba(0, 0, 0, .65); + box-shadow: 0 32px 100px rgba(0, 0, 0, .72); } .dice-window-heading { - margin-bottom: 20px; + margin-bottom: 22px; display: flex; - align-items: start; + align-items: flex-start; justify-content: space-between; - gap: 16px; + gap: 20px; +} +.dice-window-heading h2 { + margin: 7px 0 0; + font: 800 28px var(--font-display); + letter-spacing: -.03em; +} +.dice-window-heading p { + margin: 7px 0 0; + color: var(--muted); + font-size: 12px; } -.dice-window-heading h2 { margin: 5px 0 0; font: 800 27px var(--font-display); } .dice-window-heading > button { - width: 38px; - height: 38px; + width: 40px; + height: 40px; + flex: 0 0 auto; border: 1px solid var(--line); + border-radius: 8px; color: var(--muted); background: transparent; font-size: 24px; } -.dice-window-heading > button:hover { color: var(--ink); border-color: var(--line-strong); } -.dice-window-controls { - display: grid; - grid-template-columns: 64px 86px 90px minmax(0, 1fr); - gap: 10px; - margin-bottom: 14px; -} -.dice-window .dice-roll-button { width: 100%; } -.secret-roll-controls { - margin: 0 0 14px; +.dice-window-heading > button:hover { color: var(--ink); border-color: var(--accent); } +.dice-window-layout { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; + grid-template-columns: minmax(0, 1.3fr) minmax(260px, .7fr); + align-items: stretch; + gap: 14px; } -.secret-roll-controls > label { +.dice-composer, +.dice-results-panel { min-width: 0; - padding: 10px 11px; - display: flex; - align-items: center; - gap: 9px; border: 1px solid var(--line); - background: var(--surface-soft); - cursor: pointer; -} -.secret-roll-controls input { width: 16px; height: 16px; flex: 0 0 auto; accent-color: var(--accent); } -.secret-roll-controls span, .secret-roll-controls strong, .secret-roll-controls small { display: block; min-width: 0; } -.secret-roll-controls strong { font-size: 11px; } -.secret-roll-controls small { margin-top: 2px; color: var(--muted); font-size: 9px; } -.dice-audience-badge { - display: inline-block; - margin-top: 7px; - padding: 3px 6px; - color: var(--warning); - border: 1px solid rgba(255, 206, 103, .34); - background: rgba(255, 206, 103, .08); - font: 8px var(--font-mono); - text-transform: uppercase; + border-radius: 10px; + background: var(--surface-raised); } -.dice-window-history { - margin-top: 18px; - padding-top: 16px; - border-top: 1px solid var(--line); +.dice-composer { padding: 18px; } +.dice-results-panel { + padding: 18px; + display: flex; + flex-direction: column; } +.dice-quick-section { margin-bottom: 14px; } +.dice-section-heading, .dice-window-history-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } -.dice-window-history-heading strong { - font: 700 12px var(--font-display); -} +.dice-section-heading strong, +.dice-window-history-heading strong { font: 700 12px var(--font-display); } +.dice-section-heading span, .dice-window-history-heading span { color: var(--faint); - font: 9px var(--font-mono); - text-transform: uppercase; + font: 700 8px var(--font-mono); + letter-spacing: .08em; } -.dice-window .dice-history { max-height: 240px; } -.roll-mode-group { +.dice-quick-list { + margin-top: 9px; display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; - margin-bottom: 12px; } -.roll-mode-group button { +.dice-quick-list button { min-height: 38px; border: 1px solid var(--line-strong); + border-radius: 7px; color: var(--muted); background: var(--surface-soft); - font-size: 12px; + font: 700 10px var(--font-mono); } -.roll-mode-group button.selected { - color: #111; +.dice-quick-list button:hover, +.dice-quick-list button.selected { + color: #11140e; border-color: var(--accent); background: var(--accent); } -.dice-controls { +.dice-quick-list button:disabled { opacity: .45; cursor: default; } +.roll-mode-group { + margin-bottom: 14px; + padding: 4px; display: grid; - grid-template-columns: 90px minmax(0, 1fr) 110px; - align-items: end; - gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; + border-radius: 9px; + background: var(--surface-soft); } -.dice-controls .field > span { font-size: 11px; } -.dice-roll-button { min-height: 44px; } -.dice-history { - max-height: 360px; - margin-top: 14px; - overflow: auto; - display: grid; - gap: 7px; +.roll-mode-group button { + min-height: 38px; + border: 0; + border-radius: 7px; + color: var(--muted); + background: transparent; + font-size: 11px; } -.dice-empty { margin: 14px 0 0; color: var(--faint); font-size: 12px; } -.dice-roll { - padding: 11px 12px; - border: 1px solid var(--line); - background: #151815; +.roll-mode-group button.selected { + color: #11140e; + background: var(--accent); } -.dice-roll-heading { - display: flex; - align-items: center; - justify-content: space-between; +.dice-window-controls { + display: grid; + grid-template-columns: 72px 92px 100px; gap: 10px; + margin-bottom: 14px; } -.dice-roll-heading strong { font-size: 13px; } -.dice-roll-heading span { color: var(--accent); font: 10px var(--font-mono); } -.dice-roll-note { margin: 5px 0 0; color: var(--muted); font-size: 12px; } -.dice-roll-result { - margin-top: 8px; +.dice-window-controls .field:last-child { grid-column: 1 / -1; } +.secret-roll-controls { + margin: 0 0 14px; display: grid; - grid-template-columns: minmax(0, 1fr) auto 44px; - align-items: center; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; - font: 12px var(--font-mono); } -.dice-roll-result > span:nth-child(2) { color: var(--muted); } -.dice-roll-result strong { - color: var(--accent); - text-align: right; - font: 800 22px var(--font-display); +.secret-roll-controls > label { + min-width: 0; + padding: 10px 11px; + display: flex; + align-items: center; + gap: 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); + cursor: pointer; } -.workspace { - width: min(1480px, 94vw); +.secret-roll-controls input { + width: 16px; + height: 16px; + flex: 0 0 auto; + accent-color: var(--accent); +} +.secret-roll-controls span, +.secret-roll-controls strong, +.secret-roll-controls small { + display: block; + min-width: 0; +} +.secret-roll-controls strong { font-size: 11px; } +.secret-roll-controls small { margin-top: 2px; color: var(--muted); font-size: 9px; } +.dice-window .inspiration-roll-button { + width: 100%; + margin-bottom: 8px; +} +.dice-window .dice-roll-button { + width: 100%; + min-height: 54px; + padding: 0 18px; + display: flex; + align-items: center; + justify-content: space-between; + border-radius: 8px; +} +.dice-roll-button span { + font: 700 9px var(--font-mono); + letter-spacing: .12em; +} +.dice-roll-button strong { font: 800 15px var(--font-display); } +.dice-latest-result { + min-height: 168px; + padding: 20px; + display: grid; + align-content: center; + justify-items: center; + border-radius: 9px; + background: var(--accent-soft); + text-align: center; +} +.dice-latest-result > span { + color: var(--accent); + font: 700 8px var(--font-mono); + letter-spacing: .12em; +} +.dice-latest-content { display: contents; } +.dice-latest-content > strong { + margin-top: 9px; + color: var(--accent); + font: 800 58px/.9 var(--font-display); + letter-spacing: -.04em; +} +.dice-latest-content > p { + max-width: 220px; + margin: 10px 0 0; + color: var(--muted); + font: 10px/1.45 var(--font-mono); +} +.dice-window-history { + min-height: 0; + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--line); +} +.dice-history { + max-height: 230px; + margin-top: 12px; + overflow: auto; + display: grid; + gap: 7px; +} +.dice-empty { margin: 14px 0 0; color: var(--faint); font-size: 11px; line-height: 1.5; } +.dice-audience-badge { + display: inline-block; + margin-top: 7px; + padding: 3px 6px; + border: 1px solid rgba(255, 206, 103, .34); + border-radius: 999px; + color: var(--warning); + background: rgba(255, 206, 103, .08); + font: 8px var(--font-mono); + text-transform: uppercase; +} +.dice-roll { + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); +} +.dice-roll-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.dice-roll-heading strong { font-size: 11px; } +.dice-roll-heading span { color: var(--accent); font: 8px var(--font-mono); } +.dice-roll-note { margin: 5px 0 0; color: var(--muted); font-size: 10px; } +.dice-roll-result { + margin-top: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto 36px; + align-items: center; + gap: 7px; + font: 9px var(--font-mono); +} +.dice-roll-result > span:nth-child(2) { color: var(--muted); } +.dice-roll-result strong { + color: var(--accent); + text-align: right; + font: 800 19px var(--font-display); +} +.workspace { + width: min(1480px, 94vw); margin: 0 auto; padding: 36px 0 72px; display: grid; @@ -768,7 +1079,71 @@ button.data-status:hover { background: var(--surface-soft); } font-weight: 700; } .danger-button:hover { background: #c92f2f; } -.delete-confirmation-modal p { color: var(--muted); line-height: 1.6; } +.delete-confirmation-layer { + position: fixed; + z-index: 140; + inset: 0; + padding: 24px; + display: grid; + place-items: center; +} +.delete-confirmation-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: rgba(5, 7, 6, .84); + backdrop-filter: blur(10px); +} +.delete-confirmation-modal { + position: relative; + width: min(520px, 100%); + padding: 28px; + border: 1px solid rgba(255, 124, 112, .55); + border-radius: 14px; + background: var(--surface); + box-shadow: 0 32px 100px rgba(0, 0, 0, .78); +} +.delete-confirmation-heading h2 { + margin: 8px 0 0; + font: 800 26px var(--font-display); + letter-spacing: -.03em; +} +.delete-confirmation-heading p { + margin: 10px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} +.delete-confirmation-warning { + margin-top: 20px; + padding: 14px; + display: grid; + gap: 4px; + border: 1px solid rgba(255, 124, 112, .3); + border-radius: 9px; + background: rgba(255, 124, 112, .07); +} +.delete-confirmation-warning strong { + color: var(--danger); + font-size: 11px; +} +.delete-confirmation-warning span { + color: var(--muted); + font-size: 10px; + line-height: 1.45; +} +.delete-confirmation-actions { + margin-top: 20px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.delete-confirmation-actions button { + width: 100%; + border-radius: 8px; +} .join-row { margin-top: 17px; } .join-divider { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; color: var(--faint); font: 500 8px var(--font-mono); letter-spacing: .1em; } .join-divider::after { content: ""; height: 1px; flex: 1; background: var(--line); } @@ -1706,7 +2081,7 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display } } -@media (max-width: 1000px) and (min-width: 801px) { +@media (max-width: 1300px) and (min-width: 801px) { .play-resource-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } .play-resource-group:nth-child(2) { border-right: 0; } .play-state-group { grid-column: 1 / -1; border-top: 1px solid var(--line); } @@ -1825,3 +2200,4898 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .sheet-title p, .stat-block > span, .stat-block small, .sheet-section-title span, .quick-stats span, .source-card p { color: #444; } .source-card { background: #eee; } } + +/* Pencil application shell */ +.sheets-application { + min-height: 100svh; + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + color: var(--ink); + background: #0e100f; +} +.sheets-application-main { + min-width: 0; + display: flex; + flex-direction: column; +} +.sheet-application-sidebar { + height: 100svh; + position: sticky; + top: 0; + z-index: 30; + display: flex; + flex-direction: column; + padding: 26px 20px 18px; + border-right: 1px solid #202522; + background: #050706; +} +.sheet-brand { + display: flex; + align-items: center; + gap: 11px; + min-height: 42px; +} +.sheet-brand-mark { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 8px; + color: #0a0c09; + background: var(--accent); + font: 800 17px var(--font-display); +} +.sheet-brand-copy strong, +.sheet-brand-copy span { + display: block; +} +.sheet-brand-copy strong { + font: 800 15px/1 var(--font-display); + letter-spacing: .12em; +} +.sheet-brand-copy span { + margin-top: 5px; + color: var(--muted); + font: 500 8px var(--font-mono); + letter-spacing: .08em; +} +.sheet-character-switcher { + width: 100%; + min-height: 82px; + margin: 28px 0 32px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--ink); + background: #121512; + text-align: left; +} +.sheet-character-switcher:hover { + border-color: var(--line-strong); + background: var(--surface); +} +.sheet-character-switcher-initial { + grid-row: 1 / 3; + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 50%; + color: var(--accent); + background: var(--accent-soft); + font: 700 11px var(--font-mono); +} +.sheet-character-switcher-copy { + min-width: 0; +} +.sheet-character-switcher-copy strong, +.sheet-character-switcher-copy span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sheet-character-switcher-copy strong { + font: 700 12px var(--font-display); +} +.sheet-character-switcher-copy span { + margin-top: 4px; + color: var(--muted); + font: 500 8px var(--font-mono); +} +.sheet-character-switcher-chevron { + color: var(--muted); +} +.sheet-navigation-group + .sheet-navigation-group { + margin-top: 30px; +} +.sheet-navigation-label { + margin: 0 0 9px 12px; + color: #5d655f; + font: 600 8px var(--font-mono); + letter-spacing: .12em; +} +.sheet-navigation { + display: grid; + gap: 3px; +} +.sheet-navigation-item { + width: 100%; + min-height: 42px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 0 12px; + border: 0; + border-radius: 8px; + color: #7c847e; + background: transparent; + text-align: left; + font: 600 11px var(--font-body); +} +.sheet-navigation-item:hover { + color: var(--ink); + background: var(--surface); +} +.sheet-navigation-item.selected { + color: #0d100b; + background: var(--accent); +} +.sheet-navigation-marker { + font: 600 9px var(--font-mono); + text-align: center; +} +.sheet-navigation-arrow { + opacity: 0; +} +.sheet-navigation-item.selected .sheet-navigation-arrow { + opacity: 1; +} +.sheet-navigation-footer { + margin-top: auto; + padding: 16px 12px 0; + border-top: 1px solid #1d221f; + color: #606761; + font: 500 8px var(--font-mono); +} +.sheet-application-topbar { + min-height: 64px; + position: sticky; + top: 0; + z-index: 25; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(180px, 310px) auto; + align-items: center; + gap: 22px; + padding: 0 40px; + border-bottom: 1px solid var(--line); + background: rgba(14, 16, 15, .92); + backdrop-filter: blur(16px); +} +.sheet-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--faint); + font: 500 9px var(--font-mono); + white-space: nowrap; +} +.sheet-breadcrumb strong { + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; +} +.sheet-topbar-search { + min-height: 36px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 13px; + border: 1px solid var(--line); + border-radius: 8px; + background: #171a18; +} +.sheet-topbar-search span { + color: var(--faint); + font-size: 12px; +} +.sheet-topbar-search input { + min-width: 0; + width: 100%; + border: 0; + outline: 0; + color: var(--muted); + background: transparent; + font-size: 10px; +} +.sheet-topbar-actions { + display: flex; + align-items: center; + gap: 12px; + color: var(--faint); + font-size: 13px; +} +.sheet-topbar-avatar { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 50%; + color: var(--accent); + background: var(--surface-soft); + font: 700 9px var(--font-mono); +} +.sheet-mobile-topbar, +.sheet-mobile-navigation, +.sheet-mobile-more-navigation { + display: none; +} +.sheet-page-stack { + min-width: 0; + flex: 1; +} +.sheet-page { + width: min(1192px, 100%); + margin: 0 auto; + padding: 42px 40px 64px; +} +.sheet-page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + margin-bottom: 34px; +} +.sheet-page-heading-copy { + min-width: 0; +} +.sheet-eyebrow, +.sheet-overline { + display: block; + color: var(--accent); + font: 600 8px var(--font-mono); + letter-spacing: .15em; + text-transform: uppercase; +} +.sheet-page-heading h1 { + margin: 8px 0 8px; + font: 800 clamp(34px, 4.5vw, 56px)/.92 var(--font-display); + letter-spacing: -.045em; +} +.sheet-page-heading p { + max-width: 620px; + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} +.sheet-page-heading-actions { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; +} +.sheet-component-primary-button, +.sheet-component-secondary-button { + min-height: 42px; + padding: 0 18px; + border-radius: 8px; + font: 700 10px var(--font-body); +} +.sheet-component-primary-button { + border: 1px solid var(--accent); + color: #10130d; + background: var(--accent); +} +.sheet-component-primary-button:hover { + filter: brightness(1.06); + transform: translateY(-1px); +} +.sheet-component-secondary-button { + border: 1px solid var(--line-strong); + color: var(--ink); + background: var(--surface); +} +.sheet-component-secondary-button:hover { + border-color: var(--accent); + color: var(--accent); +} +.sheet-component-metric { + min-height: 126px; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto 1fr; + align-items: start; + gap: 2px 11px; + padding: 20px; + border: 1px solid var(--line); + background: #151816; +} +.sheet-component-metric-marker { + grid-row: 1 / 4; + color: var(--accent); + font-size: 16px; +} +.sheet-component-metric-label { + color: var(--muted); + font: 600 8px var(--font-mono); + letter-spacing: .11em; +} +.sheet-component-metric-value { + font: 800 31px/1 var(--font-display); + letter-spacing: -.04em; +} +.sheet-component-metric-detail { + align-self: end; + color: var(--faint); + font: 500 8px var(--font-mono); + text-transform: uppercase; +} +.sheet-status-chip { + min-height: 26px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 9px; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: #b9c0ba; + background: rgba(255,255,255,.015); + font: 600 8px var(--font-mono); + letter-spacing: .06em; + text-transform: uppercase; +} +.sheet-status-chip-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} +.sheet-status-chip.accent { color: var(--accent); border-color: rgba(201,255,82,.35); background: var(--accent-soft); } +.sheet-status-chip.warning { color: var(--warning); border-color: rgba(255,206,103,.35); } +.sheet-status-chip.danger { color: var(--danger); border-color: rgba(255,124,112,.35); } +.sheet-status-chip.notification { color: var(--notification); border-color: rgba(255,93,158,.35); background: var(--notification-soft); } +.sheet-character-hero { + min-height: 272px; + position: relative; + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 30px; + padding: 34px 38px; + border: 1px solid var(--line); + border-radius: 10px 10px 0 0; + background: + linear-gradient(90deg, #161a17 0%, rgba(22,26,23,.96) 45%, rgba(22,26,23,.7) 100%), + radial-gradient(circle at 80% 30%, rgba(201,255,82,.2), transparent 23rem); +} +.sheet-character-hero::after { + content: ""; + width: 430px; + height: 430px; + position: absolute; + right: -90px; + top: -140px; + border: 1px solid rgba(201,255,82,.12); + border-radius: 50%; + box-shadow: 0 0 0 70px rgba(201,255,82,.025), 0 0 0 140px rgba(201,255,82,.015); +} +.sheet-character-hero-copy { + position: relative; + z-index: 1; + max-width: 610px; +} +.sheet-character-hero-copy h2 { + margin: 11px 0 10px; + font: 800 clamp(30px, 4vw, 50px)/.95 var(--font-display); + letter-spacing: -.04em; +} +.sheet-character-hero-copy p { + max-width: 520px; + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} +.sheet-character-tags, +.sheet-status-chip-list { + display: flex; + flex-wrap: wrap; + gap: 7px; +} +.sheet-character-tags { + margin-top: 22px; +} +.sheet-character-level { + position: relative; + z-index: 2; + display: grid; + justify-items: center; + padding: 20px; +} +.sheet-character-level span, +.sheet-character-level small { + color: var(--muted); + font: 600 8px var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; +} +.sheet-character-level strong { + font: 800 76px/.9 var(--font-display); + letter-spacing: -.07em; +} +.sheet-overview-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.sheet-overview-metrics > * + * { + margin-left: -1px; +} +.sheet-health-metric { + padding: 0; + border: 0; + color: inherit; + background: transparent; + text-align: left; +} +.sheet-health-metric:hover .sheet-component-metric { + position: relative; + z-index: 1; + border-color: var(--accent); +} +.sheet-overview-lower { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, .75fr); + gap: 20px; + margin-top: 20px; +} +.sheet-overview-resources, +.sheet-overview-status { + min-width: 0; + padding: 24px; + border: 1px solid var(--line); + background: var(--surface); +} +.sheet-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + margin-bottom: 20px; +} +.sheet-section-heading h2 { + margin: 0; + font: 700 16px var(--font-display); +} +.sheet-section-heading button { + padding: 0; + border: 0; + color: var(--accent); + background: transparent; + font: 600 8px var(--font-mono); + letter-spacing: .08em; +} +.sheet-resource-list { + display: grid; + gap: 18px; +} +.sheet-resource-row-heading { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; +} +.sheet-resource-row-heading strong { + font-size: 11px; +} +.sheet-resource-row-heading span { + color: var(--muted); + font: 500 8px var(--font-mono); +} +.sheet-resource-pips { + display: flex; + gap: 5px; +} +.sheet-resource-pips span { + width: 100%; + max-width: 54px; + height: 7px; + border-radius: 999px; + background: #343a35; +} +.sheet-resource-pips span.ready { + background: var(--accent); + box-shadow: 0 0 10px rgba(201,255,82,.12); +} +.sheet-overview-status .sheet-status-chip-list { + margin: 0 0 26px; +} +.sheet-overview-status > p { + max-height: 4.7em; + overflow: hidden; + margin: 10px 0 0; + color: var(--muted); + font-size: 11px; + line-height: 1.55; +} +.sheet-saved-indicator { + display: inline-flex; + align-items: center; + color: var(--muted); + font: 500 8px var(--font-mono); +} +.sheet-builder-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin-bottom: 20px; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; +} +.sheet-builder-step { + min-height: 68px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 10px; + padding: 0 16px; + border: 0; + border-right: 1px solid var(--line); + color: var(--faint); + background: #141715; + text-align: left; +} +.sheet-builder-step:last-child { + border-right: 0; +} +.sheet-builder-step span { + font: 600 9px var(--font-mono); +} +.sheet-builder-step strong { + font-size: 10px; +} +.sheet-builder-step:hover { + color: var(--ink); + background: var(--surface); +} +.sheet-builder-step.selected { + color: var(--accent); + background: var(--accent-soft); + box-shadow: inset 0 -2px var(--accent); +} +.sheet-builder-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 250px; + align-items: start; + gap: 20px; +} +.sheet-builder-decision, +.sheet-builder-summary { + min-width: 0; + border: 1px solid var(--line); + background: #121513; +} +.sheet-builder-decision { + padding: 28px; +} +.sheet-builder-decision-heading { + margin-bottom: 24px; +} +.sheet-builder-decision-heading h2 { + margin: 8px 0 7px; + font: 800 25px var(--font-display); +} +.sheet-builder-decision-heading p { + max-width: 620px; + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.55; +} +.sheet-builder-content > .panel, +.sheet-builder-content > .equipment-view, +.sheet-builder-content > .spells-view, +.sheet-builder-content > .companions-view, +.sheet-builder-content > .notes-view { + margin: 0; + border-color: var(--line); + box-shadow: none; +} +.sheet-builder-actions { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--line); +} +.sheet-builder-summary { + position: sticky; + top: 84px; + padding: 22px; +} +.sheet-builder-summary h3 { + margin: 10px 0 8px; + font: 700 17px var(--font-display); +} +.sheet-builder-summary p { + margin: 0; + color: var(--muted); + font-size: 10px; + line-height: 1.5; +} +.sheet-builder-summary-score { + margin: 23px 0; + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + padding: 16px 0; + border-block: 1px solid var(--line); +} +.sheet-builder-summary-score span { + color: var(--muted); + font: 600 8px var(--font-mono); +} +.sheet-builder-summary-score strong { + font: 800 34px var(--font-display); +} +.sheet-builder-summary-list { + display: grid; + gap: 10px; + color: var(--muted); + font: 500 9px var(--font-mono); +} +.sheet-resource-page .sheet-page-heading, +.sheet-notes-page .sheet-page-heading { + margin-bottom: 26px; +} +.sheet-resource-workspace { + min-width: 0; +} +.sheet-resource-workspace > .equipment-view, +.sheet-resource-workspace > .spells-view, +.sheet-resource-workspace > .companions-view, +.sheet-resource-workspace > .notes-view { + min-width: 0; +} +.sheet-resource-workspace .notes-field textarea { + min-height: 540px; +} +.sheet-page-stack > .workspace, +.sheet-page-stack > .saved-player-workspace, +.sheet-page-stack > .first-character-workspace { + width: min(1192px, calc(100% - 80px)); + margin: 0 auto; + padding-top: 40px; + padding-bottom: 64px; +} +.sheet-page-stack > .table-workspace, +.sheet-page-stack > .master-workspace { + grid-template-columns: minmax(0, 1fr); +} +.sheet-page-stack > .master-workspace { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, .65fr); +} +.sheet-page-stack > .master-workspace .master-console, +.sheet-page-stack > .master-workspace .master-inspector { + min-width: 0; +} +.sheet-page-stack > .rule-restriction-notice { + width: min(1192px, calc(100% - 80px)); + margin: 20px auto 0; +} +.sheet-page-stack .panel, +.sheet-page-stack .sheet, +.sheet-page-stack .builder { + border-color: var(--line); + box-shadow: none; +} +.sheet-page-stack .saved-player-workspace { + display: grid; + gap: 20px; +} + +@media (max-width: 1100px) { + .sheets-application { + grid-template-columns: 214px minmax(0, 1fr); + } + .sheet-application-sidebar { + padding-inline: 14px; + } + .sheet-application-topbar { + padding-inline: 28px; + } + .sheet-page { + padding-inline: 28px; + } + .sheet-builder-layout { + grid-template-columns: minmax(0, 1fr) 220px; + } + .sheet-builder-step { + padding-inline: 10px; + } + .sheet-page-stack > .workspace, + .sheet-page-stack > .saved-player-workspace, + .sheet-page-stack > .first-character-workspace, + .sheet-page-stack > .rule-restriction-notice { + width: calc(100% - 56px); + } +} + +@media (max-width: 800px) { + body { + background: #303632; + } + .sheets-application { + display: block; + min-height: 100svh; + background: #303632; + } + .sheet-application-sidebar, + .sheet-application-topbar { + display: none; + } + .sheet-mobile-topbar { + min-height: 66px; + position: sticky; + top: 0; + z-index: 30; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 0 18px; + border-bottom: 1px solid #3c443e; + background: rgba(48,54,50,.94); + backdrop-filter: blur(16px); + } + .sheet-mobile-brand { + display: flex; + align-items: center; + gap: 8px; + font: 800 14px var(--font-display); + letter-spacing: .1em; + } + .sheet-mobile-brand-mark { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 6px; + color: #111; + background: var(--accent); + font-size: 11px; + } + .sheet-mobile-character { + justify-self: center; + font: 700 10px var(--font-display); + } + .sheet-mobile-status { + justify-self: end; + display: flex; + align-items: center; + gap: 6px; + color: #c5cbc6; + font: 600 7px var(--font-mono); + } + .sheet-status-light { + width: 5px; + height: 5px; + display: inline-block; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 7px rgba(201,255,82,.65); + } + .sheet-page-stack { + padding-bottom: 77px; + } + .sheet-mobile-navigation { + height: 72px; + position: fixed; + inset: auto 0 0; + z-index: 40; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-top: 1px solid #252a27; + background: rgba(13,15,14,.97); + backdrop-filter: blur(18px); + } + .sheet-mobile-navigation-item { + display: grid; + place-items: center; + align-content: center; + gap: 5px; + border: 0; + color: #798079; + background: transparent; + } + .sheet-mobile-navigation-item span:first-child { + font-size: 14px; + } + .sheet-mobile-navigation-item span:last-child { + font: 600 7px var(--font-mono); + text-transform: uppercase; + } + .sheet-mobile-navigation-item.selected { + color: var(--accent); + background: rgba(201,255,82,.035); + } + .sheet-mobile-more-navigation { + position: sticky; + top: 66px; + z-index: 24; + display: flex; + gap: 6px; + padding: 9px 14px; + overflow-x: auto; + border-bottom: 1px solid #434a45; + background: #272d29; + scrollbar-width: none; + } + .sheet-mobile-more-navigation button { + flex: 0 0 auto; + min-height: 30px; + padding: 0 12px; + border: 1px solid #4a524c; + border-radius: 999px; + color: #a5aba6; + background: transparent; + font: 600 8px var(--font-mono); + } + .sheet-mobile-more-navigation button.selected { + border-color: var(--accent); + color: #111; + background: var(--accent); + } + .sheet-page { + width: 100%; + padding: 26px 18px 36px; + } + .sheet-page-heading { + display: block; + margin-bottom: 23px; + } + .sheet-page-heading h1 { + margin: 7px 0 7px; + font-size: 31px; + } + .sheet-page-heading p { + font-size: 10px; + } + .sheet-page-heading-actions { + margin-top: 18px; + } + .sheet-character-hero { + min-height: 236px; + padding: 25px 22px; + border-radius: 8px 8px 0 0; + } + .sheet-character-hero::after { + width: 260px; + height: 260px; + right: -100px; + top: -65px; + } + .sheet-character-hero-copy h2 { + max-width: 220px; + font-size: 31px; + } + .sheet-character-hero-copy p { + max-width: 250px; + font-size: 9px; + } + .sheet-character-tags { + margin-top: 16px; + } + .sheet-character-tags .sheet-status-chip:nth-child(n+2) { + display: none; + } + .sheet-character-level { + align-self: start; + padding: 0; + } + .sheet-character-level strong { + font-size: 46px; + } + .sheet-overview-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sheet-overview-metrics > * + * { + margin-left: 0; + } + .sheet-overview-metrics > *:nth-child(even) { + margin-left: -1px; + } + .sheet-overview-metrics > *:nth-child(n+3) { + margin-top: -1px; + } + .sheet-component-metric { + min-height: 105px; + padding: 16px; + } + .sheet-component-metric-value { + font-size: 24px; + } + .sheet-overview-lower { + grid-template-columns: 1fr; + } + .sheet-overview-resources, + .sheet-overview-status { + padding: 20px; + background: #272d29; + border-color: #444b46; + } + .sheet-builder-page { + padding-inline: 14px; + } + .sheet-builder-page .sheet-page-heading { + padding-inline: 4px; + } + .sheet-builder-page .sheet-page-heading-actions { + display: none; + } + .sheet-builder-steps { + display: flex; + overflow-x: auto; + border-color: #444b46; + scrollbar-width: none; + } + .sheet-builder-step { + min-width: 126px; + min-height: 54px; + background: #272d29; + border-color: #444b46; + } + .sheet-builder-layout { + display: block; + } + .sheet-builder-decision { + padding: 20px 14px; + border-color: #444b46; + background: #272d29; + } + .sheet-builder-summary { + display: none; + } + .sheet-builder-content > .panel { + padding-inline: 12px; + } + .sheet-builder-actions { + position: sticky; + bottom: 80px; + z-index: 10; + margin-inline: -14px; + padding: 14px; + background: rgba(39,45,41,.95); + backdrop-filter: blur(12px); + } + .sheet-builder-actions > * { + width: auto; + } + .sheet-page-stack > .workspace, + .sheet-page-stack > .saved-player-workspace, + .sheet-page-stack > .first-character-workspace, + .sheet-page-stack > .rule-restriction-notice { + width: calc(100% - 28px); + } + .sheet-page-stack > .workspace, + .sheet-page-stack > .saved-player-workspace { + padding-top: 22px; + padding-bottom: 36px; + } + .sheet-page-stack > .master-workspace { + grid-template-columns: minmax(0, 1fr); + } + .sheet-page-stack .panel, + .sheet-page-stack .sheet, + .sheet-page-stack .builder { + border-color: #444b46; + background: #272d29; + } + .sheet-resource-workspace .panel { + padding-inline: 15px; + } + .sheet-resource-workspace .notes-field textarea { + min-height: 430px; + } + .global-dice-button { + right: 16px; + bottom: 85px; + } +} + +@media (max-width: 440px) { + .sheet-page { + padding-inline: 14px; + } + .sheet-character-hero { + grid-template-columns: minmax(0, 1fr) 56px; + } + .sheet-character-hero-copy p { + display: none; + } + .sheet-character-level span, + .sheet-character-level small { + font-size: 6px; + } + .sheet-character-level strong { + font-size: 40px; + } + .sheet-page-heading-actions { + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + } + .sheet-page-heading-actions .sheet-component-primary-button { + width: 100%; + } + .sheet-page-stack > .workspace, + .sheet-page-stack > .saved-player-workspace, + .sheet-page-stack > .first-character-workspace, + .sheet-page-stack > .rule-restriction-notice { + width: calc(100% - 20px); + } + .first-character-card { + padding: 30px 18px; + } +} + +/* Component class bindings */ +.sheet-application { + min-height: 100svh; + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + color: var(--ink); + background: #0e100f; +} +.sheet-main { + min-width: 0; + display: flex; + flex-direction: column; +} +.sheet-sidebar { + height: 100svh; + position: sticky; + top: 0; + z-index: 30; + display: flex; + flex-direction: column; + padding: 26px 20px 18px; + border-right: 1px solid #202522; + background: #050706; +} +.sheet-active-character { + width: 100%; + min-height: 82px; + margin: 28px 0 32px; + display: grid; + align-content: center; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121512; +} +.sheet-character-identity { + min-width: 0; + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 10px; +} +.sheet-character-avatar { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 50%; + color: var(--accent); + background: var(--accent-soft); + font: 700 9px var(--font-mono); +} +.sheet-character-copy { + min-width: 0; +} +.sheet-character-copy strong, +.sheet-character-copy span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sheet-character-copy strong { + font: 700 12px var(--font-display); +} +.sheet-character-copy span { + margin-top: 4px; + color: var(--muted); + font: 500 8px var(--font-mono); +} +.sheet-active-character > .sheet-character-switcher { + width: auto; + min-height: 0; + margin: 0; + display: inline; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + text-align: left; + color: #69716a; + font: 600 7px var(--font-mono); + letter-spacing: .08em; +} +.sheet-navigation-group { + display: grid; + gap: 3px; +} +.sheet-navigation-group + .sheet-navigation-group { + margin-top: 30px; +} +.sheet-sidebar-spacer { + flex: 1; +} +.sheet-sync-state { + display: flex; + align-items: center; + gap: 6px; + padding: 16px 12px 0; + border-top: 1px solid #1d221f; + color: #606761; + font: 500 8px var(--font-mono); +} +.sheet-desktop-topbar { + min-height: 64px; + position: sticky; + top: 0; + z-index: 25; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 22px; + padding: 0 40px; + border-bottom: 1px solid var(--line); + background: rgba(14, 16, 15, .92); + backdrop-filter: blur(16px); +} +.sheet-desktop-topbar .sheet-topbar-actions { + min-width: min(360px, 40vw); +} +.sheet-search-placeholder { + min-width: 260px; + min-height: 36px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 13px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--muted); + background: #171a18; + font-size: 10px; +} +.sheet-settings-marker { + color: var(--faint); + font-size: 14px; +} +.sheet-page-actions { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; +} +.sheet-primary-button, +.sheet-secondary-button { + min-height: 42px; + padding: 0 18px; + border-radius: 8px; + font: 700 10px var(--font-body); +} +.sheet-primary-button { + display: inline-flex; + align-items: center; + gap: 13px; + border: 1px solid var(--accent); + color: #10130d; + background: var(--accent); +} +.sheet-primary-button:hover { + filter: brightness(1.06); + transform: translateY(-1px); +} +.sheet-secondary-button { + border: 1px solid var(--line-strong); + color: var(--ink); + background: var(--surface); +} +.sheet-secondary-button:hover { + border-color: var(--accent); + color: var(--accent); +} +.sheet-metric { + min-height: 126px; + display: grid; + align-content: space-between; + gap: 18px; + padding: 20px; + border: 1px solid var(--line); + background: #151816; +} +.sheet-metric-heading { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--muted); + font: 600 8px var(--font-mono); + letter-spacing: .11em; +} +.sheet-metric-marker { + color: var(--accent); + font-size: 16px; +} +.sheet-metric-value { + display: flex; + align-items: end; + justify-content: space-between; + gap: 8px; +} +.sheet-metric-value strong { + font: 800 31px/1 var(--font-display); + letter-spacing: -.04em; +} +.sheet-metric-value span { + color: var(--faint); + font: 500 8px var(--font-mono); + text-transform: uppercase; +} +.sheet-health-metric:hover .sheet-metric { + position: relative; + z-index: 1; + border-color: var(--accent); +} + +.sheet-session-page > .workspace { + width: 100%; + margin: 0; + padding: 0; +} +.sheet-session-page > .master-workspace { + grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); +} +.sheet-session-page .table-main-column { + grid-template-columns: minmax(0, 1fr); +} +.sheet-session-page .table-main-column > * { + width: 100%; + min-width: 0; + max-width: 100%; +} + +.sheet-settings-marker, +.sheet-mobile-menu { + padding: 0; + border: 0; + color: var(--faint); + background: transparent; +} +.sheet-root > .character-library, +.sheet-root > .rules-data-settings { + position: fixed; + top: 74px; + right: 24px; + z-index: 100; +} + +@media (max-width: 1100px) { + .sheet-application { + grid-template-columns: 214px minmax(0, 1fr); + } + .sheet-sidebar { + padding-inline: 14px; + } + .sheet-desktop-topbar { + padding-inline: 28px; + } +} + +@media (max-width: 800px) { + .sheet-application { + display: block; + min-height: 100svh; + background: #303632; + } + .sheet-sidebar, + .sheet-desktop-topbar { + display: none; + } + .sheet-root > .global-dice-button { + display: none; + } + .sheet-session-page > .master-workspace { + grid-template-columns: minmax(0, 1fr); + } + .sheet-root > .character-library, + .sheet-root > .rules-data-settings { + top: 74px; + right: 12px; + max-height: calc(100svh - 90px); + } + .sheet-mobile-topbar .sheet-brand { + min-width: 0; + gap: 8px; + } + .sheet-mobile-topbar .sheet-brand-mark { + width: 26px; + height: 26px; + border-radius: 6px; + font-size: 11px; + } + .sheet-mobile-topbar .sheet-brand-copy span { + display: none; + } + .sheet-mobile-topbar .sheet-brand-copy strong { + font-size: 13px; + } + .sheet-mobile-topbar-actions { + justify-self: end; + display: flex; + align-items: center; + gap: 12px; + } + .sheet-mobile-menu { + color: var(--muted); + font-size: 14px; + } + .sheet-page-actions { + margin-top: 18px; + } + .sheet-metric { + min-height: 105px; + padding: 16px; + } + .sheet-metric-value strong { + font-size: 24px; + } + .sheet-builder-page .sheet-page-actions { + display: none; + } +} + +@media (max-width: 440px) { + .sheet-page-actions { + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + } + .sheet-page-actions .sheet-primary-button { + width: 100%; + justify-content: center; + } +} + +/* Finished Pencil page compositions */ +.sheet-page { + width: min(1280px, 100%); + padding: 38px 42px 72px; +} +.sheet-page-heading { + margin-bottom: 30px; + padding-bottom: 0; + border: 0; +} +.sheet-page-heading h1 { + font-size: clamp(34px, 4vw, 50px); +} +.sheet-page-heading p { + font-size: 11px; +} +.sheet-resource-workspace { + min-width: 0; + padding: 0; + border: 0; + background: transparent; +} +.sheet-overline { + color: var(--accent); + font: 600 8px var(--font-mono); + letter-spacing: .15em; + text-transform: uppercase; +} +.sheet-section-heading { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.sheet-section-heading h2 { + margin: 0; + font: 700 17px var(--font-display); +} +.sheet-section-heading button { + padding: 0; + border: 0; + color: var(--accent); + background: transparent; + font: 600 8px var(--font-mono); + letter-spacing: .08em; +} +.sheet-empty-inline { + min-height: 116px; + display: grid; + place-content: center; + gap: 6px; + border: 1px dashed var(--line); + color: var(--muted); + text-align: center; +} +.sheet-empty-inline strong { + color: var(--ink); + font-size: 12px; +} +.sheet-empty-inline p { + margin: 0; + font-size: 10px; +} + +/* Overview */ +.sheet-overview-page .sheet-character-hero { + display: none; +} +.sheet-overview-metrics { + overflow: hidden; + border-radius: 8px 8px 0 0; +} +.sheet-overview-metrics .sheet-metric { + min-height: 120px; + background: #151816; +} +.sheet-overview-metrics .sheet-metric-marker { + font: 700 9px var(--font-mono); + letter-spacing: .06em; +} +.sheet-overview-abilities { + padding: 26px 0 0; +} +.sheet-overview-ability-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + margin-top: 14px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; +} +.sheet-overview-ability-card { + min-width: 0; + min-height: 142px; + display: grid; + grid-template-rows: auto 1fr auto auto; + justify-items: center; + align-items: center; + gap: 4px; + padding: 16px 10px 14px; + border: 0; + border-right: 1px solid var(--line); + color: var(--ink); + background: #121513; +} +.sheet-overview-ability-card:last-child { + border-right: 0; +} +.sheet-overview-ability-card:hover { + color: var(--accent); + background: #181d19; +} +.sheet-overview-ability-card > span { + color: var(--muted); + font: 600 8px var(--font-mono); + letter-spacing: .14em; +} +.sheet-overview-ability-card strong { + font: 800 36px/1 var(--font-display); +} +.sheet-overview-ability-card em { + width: 44px; + height: 26px; + display: grid; + place-items: center; + border-radius: 999px; + color: #10130d; + background: var(--accent); + font: normal 700 11px var(--font-mono); +} +.sheet-overview-ability-card small { + color: var(--faint); + font: 600 7px var(--font-mono); + letter-spacing: .08em; +} +.sheet-overview-lower { + grid-template-columns: minmax(0, 1.4fr) minmax(280px, .75fr); + gap: 12px; + margin-top: 12px; +} +.sheet-overview-resources, +.sheet-overview-status { + min-height: 232px; + padding: 22px; + border-radius: 8px; + background: #121513; +} +.sheet-resource-list { + margin-top: 14px; +} +.sheet-resource-row { + padding: 13px 0; +} +.sheet-resource-row:first-child { + border-top: 1px solid var(--line); +} +.sheet-overview-features { + margin-top: 12px; + padding: 22px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.sheet-overview-feature-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 16px; +} +.sheet-overview-feature-card { + min-width: 0; + min-height: 142px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 6px; + background: #171a18; +} +.sheet-overview-feature-card span { + color: var(--accent); + font: 600 7px var(--font-mono); + letter-spacing: .1em; +} +.sheet-overview-feature-card h3 { + margin: 8px 0 10px; + font: 700 13px var(--font-display); +} +.sheet-overview-feature-card p { + max-height: 4.5em; + overflow: hidden; + margin: 0; + color: var(--muted); + font-size: 9px; + line-height: 1.5; +} + +/* Focused builder */ +.sheet-builder-page { + width: min(1260px, 100%); +} +.sheet-builder-steps { + border-radius: 8px; + overflow: hidden; +} +.sheet-builder-step { + min-height: 72px; + padding: 0 14px; +} +.sheet-builder-layout { + grid-template-columns: minmax(0, 1fr) 270px; + gap: 14px; +} +.sheet-builder-decision, +.sheet-builder-summary { + border-radius: 8px; +} +.sheet-builder-decision { + padding: 28px 30px; +} +.sheet-builder-decision-heading { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--line); +} +.sheet-builder-content .tab-description { + margin: 0 0 18px; + color: var(--muted); + font-size: 10px; +} +.sheet-builder-content .ability-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; +} +.sheet-builder-content .ability-card { + min-height: 168px; + position: relative; + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto 1fr auto; + align-items: center; + gap: 10px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 7px; + background: #171a18; +} +.sheet-builder-content .ability-abbreviation { + color: var(--accent); + font: 700 9px var(--font-mono); + letter-spacing: .1em; +} +.sheet-builder-content .ability-name { + justify-self: end; + color: var(--muted); + font-size: 9px; +} +.sheet-builder-content .score-control { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 34px 1fr 34px; + align-items: center; + gap: 8px; +} +.sheet-builder-content .score-control button { + width: 34px; + height: 34px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--ink); + background: #111411; +} +.sheet-builder-content .score-control strong { + text-align: center; + font: 800 36px var(--font-display); +} +.sheet-builder-content .ability-card > .modifier { + grid-column: 1 / -1; + justify-self: center; + width: 48px; + height: 28px; + border: 0; + border-radius: 999px; + color: #0e110c; + background: var(--accent); + font: 700 11px var(--font-mono); +} +.sheet-builder-summary { + padding: 22px; + background: linear-gradient(160deg, #171b18, #111310); +} +.sheet-builder-summary::before { + content: ""; + height: 3px; + display: block; + margin: -22px -22px 20px; + border-radius: 8px 8px 0 0; + background: var(--accent); +} +.sheet-builder-summary-score { + padding: 14px; + border: 1px solid rgba(201,255,82,.2); + border-radius: 6px; + background: var(--accent-soft); +} +.sheet-builder-actions { + padding-top: 22px; + border-top: 1px solid var(--line); +} + +/* Inventory */ +.equipment-view { + display: grid; + gap: 12px; +} +.equipment-summary, +.spell-summary, +.companion-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; +} +.equipment-summary > *, +.spell-summary > *, +.companion-summary > * { + min-height: 92px; + margin: 0; + padding: 17px; + border: 0; + border-right: 1px solid var(--line); + background: #151816; +} +.equipment-summary > *:last-child, +.spell-summary > *:last-child, +.companion-summary > *:last-child { + border-right: 0; +} +.inventory-workbench { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1.65fr) minmax(270px, .7fr); + gap: 12px; + align-items: stretch; +} +.inventory-browser, +.inventory-inspector { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.inventory-browser { + padding: 20px; +} +.inventory-browser-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} +.inventory-browser-heading h2, +.inventory-inspector h2 { + margin: 7px 0 0; + font: 700 21px var(--font-display); +} +.inventory-count { + color: var(--muted); + font: 600 8px var(--font-mono); +} +.inventory-adders { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(190px, .75fr); + gap: 8px; + margin-bottom: 16px; +} +.inventory-catalog-picker, +.custom-item-picker { + min-width: 0; + margin: 0; + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #171a18; +} +.inventory-catalog-picker .inventory-catalog-controls { + grid-template-columns: minmax(0, 1fr) auto; +} +.inventory-catalog-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} +.inventory-catalog-actions button { + min-width: 92px; + height: 40px; + padding-inline: 12px; + white-space: nowrap; +} +.inventory-picker label, +.inventory-catalog-picker label { + min-width: 0; +} +.custom-item-picker { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 7px; +} +.custom-item-picker label { + display: grid; + gap: 6px; + color: var(--muted); + font: 600 7px var(--font-mono); +} +.inventory-empty { + min-height: 270px; + display: grid; + place-content: center; + gap: 8px; + border: 1px dashed var(--line); + color: var(--muted); + text-align: center; +} +.inventory-empty > span { + color: var(--accent); + font: 700 12px var(--font-mono); +} +.inventory-empty h3, +.inventory-empty p { + margin: 0; +} +.inventory-empty h3 { + color: var(--ink); + font-size: 16px; +} +.inventory-empty p { + font-size: 10px; +} +.inventory-browser .equipment-list { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 5px; + max-height: 520px; + overflow: auto; +} +.inventory-browser .equipment-row { + min-height: 62px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) 58px auto; + align-items: center; + gap: 8px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 5px; + background: #171a18; +} +.inventory-browser .equipment-row.selected { + border-color: var(--accent); + box-shadow: inset 3px 0 var(--accent); +} +.inventory-browser .equipment-row.equipped { + background: rgba(201,255,82,.055); +} +.inventory-browser .equipment-description-button { + display: grid; + gap: 4px; + text-align: left; +} +.inventory-browser .equipment-description-button strong { + font-size: 11px; +} +.inventory-browser .equipment-description-button small { + font: 500 7px var(--font-mono); + letter-spacing: .06em; +} +.inventory-inspector, +.spell-inspector, +.companion-inspector { + padding: 22px; +} +.inventory-inspector { + min-height: 100%; +} +.inventory-inspector-mark, +.inventory-inspector-empty-mark, +.companion-inspector-mark { + width: 72px; + height: 72px; + margin: 24px auto 18px; + display: grid; + place-items: center; + border: 1px solid rgba(201,255,82,.35); + border-radius: 50%; + color: var(--accent); + background: radial-gradient(circle, rgba(201,255,82,.12), transparent 66%); + font: 700 17px var(--font-mono); +} +.empty-inspector { + min-height: 340px; + display: grid; + align-content: center; + justify-items: center; + color: var(--muted); + text-align: center; +} +.empty-inspector h3 { + margin: 0 0 8px; + color: var(--ink); + font: 700 16px var(--font-display); +} +.empty-inspector p { + max-width: 220px; + margin: 0; + font-size: 10px; + line-height: 1.5; +} +.inventory-inspector-type, +.spell-inspector-school { + margin: 5px 0 18px; + color: var(--accent); + font: 600 8px var(--font-mono); + text-transform: uppercase; +} +.inventory-inspector-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 18px 0; + border: 1px solid var(--line); +} +.inventory-inspector-stats > div { + display: grid; + gap: 5px; + padding: 11px; + border-right: 1px solid var(--line); +} +.inventory-inspector-stats > div:last-child { + border-right: 0; +} +.inventory-inspector-stats span { + color: var(--faint); + font: 600 7px var(--font-mono); +} +.inventory-inspector-stats strong { + font-size: 11px; +} +.inventory-inspector .equipment-description { + padding: 14px 0; + border: 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} +.inventory-inspector .item-resource-setup, +.inventory-inspector .item-resource-controls { + margin-top: 16px; +} +.inventory-currency { + display: grid; + grid-template-columns: minmax(150px, .4fr) minmax(0, 1fr); + align-items: center; + gap: 22px; + padding: 18px 20px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.inventory-currency h3 { + margin: 6px 0 0; + font: 700 15px var(--font-display); +} +.inventory-currency .currency-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} +.inventory-auxiliary { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(230px, .35fr); + gap: 12px; +} +.inventory-auxiliary > * { + margin: 0; + padding: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} + +/* Spellbook */ +.spell-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.spellbook-workbench { + min-width: 0; + display: grid; + grid-template-columns: 190px minmax(360px, 1fr) minmax(270px, .72fr); + gap: 10px; + align-items: stretch; +} +.spell-slot-panel, +.spell-catalog-panel, +.spell-inspector { + min-width: 0; + padding: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.spell-slot-panel h2 { + margin: 7px 0 18px; + font: 700 18px var(--font-display); +} +.spell-slot-list { + display: grid; + gap: 7px; +} +.spell-slot-row { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 5px; + background: #171a18; +} +.spell-slot-row > div:first-child { + display: flex; + justify-content: space-between; + gap: 8px; +} +.spell-slot-row span, +.spell-slot-row strong { + font: 600 7px var(--font-mono); +} +.spell-slot-row strong { + color: var(--accent); +} +.spell-slot-pips { + display: flex; + gap: 4px; +} +.spell-slot-pips span { + width: 9px; + height: 9px; + border: 1px solid var(--line-strong); + border-radius: 50%; +} +.spell-slot-pips span.ready { + border-color: var(--accent); + background: var(--accent); + box-shadow: 0 0 6px rgba(201,255,82,.3); +} +.spell-slot-stepper { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; +} +.spell-slot-stepper button, +.spell-slot-actions button { + min-height: 26px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--muted); + background: #101310; + font-size: 10px; +} +.spell-slot-actions { + display: grid; + gap: 5px; + margin-top: 14px; +} +.spell-slot-actions button:last-child { + border-color: rgba(201,255,82,.4); + color: var(--accent); +} +.spell-catalog-panel { + padding: 14px; +} +.spell-catalog-panel .spell-controls { + grid-template-columns: minmax(160px, 1.5fr) repeat(3, minmax(90px, .7fr)); + margin: 0 0 10px; + padding: 0; + border: 0; + background: transparent; +} +.spell-catalog-panel .spell-result-count { + grid-column: 1 / -1; + padding: 0; +} +.spell-catalog-panel .spell-list { + max-height: 590px; +} +.spell-catalog-panel .spell-row { + min-height: 58px; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 5px; +} +.spell-catalog-panel .spell-row.inspected { + border-color: var(--accent); + box-shadow: inset 3px 0 var(--accent); +} +.spell-row-selector { + min-width: 0; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 0; + border: 0; + color: inherit; + background: transparent; + text-align: left; +} +.spell-row-selector .spell-main { + min-width: 0; +} +.spell-catalog-panel .spell-row .spell-cast-button { + grid-column: auto; + min-width: 48px; + padding: 0 7px; +} +.spell-inspector { + min-height: 450px; +} +.spell-inspector > h2 { + margin: 9px 0 0; + font: 800 26px/.98 var(--font-display); +} +.spell-inspector .spell-metadata { + margin: 18px 0; +} +.spell-inspector .spell-description { + max-height: 260px; + overflow: auto; + color: var(--muted); + font-size: 10px; + line-height: 1.6; +} +.spell-inspector > .sheet-primary-button { + width: 100%; + justify-content: center; + margin-top: 16px; +} + +/* Companions */ +.companion-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.companion-catalog-manager { + display: grid; + grid-template-columns: auto minmax(170px, .6fr) minmax(240px, 1fr) auto; + align-items: end; + gap: 8px; + padding: 12px; + border-radius: 8px; + background: #121513; +} +.companion-type-tabs { + display: flex; + align-items: center; + gap: 3px; + align-self: center; +} +.companion-type-tabs button { + min-height: 36px; + padding: 0 11px; + border: 0; + border-bottom: 2px solid transparent; + color: var(--muted); + background: transparent; + font-size: 9px; +} +.companion-type-tabs button.active { + border-color: var(--accent); + color: var(--accent); +} +.companion-search, +.companion-catalog-select { + min-width: 0; + display: grid; + gap: 5px; + color: var(--muted); + font: 600 7px var(--font-mono); +} +.companion-search input, +.companion-catalog-select select { + min-width: 0; + width: 100%; + height: 38px; +} +.companion-catalog-preview-wrap { + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.companion-catalog-preview-wrap summary { + color: var(--accent); + font: 600 8px var(--font-mono); + cursor: pointer; +} +.companion-workbench { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(280px, .68fr); + gap: 10px; +} +.companion-roster, +.companion-inspector { + min-width: 0; + padding: 20px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.companion-roster .companion-card-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 15px; +} +.companion-card { + min-width: 0; + padding: 0; + overflow: hidden; + border-radius: 7px; + background: #171a18; +} +.companion-card.selected { + border-color: var(--accent); + box-shadow: inset 3px 0 var(--accent); +} +.companion-card-heading { + width: 100%; + min-height: 72px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px; + border: 0; + border-bottom: 1px solid var(--line); + color: var(--ink); + background: transparent; + text-align: left; +} +.companion-card-heading > div { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} +.companion-avatar { + width: 38px; + height: 38px; + flex: 0 0 auto; + display: grid; + place-items: center; + border-radius: 50%; + color: #10130d; + background: var(--accent); + font: 700 9px var(--font-mono); +} +.companion-card-controls { + padding: 10px 12px; +} +.companion-card-actions { + padding: 0 12px 12px; +} +.companion-inspector { + min-height: 390px; +} +.companion-inspector > h2 { + margin: 8px 0 5px; + text-align: center; + font: 800 24px var(--font-display); +} +.companion-inspector > p { + color: var(--muted); + text-align: center; + font-size: 9px; +} +.companion-inspector .companion-preview-stats { + margin: 18px 0; +} +.companion-inspector .companion-catalog-preview { + padding: 0; + border: 0; + background: transparent; +} +.companion-inspector .companion-catalog-preview > .companion-card-heading, +.companion-inspector .companion-catalog-preview > .companion-preview-stats { + display: none; +} +.custom-companion-disclosure { + padding: 13px 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.custom-companion-disclosure > summary { + color: var(--accent); + font: 600 9px var(--font-mono); + cursor: pointer; +} +.custom-companion-disclosure .custom-companion-manager { + margin-top: 13px; +} + +/* Notes */ +.notes-view { + min-width: 0; + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 10px; +} +.notes-category-panel, +.notes-workspace { + min-width: 0; + padding: 20px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.notes-category-panel h2 { + margin: 7px 0 18px; + font: 700 18px var(--font-display); +} +.notes-category-panel > button { + width: 100%; + min-height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + border: 0; + border-left: 2px solid transparent; + color: var(--muted); + background: transparent; + font-size: 9px; + text-align: left; +} +.notes-category-panel > button.active { + border-color: var(--accent); + color: var(--ink); + background: var(--accent-soft); +} +.notes-category-panel > button strong { + color: var(--faint); + font: 600 8px var(--font-mono); +} +.notes-prompt { + margin-top: 28px; + padding: 14px; + border: 1px solid rgba(201,255,82,.2); + border-radius: 6px; + background: var(--accent-soft); +} +.notes-prompt span { + color: var(--accent); + font: 600 7px var(--font-mono); +} +.notes-prompt p { + margin: 9px 0 0; + color: var(--muted); + font-size: 9px; + line-height: 1.5; +} +.notes-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; +} +.notes-heading h2 { + margin: 7px 0 5px; + font: 800 25px var(--font-display); +} +.notes-heading p { + max-width: 560px; + margin: 0; + color: var(--muted); + font-size: 10px; +} +.notes-workspace .notes-field { + margin-top: 18px; +} +.notes-workspace .notes-field textarea { + min-height: 210px; + padding: 16px; + border-radius: 6px; + background: #171a18; + font: 11px/1.65 var(--font-body); +} +.notes-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; +} +.note-card { + min-width: 0; + min-height: 128px; + padding: 15px; + border: 1px solid var(--line); + border-radius: 6px; + background: #171a18; +} +.note-card > div { + display: flex; + justify-content: space-between; + gap: 8px; +} +.note-card span { + color: var(--accent); + font: 600 7px var(--font-mono); +} +.note-card small { + color: var(--faint); + font: 600 7px var(--font-mono); +} +.note-card p { + max-height: 5.2em; + overflow: hidden; + margin: 13px 0 0; + color: var(--muted); + font-size: 10px; + line-height: 1.55; +} +.notes-empty-state { + min-height: 210px; + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + color: var(--muted); + text-align: center; +} +.notes-empty-state > span { + color: var(--accent); + font-size: 24px; +} +.notes-empty-state h3, +.notes-empty-state p { + margin: 0; +} +.notes-empty-state h3 { + color: var(--ink); + font-size: 15px; +} +.notes-empty-state p { + max-width: 360px; + font-size: 9px; +} + +/* Saved players and live session */ +.saved-players-workspace { + width: min(1196px, calc(100% - 80px)); + margin: 0 auto; + padding: 40px 0 72px; +} +.saved-player-library { + padding: 26px; + border-radius: 8px; + background: #121513; +} +.saved-player-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.saved-player-entry { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 7px; + background: #171a18; +} +.saved-player-entry .remote-card { + min-height: 132px; + border: 0; + background: transparent; +} +.saved-player-entry .saved-player-meta { + border: 0; + border-top: 1px solid var(--line); + background: #111411; +} +.saved-player-inspector { + padding: 20px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121513; +} +.sheet-session-page .workspace { + display: grid; + gap: 10px; +} +.sheet-session-page .panel, +.sheet-session-page .builder { + border-radius: 8px; + background: #121513; +} +.sheet-session-page .collaboration-panel { + padding: 18px; +} +.sheet-session-page .session-bar { + min-height: 54px; + padding: 8px 12px; + border-radius: 6px; + background: #171a18; +} +.sheet-session-page .initiative-panel, +.sheet-session-page .session-dashboard-panel { + padding: 20px; +} + +@media (max-width: 1180px) { + .sheet-overview-feature-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .spellbook-workbench { + grid-template-columns: 170px minmax(330px, 1fr); + } + .spell-inspector { + grid-column: 1 / -1; + min-height: 0; + } + .spell-inspector .spell-metadata { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .saved-player-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .sheet-page { + padding-inline: 24px; + } + .sheet-overview-ability-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .sheet-overview-ability-card:nth-child(3) { + border-right: 0; + } + .sheet-overview-ability-card:nth-child(-n+3) { + border-bottom: 1px solid var(--line); + } + .sheet-builder-layout, + .inventory-workbench, + .companion-workbench { + grid-template-columns: minmax(0, 1fr); + } + .sheet-builder-summary { + position: static; + } + .inventory-inspector, + .companion-inspector { + min-height: 0; + } + .spellbook-workbench { + grid-template-columns: minmax(0, 1fr); + } + .spell-slot-panel { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px 18px; + } + .spell-slot-panel .sheet-overline, + .spell-slot-panel h2 { + grid-column: 1 / -1; + } + .spell-slot-list { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + } + .notes-view { + grid-template-columns: minmax(0, 1fr); + } + .notes-category-panel { + display: none; + } +} + +@media (max-width: 800px) { + .sheet-page { + width: 100%; + padding: 24px 16px 94px; + background: #0e100f; + } + .sheet-page-heading { + align-items: flex-start; + margin-bottom: 18px; + } + .sheet-page-heading h1 { + margin: 6px 0; + font-size: 28px; + } + .sheet-page-heading p { + max-width: 88%; + font-size: 10px; + } + .sheet-overview-page .sheet-page-heading-copy p { + display: none; + } + .sheet-overview-page .sheet-character-hero { + min-height: 162px; + display: flex; + margin-bottom: 10px; + padding: 20px; + border-radius: 8px; + } + .sheet-character-hero::after { + width: 230px; + height: 230px; + } + .sheet-character-hero-copy h2 { + font-size: 27px; + } + .sheet-character-level strong { + font-size: 38px; + } + .sheet-overview-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-radius: 8px; + } + .sheet-overview-metrics > * + * { + margin-left: 0; + } + .sheet-overview-metrics > *:nth-child(odd) .sheet-metric { + border-right: 0; + } + .sheet-overview-metrics > *:nth-child(-n+2) .sheet-metric { + border-bottom: 0; + } + .sheet-overview-metrics .sheet-metric { + min-height: 98px; + } + .sheet-overview-abilities { + padding-top: 18px; + } + .sheet-overview-ability-card { + min-height: 116px; + padding: 12px 7px; + } + .sheet-overview-ability-card strong { + font-size: 29px; + } + .sheet-overview-lower { + grid-template-columns: minmax(0, 1fr); + } + .sheet-overview-resources, + .sheet-overview-status, + .sheet-overview-features { + padding: 17px; + } + .sheet-overview-feature-grid { + grid-template-columns: minmax(0, 1fr); + } + .sheet-overview-feature-card { + min-height: 110px; + } + .sheet-builder-steps { + grid-template-columns: repeat(5, minmax(56px, 1fr)); + overflow-x: auto; + } + .sheet-builder-step { + min-height: 54px; + grid-template-columns: 1fr; + justify-items: center; + padding: 7px; + } + .sheet-builder-step strong { + display: none; + } + .sheet-builder-decision { + padding: 18px 15px; + } + .sheet-builder-content .ability-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sheet-builder-content .ability-card { + min-height: 140px; + padding: 12px; + } + .sheet-builder-summary { + display: none; + } + .sheet-builder-actions { + position: sticky; + bottom: 76px; + z-index: 10; + margin: 18px -15px -18px; + padding: 10px 15px; + border-top: 1px solid var(--line); + background: rgba(14,16,15,.96); + } + .equipment-summary, + .spell-summary, + .companion-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .equipment-summary > *, + .spell-summary > *, + .companion-summary > * { + min-height: 76px; + border-bottom: 1px solid var(--line); + } + .equipment-summary > *:nth-child(even), + .spell-summary > *:nth-child(even), + .companion-summary > *:nth-child(even) { + border-right: 0; + } + .equipment-summary > *:nth-last-child(-n+2), + .spell-summary > *:nth-last-child(-n+2), + .companion-summary > *:nth-last-child(-n+2) { + border-bottom: 0; + } + .inventory-browser, + .inventory-inspector, + .companion-roster, + .companion-inspector, + .spell-slot-panel, + .spell-catalog-panel, + .spell-inspector, + .notes-workspace { + padding: 14px; + } + .inventory-adders { + grid-template-columns: minmax(0, 1fr); + } + .inventory-browser .equipment-row { + grid-template-columns: 18px minmax(0, 1fr) 54px; + } + .inventory-browser .equipment-actions { + grid-column: 2 / -1; + } + .inventory-currency, + .inventory-auxiliary { + grid-template-columns: minmax(0, 1fr); + } + .inventory-currency { + gap: 12px; + } + .inventory-currency .currency-grid { + grid-template-columns: repeat(5, minmax(52px, 1fr)); + overflow-x: auto; + } + .inventory-auxiliary { + display: none; + } + .spell-slot-panel { + display: block; + } + .spell-slot-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .spell-catalog-panel .spell-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .spell-catalog-panel .spell-controls .spell-search { + grid-column: 1 / -1; + } + .spell-inspector { + min-height: 0; + } + .spell-inspector .spell-metadata { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .companion-catalog-manager { + grid-template-columns: minmax(0, 1fr); + } + .companion-type-tabs { + overflow-x: auto; + } + .companion-roster .companion-card-list { + grid-template-columns: minmax(0, 1fr); + } + .notes-workspace .notes-field textarea { + min-height: 260px; + } + .notes-card-grid { + grid-template-columns: minmax(0, 1fr); + } + .saved-players-workspace { + width: 100%; + padding: 22px 14px 94px; + background: #0e100f; + } + .saved-player-library, + .saved-player-inspector { + padding: 16px; + } + .saved-player-grid { + grid-template-columns: minmax(0, 1fr); + } + .saved-player-heading h2 { + font-size: 23px; + } + .sheet-session-page .workspace { + padding-bottom: 88px; + } +} + +@media (max-width: 440px) { + .sheet-page-heading-actions { + grid-template-columns: auto minmax(0, 1fr); + } + .sheet-overview-page .sheet-page-heading-actions .sheet-secondary-button { + display: none; + } + .sheet-overview-page .sheet-page-heading-actions { + display: block; + } + .sheet-overview-page .sheet-page-heading-actions .sheet-primary-button { + min-height: 38px; + padding-inline: 12px; + } + .sheet-character-level { + min-width: 74px; + } + .sheet-overview-ability-card small { + font-size: 6px; + } + .sheet-builder-content .ability-card { + min-height: 132px; + } + .inventory-browser .equipment-row { + grid-template-columns: 18px minmax(0, 1fr) 48px; + } + .spellbook-workbench { + gap: 8px; + } + .spell-catalog-panel .spell-controls { + grid-template-columns: minmax(0, 1fr); + } + .spell-catalog-panel .spell-controls .spell-search, + .spell-catalog-panel .spell-result-count { + grid-column: auto; + } + .companion-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .companion-summary > * { + min-width: 0; + padding: 10px 7px; + border-right: 1px solid var(--line); + border-bottom: 0; + } + .companion-summary > *:nth-child(even) { + border-right: 1px solid var(--line); + } + .companion-summary > *:last-child { + border-right: 0; + } +} + +/* Resource management */ +.resource-management-page { + width: min(1196px, calc(100% - 80px)); + margin: 0 auto; + padding: 34px 0 72px; +} +.resource-command-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 32px; + min-height: 210px; + padding: 30px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 9px; + background: + radial-gradient(circle at 85% 35%, rgba(201, 255, 82, .09), transparent 34%), + #171a18; +} +.resource-command-copy { + max-width: 650px; +} +.resource-kicker, +.resource-side-heading > span, +.resource-section-heading span, +.resource-status-toggle > span, +.resource-concentration span, +.resource-condition-heading span, +.resource-exhaustion > span, +.resource-recovery-readout span { + color: var(--accent); + font: 700 8px var(--font-mono); + letter-spacing: .11em; +} +.resource-command-copy h2 { + margin: 12px 0 8px; + color: var(--ink); + font: 500 34px/1.08 var(--font-display); + letter-spacing: -.035em; +} +.resource-command-copy p { + max-width: 610px; + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.55; +} +.resource-rest-controls { + display: grid; + grid-template-columns: repeat(2, 158px); + gap: 8px; +} +.resource-rest-button { + min-height: 58px; + display: grid; + justify-items: start; + gap: 3px; + padding: 10px 13px; + border: 1px solid var(--line-strong); + border-radius: 7px; + color: var(--ink); + background: #111411; + text-align: left; +} +.resource-rest-button.primary { + border-color: var(--accent); + color: #11140e; + background: var(--accent); +} +.resource-rest-button span { + color: inherit; + opacity: .72; + font: 700 7px var(--font-mono); + letter-spacing: .08em; +} +.resource-rest-button strong { + font-size: 11px; +} +.resource-undo-button { + grid-column: 1 / -1; + min-height: 28px; + padding: 0; + border: 0; + color: var(--muted); + background: transparent; + font: 700 8px var(--font-mono); + text-align: right; +} +.resource-vitals { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + margin-top: 14px; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: #171a18; +} +.resource-vital { + min-width: 0; + min-height: 112px; + display: grid; + align-content: center; + gap: 5px; + padding: 17px; + border-right: 1px solid var(--line); +} +.resource-vital:last-child { + border-right: 0; +} +.resource-vital > span { + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .09em; +} +.resource-vital > strong { + color: var(--accent); + font: 500 25px var(--font-display); + letter-spacing: -.03em; +} +.resource-vital.warning > strong { + color: var(--danger); +} +.resource-vital > small { + overflow: hidden; + color: var(--muted); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} +.resource-management-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + align-items: start; + gap: 14px; + margin-top: 14px; +} +.resource-management-ledger, +.resource-management-sidebar { + display: grid; + gap: 14px; +} +.resource-ledger-section, +.resource-side-card { + padding: 22px; + border: 1px solid var(--line); + border-radius: 8px; + background: #171a18; +} +.resource-section-heading { + margin-bottom: 18px; + padding-bottom: 15px; + border-bottom: 1px solid var(--line); +} +.resource-section-heading h2, +.resource-side-heading h2 { + margin: 5px 0; + color: var(--ink); + font: 700 20px var(--font-display); +} +.resource-section-heading p, +.resource-side-heading p { + max-width: 640px; + margin: 0; + color: var(--muted); + font-size: 9px; + line-height: 1.5; +} +.resource-pool-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.resource-pool-card { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px 14px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 7px; + background: #111411; +} +.resource-pool-heading { + min-width: 0; + display: flex; + justify-content: space-between; + gap: 8px; +} +.resource-pool-heading strong, +.resource-pool-heading small { + display: block; +} +.resource-pool-heading strong { + color: var(--ink); + font-size: 11px; +} +.resource-pool-heading small { + margin-top: 3px; + overflow: hidden; + color: var(--faint); + font: 600 7px var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} +.resource-delete-button { + width: 24px; + height: 24px; + padding: 0; + border: 0; + color: var(--danger); + background: transparent; +} +.resource-pool-value { + align-self: start; + color: var(--muted); + white-space: nowrap; +} +.resource-pool-value strong { + color: var(--accent); + font: 500 24px var(--font-display); +} +.resource-pool-progress { + grid-column: 1 / -1; + height: 4px; + overflow: hidden; + border-radius: 2px; + background: #242925; +} +.resource-pool-progress span { + display: block; + height: 100%; + background: var(--accent); +} +.resource-pool-actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} +.resource-pool-actions button { + min-height: 32px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--muted); + background: #171a18; + font: 700 8px var(--font-mono); +} +.resource-pool-actions button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} +.resource-pool-actions button:disabled { + opacity: .35; +} +.resource-empty-state { + min-height: 170px; + display: grid; + place-content: center; + justify-items: center; + gap: 7px; + padding: 24px; + border: 1px dashed var(--line-strong); + border-radius: 7px; + color: var(--muted); + text-align: center; +} +.resource-empty-state.compact { + min-height: 96px; +} +.resource-empty-state > span { + color: var(--accent); + font-size: 24px; +} +.resource-empty-state strong, +.resource-empty-state p { + margin: 0; +} +.resource-empty-state strong { + color: var(--ink); + font-size: 12px; +} +.resource-empty-state p { + max-width: 420px; + font-size: 9px; +} +.resource-custom-form { + display: grid; + grid-template-columns: minmax(170px, 1fr) 86px minmax(140px, .7fr) auto; + align-items: end; + gap: 8px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} +.resource-custom-form > div { + min-width: 0; + display: grid; + gap: 5px; +} +.resource-custom-form label, +.resource-custom-form > div > label { + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .08em; +} +.resource-custom-form input, +.resource-custom-form select, +.resource-condition-controls select, +.resource-exhaustion input { + width: 100%; + min-width: 0; + height: 38px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--ink); + background: #0f1110; +} +.resource-add-button { + min-height: 38px; + padding-inline: 14px; + border: 1px solid var(--accent); + border-radius: 5px; + color: #11140e; + background: var(--accent); + font: 700 9px var(--font-mono); + white-space: nowrap; +} +.resource-side-card { + padding: 20px; +} +.resource-side-heading { + margin-bottom: 16px; +} +.resource-status-toggle, +.resource-concentration { + width: 100%; + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--muted); + background: #111411; +} +.resource-status-toggle { + text-align: left; +} +.resource-status-toggle > span, +.resource-status-toggle > strong { + display: block; +} +.resource-status-toggle.active { + border-color: rgba(201, 255, 82, .5); + color: var(--ink); + background: #27331b; +} +.resource-status-toggle.active > strong { + color: var(--accent); +} +.resource-concentration { + margin-top: 8px; +} +.resource-concentration div span, +.resource-concentration div strong { + display: block; +} +.resource-concentration div strong { + margin-top: 4px; + color: var(--ink); + font-size: 10px; +} +.resource-concentration button { + min-height: 28px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--muted); + background: transparent; + font: 700 7px var(--font-mono); +} +.resource-death-saves { + margin-top: 8px; + padding: 12px; + border: 1px solid rgba(255, 92, 92, .45); + border-radius: 6px; + background: rgba(255, 92, 92, .06); +} +.resource-death-saves span, +.resource-death-saves strong { + display: block; +} +.resource-death-saves span { + color: var(--danger); + font: 700 7px var(--font-mono); +} +.resource-death-saves strong { + margin-top: 4px; + color: var(--ink); + font-size: 10px; +} +.resource-death-save-actions { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 10px; +} +.resource-death-save-actions button { + min-height: 28px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--muted); + background: #111411; + font-size: 8px; +} +.resource-conditions { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} +.resource-condition-heading { + display: flex; + justify-content: space-between; + gap: 10px; +} +.resource-condition-heading strong { + color: var(--ink); + font-size: 9px; +} +.resource-condition-list { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 9px 0; +} +.resource-condition-list > span { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 27px; + padding: 4px 8px; + border: 1px solid var(--line-strong); + border-radius: 14px; + color: var(--ink); + background: #242925; + font-size: 8px; +} +.resource-condition-list button { + width: 16px; + height: 16px; + padding: 0; + border: 0; + color: var(--muted); + background: transparent; +} +.resource-condition-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; +} +.resource-condition-controls button { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--ink); + background: #242925; +} +.resource-condition-controls button:disabled { + opacity: .35; +} +.resource-exhaustion { + display: grid; + grid-template-columns: minmax(0, 1fr) 58px; + align-items: center; + gap: 5px 10px; + margin-top: 10px; +} +.resource-exhaustion input { + text-align: center; +} +.resource-exhaustion small { + grid-column: 1 / -1; + color: var(--muted); + font-size: 8px; +} +.resource-recovery-readout { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: #111411; +} +.resource-recovery-readout strong { + color: var(--accent); + font: 500 18px var(--font-display); +} +.resource-recovery-action { + width: 100%; + min-height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 0; + border-bottom: 1px solid var(--line); + color: var(--ink); + background: transparent; + text-align: left; +} +.resource-recovery-action strong, +.resource-recovery-action small { + display: block; +} +.resource-recovery-action strong { + font-size: 10px; +} +.resource-recovery-action small { + margin-top: 3px; + color: var(--faint); + font-size: 7px; +} +.resource-recovery-action > span { + color: var(--accent); +} +.resource-prepared-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} +.resource-prepared-list article { + min-width: 0; + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: #111411; +} +.resource-prepared-list span, +.resource-prepared-list strong { + display: block; +} +.resource-prepared-list span { + color: var(--faint); + font: 700 7px var(--font-mono); +} +.resource-prepared-list strong { + margin-top: 4px; + overflow: hidden; + color: var(--ink); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.resource-prepared-list button { + min-height: 32px; + padding-inline: 12px; + border: 1px solid var(--accent); + border-radius: 5px; + color: var(--accent); + background: var(--accent-soft); + font: 700 8px var(--font-mono); + white-space: nowrap; +} + +/* Live table rebuilt from the Pencil session compositions */ +.live-table-page { + width: min(1196px, calc(100% - 80px)); + margin: 0 auto; + padding: 32px 0 72px; +} +.session-connection-portal { + min-height: 720px; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(360px, .8fr); + align-items: center; + gap: 52px; +} +.session-connection-intro { + max-width: 650px; +} +.session-live-label { + color: var(--accent); + font: 700 8px var(--font-mono); + letter-spacing: .12em; +} +.session-connection-intro h1, +.session-connecting-state h1, +.live-table-header h1 { + margin: 12px 0 8px; + color: var(--ink); + font: 500 36px/1.08 var(--font-display); + letter-spacing: -.035em; +} +.session-connection-intro > p, +.session-connecting-state > p, +.live-table-header p { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.55; +} +.session-connection-proof { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 16px 13px; + margin-top: 34px; + padding-top: 24px; + border-top: 1px solid var(--line); +} +.session-connection-proof > span { + color: var(--accent); + font: 700 10px var(--font-mono); +} +.session-connection-proof strong, +.session-connection-proof small { + display: block; +} +.session-connection-proof strong { + color: var(--ink); + font-size: 11px; +} +.session-connection-proof small { + margin-top: 3px; + color: var(--faint); + font-size: 8px; +} +.session-connection-card { + padding: 28px; + border: 1px solid var(--line-strong); + border-radius: 9px; + background: #171a18; +} +.session-connection-heading { + margin-bottom: 22px; +} +.session-connection-heading > span { + color: var(--accent); + font: 700 8px var(--font-mono); + letter-spacing: .12em; +} +.session-connection-heading h2 { + margin: 6px 0; + color: var(--ink); + font: 700 23px var(--font-display); +} +.session-connection-heading p { + margin: 0; + color: var(--muted); + font-size: 10px; +} +.session-field { + display: grid; + gap: 6px; + margin-top: 11px; +} +.session-field span, +.session-encounter-form label > span, +.session-turn-fields label > span, +.session-turn-notes > span { + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .08em; +} +.session-field input, +.session-encounter-form input, +.session-encounter-form select, +.session-turn-fields input, +.session-turn-notes input { + width: 100%; + min-width: 0; + height: 44px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--ink); + background: #0f1110; +} +.session-host-button, +.session-join-button { + width: 100%; + min-height: 48px; + margin-top: 12px; + border-radius: 6px; + font-weight: 700; +} +.session-host-button { + border: 1px solid var(--accent); + color: #11140e; + background: var(--accent); +} +.session-join-button { + border: 1px solid var(--line-strong); + color: var(--ink); + background: #242925; +} +.session-join-button:disabled { + opacity: .4; +} +.session-connection-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 18px 0 4px; + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .1em; +} +.session-connection-divider::after { + height: 1px; + flex: 1; + content: ""; + background: var(--line); +} +.session-connection-error { + margin: 12px 0 0; + color: var(--danger); + font-size: 9px; +} +.session-connecting-state { + min-height: 650px; + display: grid; + place-content: center; + justify-items: center; + text-align: center; +} +.session-connecting-state .connection-spinner { + margin-bottom: 20px; +} +.live-table-header { + min-height: 116px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 28px; +} +.live-table-header h1 { + font-size: 34px; +} +.live-table-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} +.session-invite-button, +.session-leave-button, +.session-player-roll-button, +.session-next-turn-button { + min-height: 46px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 18px; + border: 1px solid var(--line-strong); + border-radius: 7px; + color: var(--ink); + background: #171a18; + font-size: 10px; + text-decoration: none; + white-space: nowrap; +} +.session-leave-button { + color: var(--muted); +} +.session-next-turn-button { + min-width: 168px; + border-color: var(--accent); + color: #11140e; + background: var(--accent); + font-weight: 700; +} +.session-player-roll-button { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); +} +.session-next-turn-button:disabled, +.session-player-roll-button:disabled { + opacity: .4; +} +.live-table-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + align-items: start; + gap: 22px; +} +.session-initiative-board, +.session-party-card, +.session-activity-card, +.session-rule-settings { + border: 1px solid var(--line); + border-radius: 9px; + background: #171a18; +} +.session-initiative-board { + min-height: 660px; + padding: 26px; +} +.session-board-heading, +.session-party-heading, +.session-activity-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.session-board-heading { + margin-bottom: 22px; +} +.session-board-heading h2, +.session-board-heading p { + margin: 0; +} +.session-board-heading h2, +.session-party-heading h2 { + color: var(--ink); + font: 700 18px var(--font-display); +} +.session-board-heading p { + margin-top: 3px; + color: var(--muted); + font-size: 9px; +} +.session-board-heading > span { + padding: 8px 11px; + border-radius: 5px; + color: var(--accent); + background: #27331b; + font: 700 8px var(--font-mono); + letter-spacing: .08em; +} +.session-combat-actions { + margin: -8px 0 18px; + display: flex; + justify-content: flex-end; + gap: 7px; +} +.session-combat-actions button { + min-height: 38px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 7px; + color: var(--muted); + background: transparent; + font-size: 9px; +} +.session-combat-actions button:hover:not(:disabled) { + color: var(--ink); + border-color: var(--line-strong); + background: var(--surface-soft); +} +.session-combat-actions button.danger-action { color: var(--danger); } +.session-combat-actions button:disabled { opacity: .35; cursor: default; } +.session-current-turn { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(220px, .8fr); + gap: 24px; + min-height: 218px; + margin-bottom: 18px; + padding: 24px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: #191d1a; +} +.session-current-turn.empty { + display: grid; + grid-template-columns: 1fr; + align-content: center; +} +.session-current-turn h3 { + margin: 12px 0 7px; + color: var(--ink); + font: 500 29px var(--font-display); +} +.session-current-turn p { + margin: 0; + color: var(--muted); + font-size: 10px; +} +.session-current-facts { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 54px; +} +.session-current-facts span { + padding: 7px 9px; + border-radius: 4px; + color: var(--faint); + background: #242925; + font: 700 7px var(--font-mono); +} +.session-current-health { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-content: center; + gap: 9px; + padding-left: 24px; + border-left: 1px solid var(--line); +} +.session-current-health > span { + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .09em; +} +.session-current-health > strong { + grid-column: 1 / -1; + color: var(--ink); + font: 700 29px var(--font-display); +} +.session-current-health > small { + grid-column: 2; + grid-row: 1; + color: var(--accent); + font: 700 7px var(--font-mono); +} +.session-health-track { + grid-column: 1 / -1; + height: 7px; + overflow: hidden; + border-radius: 4px; + background: #242925; +} +.session-health-track span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--accent); +} +.session-health-actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + margin-top: 16px; +} +.session-health-action-control { + position: relative; + min-width: 0; +} +.session-health-action-button { + width: 100%; +} +.session-health-amount-popover { + position: absolute; + z-index: 5; + bottom: calc(100% + 2px); + left: 50%; + min-width: 156px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + padding: 10px; + border: 1px solid var(--line-strong); + border-radius: 6px; + opacity: 0; + visibility: hidden; + color: var(--ink); + background: #111411; + box-shadow: 0 12px 28px rgba(0, 0, 0, .42); + pointer-events: none; + transform: translate(-50%, 5px); + transition: + opacity 120ms ease, + transform 120ms ease, + visibility 120ms ease; +} +.session-health-action-control:hover .session-health-amount-popover, +.session-health-action-control:focus-within .session-health-amount-popover { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translate(-50%, 0); +} +.session-health-amount-popover > span { + grid-column: 1 / -1; + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .08em; +} +.session-health-amount-popover input { + width: 92px; + height: 34px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--ink); + background: #0f1110; + text-align: center; +} +.session-health-amount-popover small { + color: var(--faint); + font: 700 7px var(--font-mono); +} +.session-health-action-control.damage:hover .session-health-action-button, +.session-health-action-control.damage:focus-within .session-health-action-button { + border-color: var(--danger); + color: var(--danger); +} +.session-health-action-control.heal:hover .session-health-action-button, +.session-health-action-control.heal:focus-within .session-health-action-button { + border-color: var(--accent); + color: var(--accent); +} +.session-health-actions button { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--muted); + background: #242925; +} +.session-health-actions button:disabled { + opacity: .35; +} +.session-turn-list { + border-top: 1px solid var(--line); +} +.session-turn-row { + border-bottom: 1px solid var(--line); + background: transparent; +} +.session-turn-row.active { + background: #27331b; +} +.session-turn-row summary { + min-height: 64px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 94px 84px 30px; + align-items: center; + gap: 10px; + padding: 0 12px; + cursor: pointer; + list-style: none; +} +.session-turn-row summary::-webkit-details-marker { + display: none; +} +.session-turn-initiative { + color: var(--accent); + font: 500 17px var(--font-display); +} +.session-turn-identity strong, +.session-turn-identity small { + display: block; +} +.session-turn-identity strong { + color: var(--ink); + font-size: 10px; +} +.session-turn-identity small { + margin-top: 3px; + color: var(--faint); + font: 600 7px var(--font-mono); +} +.session-turn-health { + color: var(--muted); + font: 600 7px var(--font-mono); + text-align: right; +} +.session-turn-status { + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 9px; + overflow: hidden; + border-radius: 14px; + color: var(--accent); + background: #242925; + font-size: 7px; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-turn-status.danger { + color: var(--danger); + background: rgba(255, 92, 92, .18); +} +.session-turn-disclosure { + color: var(--faint); + text-align: right; +} +.session-turn-editor, +.session-turn-readonly { + padding: 14px; + border-top: 1px solid var(--line); + background: #111411; +} +.session-turn-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.session-turn-fields label, +.session-turn-notes { + display: grid; + gap: 5px; +} +.session-turn-fields input { + height: 36px; +} +.session-condition-editor { + margin-top: 11px; + padding-top: 11px; + border-top: 1px solid var(--line); +} +.session-condition-editor > span { + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .08em; +} +.session-condition-list { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} +.session-condition-list > span { + min-height: 27px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid var(--line-strong); + border-radius: 14px; + color: var(--ink); + background: #242925; + font-size: 8px; +} +.session-condition-list button { + width: 16px; + height: 16px; + min-height: 0; + padding: 0; + border: 0; + color: var(--muted); + background: transparent; +} +.session-condition-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) 112px; + align-items: end; + gap: 7px; + margin-top: 8px; +} +.session-condition-controls select, +.session-condition-controls input { + width: 100%; + min-width: 0; + height: 36px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--ink); + background: #171a18; +} +.session-condition-controls label { + display: grid; + gap: 5px; +} +.session-condition-controls label span { + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .08em; +} +.session-condition-controls input { + text-align: center; +} +.session-player-condition-summary { + padding: 11px; + border: 1px solid var(--line); + border-radius: 5px; + background: #171a18; +} +.session-player-condition-summary p, +.session-player-condition-summary small { + display: block; + margin: 7px 0 0; + color: var(--muted); + font-size: 8px; + line-height: 1.45; +} +.session-player-condition-summary small { + color: var(--faint); +} +.session-turn-notes { + margin-top: 10px; +} +.session-turn-notes input { + height: 36px; +} +.session-turn-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + margin-top: 10px; +} +.session-turn-actions button { + min-height: 30px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--muted); + background: #171a18; + font-size: 8px; +} +.session-turn-actions button.danger { + color: var(--danger); +} +.session-turn-readonly { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 8px; +} +.session-order-empty { + min-height: 220px; + display: grid; + place-content: center; + justify-items: center; + gap: 7px; + border: 1px dashed var(--line-strong); + color: var(--muted); + text-align: center; +} +.session-order-empty > span { + color: var(--accent); + font-size: 24px; +} +.session-order-empty strong, +.session-order-empty p { + margin: 0; +} +.session-order-empty strong { + color: var(--ink); +} +.session-order-empty p { + max-width: 380px; + font-size: 9px; +} +.session-encounter-controls, +.session-rule-settings { + margin-top: 18px; + overflow: hidden; +} +.session-encounter-controls > summary, +.session-rule-settings > summary { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + list-style: none; +} +.session-encounter-controls > summary { + padding: 0 4px; +} +.session-encounter-controls > summary::-webkit-details-marker, +.session-rule-settings > summary::-webkit-details-marker { + display: none; +} +.session-encounter-controls > summary span, +.session-rule-settings > summary span { + color: var(--accent); + font: 700 7px var(--font-mono); + letter-spacing: .08em; +} +.session-encounter-controls > summary strong, +.session-rule-settings > summary strong { + display: block; + margin-top: 4px; + color: var(--ink); + font-size: 10px; +} +.session-encounter-form { + display: grid; + grid-template-columns: minmax(150px, 1fr) minmax(170px, 1fr) minmax(140px, .8fr) 86px 110px auto; + align-items: end; + gap: 7px; + padding: 14px; + border: 1px solid var(--line); + background: #111411; +} +.session-encounter-form label { + min-width: 0; + display: grid; + gap: 5px; +} +.session-encounter-form input, +.session-encounter-form select { + height: 36px; +} +.session-add-combatant-button { + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--accent); + border-radius: 5px; + color: #11140e; + background: var(--accent); + font: 700 8px var(--font-mono); + white-space: nowrap; +} +.session-party-rail { + display: grid; + gap: 14px; +} +.session-party-card { + padding: 20px; +} +.session-party-heading { + margin-bottom: 12px; +} +.session-party-heading h2 { + margin: 0; +} +.session-party-heading span { + color: var(--faint); + font: 700 7px var(--font-mono); +} +.session-party-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + margin-bottom: 10px; + padding: 4px; + border-radius: 5px; + background: #242925; +} +.session-party-tabs > * { + min-height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: var(--muted); + font-size: 8px; + text-decoration: none; +} +.session-party-tabs button { + border: 0; + background: transparent; + font: inherit; +} +.session-party-tabs button:hover { + color: var(--ink); + background: var(--surface-raised); +} +.session-party-tabs .active { + color: var(--accent); + background: #27331b; +} +.session-party-list { + display: grid; +} +.session-party-member { + border-bottom: 1px solid var(--line); +} +.session-party-member summary { + min-height: 58px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + cursor: pointer; + list-style: none; +} +.session-party-member summary::-webkit-details-marker { + display: none; +} +.session-party-avatar { + width: 34px; + height: 34px; + display: grid; + place-content: center; + border-radius: 5px; + color: var(--accent); + background: #242925; + font: 700 8px var(--font-mono); +} +.session-party-member summary strong, +.session-party-member summary small { + display: block; +} +.session-party-member summary strong { + color: var(--ink); + font-size: 9px; +} +.session-party-member summary small { + margin-top: 3px; + color: var(--faint); + font-size: 7px; +} +.session-party-presence { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} +.session-party-member-details { + display: grid; + gap: 5px; + padding: 0 0 12px 44px; + color: var(--muted); + font: 600 7px var(--font-mono); +} +.session-party-empty { + min-height: 118px; + display: grid; + place-content: center; + gap: 5px; + color: var(--muted); + text-align: center; +} +.session-party-empty strong { + color: var(--ink); + font-size: 10px; +} +.session-party-empty p { + max-width: 210px; + margin: 0; + font-size: 8px; +} +.session-activity-card { + padding: 18px 0 0; + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 0; + background: transparent; +} +.session-activity-heading { + color: var(--faint); + font: 700 7px var(--font-mono); + letter-spacing: .09em; +} +.session-activity-card > p { + margin: 13px 0 0; + color: var(--muted); + font-size: 8px; +} +.session-activity-list { + display: grid; + gap: 7px; + margin-top: 11px; +} +.session-activity-list > div { + padding-bottom: 7px; + border-bottom: 1px solid var(--line); +} +.session-activity-list span, +.session-activity-list small { + display: block; +} +.session-activity-list span { + color: var(--ink); + font-size: 8px; +} +.session-activity-list small { + margin-top: 3px; + color: var(--faint); + font-size: 7px; +} +.session-rule-settings { + margin-top: 4px; + padding: 0 14px; +} +.session-rule-settings > summary { + min-height: 62px; +} +.session-rule-list { + display: grid; + gap: 6px; + padding: 0 0 14px; +} +.session-rule-list label { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 4px; +} +.session-rule-list strong, +.session-rule-list small { + display: block; +} +.session-rule-list strong { + color: var(--ink); + font-size: 8px; +} +.session-rule-list small { + margin-top: 2px; + color: var(--faint); + font-size: 7px; +} +.session-mobile-next-turn { + display: none; +} + +@media (max-width: 1180px) { + .resource-vitals { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .resource-vital:nth-child(3) { + border-right: 0; + } + .resource-vital:nth-child(-n+3) { + border-bottom: 1px solid var(--line); + } + .resource-management-layout, + .live-table-columns { + grid-template-columns: minmax(0, 1fr); + } + .resource-management-sidebar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .session-party-rail { + grid-template-columns: minmax(280px, .8fr) minmax(0, 1.2fr); + } + .session-rule-settings { + grid-column: 1 / -1; + } + .session-encounter-form { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 800px) { + .resource-management-page, + .live-table-page { + width: 100%; + padding: 22px 16px 96px; + } + .resource-management-page .sheet-page-heading { + margin-bottom: 20px; + } + .resource-command-hero { + grid-template-columns: minmax(0, 1fr); + min-height: 0; + padding: 20px; + } + .resource-command-copy h2 { + font-size: 27px; + } + .resource-rest-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .resource-vitals { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .resource-vital, + .resource-vital:nth-child(3) { + min-height: 94px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + } + .resource-vital:nth-child(even) { + border-right: 0; + } + .resource-vital:nth-last-child(-n+2) { + border-bottom: 0; + } + .resource-management-sidebar { + grid-template-columns: minmax(0, 1fr); + } + .resource-ledger-section, + .resource-side-card { + padding: 17px; + } + .resource-pool-grid, + .resource-prepared-list { + grid-template-columns: minmax(0, 1fr); + } + .resource-custom-form { + grid-template-columns: minmax(0, 1fr) 82px; + } + .resource-custom-form > div:nth-child(3), + .resource-custom-form .resource-add-button { + grid-column: 1 / -1; + } + .session-connection-portal { + min-height: 0; + grid-template-columns: minmax(0, 1fr); + gap: 24px; + } + .session-connection-intro h1 { + font-size: 30px; + } + .session-connection-proof { + display: none; + } + .session-connection-card { + padding: 20px; + } + .live-table-header { + min-height: 82px; + align-items: flex-start; + } + .live-table-header h1 { + margin-top: 8px; + font-size: 29px; + } + .live-table-header p { + display: none; + } + .live-table-header-actions { + padding-top: 4px; + } + .session-leave-button, + .session-next-turn-button, + .session-player-roll-button { + display: none; + } + .session-initiative-board { + min-height: 0; + padding: 0; + border: 0; + background: transparent; + } + .session-board-heading { + margin: 0 0 10px; + padding: 0; + } + .session-board-heading h2 { + display: none; + } + .session-board-heading p { + color: var(--faint); + font: 700 7px var(--font-mono); + text-transform: uppercase; + } + .session-combat-actions { + margin: 0 0 12px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; + } + .session-combat-actions button { + min-width: 0; + min-height: 44px; + padding: 0 5px; + font-size: 8px; + } + .session-current-turn { + grid-template-columns: minmax(0, 1fr); + min-height: 0; + margin-bottom: 18px; + padding: 20px; + } + .session-current-turn h3 { + font-size: 28px; + } + .session-current-facts { + display: none; + } + .session-current-health { + padding: 14px 0 0; + border-top: 1px solid var(--line); + border-left: 0; + } + .session-current-health > strong { + font-size: 25px; + } + .session-health-actions { + margin-top: 8px; + } + .session-health-actions button { + min-height: 44px; + } + .session-health-action-control { + display: grid; + gap: 5px; + } + .session-health-amount-popover { + position: static; + min-width: 0; + opacity: 1; + visibility: visible; + box-shadow: none; + pointer-events: auto; + transform: none; + } + .session-health-amount-popover input { + width: 100%; + } + .session-mobile-next-turn { + grid-column: 1 / -1; + display: inline-flex; + align-items: center; + justify-content: center; + border-color: var(--accent) !important; + color: #11140e !important; + background: var(--accent) !important; + font-weight: 700; + } + .session-turn-list { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 9px; + background: #171a18; + } + .session-turn-row summary { + grid-template-columns: 30px minmax(0, 1fr) 28px; + min-height: 60px; + padding: 0 12px; + } + .session-turn-health, + .session-turn-status { + display: none; + } + .session-turn-fields { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .session-turn-readonly { + flex-direction: column; + } + .session-condition-controls { + grid-template-columns: minmax(0, 1fr) 92px; + } + .session-party-rail { + display: none; + } + .session-encounter-controls { + margin-top: 12px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #171a18; + } + .session-encounter-form { + grid-template-columns: minmax(0, 1fr); + margin-inline: -12px; + } +} + +/* Cohesive builder, progression, inventory, and notes surfaces */ +.text-input, +.select-input { + border-radius: 6px; +} + +.sheet-builder-steps { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.foundation-form { + min-width: 0; +} +.foundation-form .section-heading { + margin-bottom: 20px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} +.foundation-form .identity-grid { + margin-bottom: 16px; +} +.foundation-form .choice-grid { + gap: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 9px; + background: #171a18; +} +.foundation-form .choice-card { + min-height: 156px; + border: 0; + border-right: 1px solid var(--line); + background: transparent; +} +.foundation-form .choice-card:last-child { + border-right: 0; +} + +.class-progression .class-starting-details { + gap: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 9px; + background: #171a18; +} +.class-progression .class-starting-details > div { + border: 0; + border-right: 1px solid var(--line); + background: transparent; +} +.class-progression .class-starting-details > div:nth-child(2) { + border-right: 0; +} +.class-progression .class-starting-details > .class-starting-equipment { + border-top: 1px solid var(--line); + border-right: 0; +} +.class-progression .progression-choices { + border-radius: 9px; +} +.class-progression .feature-list { + gap: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 9px; + background: #171a18; +} +.class-progression .class-feature { + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; +} +.class-progression .class-feature:last-child { + border-bottom: 0; +} +.class-progression .feature-level { + border-radius: 50%; + background: var(--accent-soft); +} + +.inventory-workbench { + gap: 16px; +} +.inventory-browser, +.inventory-inspector, +.inventory-currency, +.inventory-auxiliary > * { + border-radius: 10px; + background: linear-gradient(155deg, #151816, #111411); +} +.inventory-catalog-picker, +.custom-item-picker, +.inventory-empty, +.inventory-inspector-stats { + border-radius: 8px; +} +.inventory-inspector-stats { + overflow: hidden; +} +.coin-control input, +.quantity-control input { + border-radius: 5px; +} + +.notes-heading-actions { + display: flex; + align-items: center; + gap: 12px; +} +.notes-heading-actions .primary-button { + min-width: 112px; +} +.note-editor { + margin-top: 18px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: #171a18; +} +.note-editor-toolbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; +} +.note-editor-toolbar label, +.notes-title-field { + min-width: 0; + display: grid; + gap: 6px; + color: var(--muted); + font: 600 8px var(--font-mono); + letter-spacing: .08em; +} +.note-editor-toolbar label { + width: min(240px, 60%); +} +.note-delete-button { + min-height: 40px; + padding: 0 12px; + border: 1px solid rgba(255,124,112,.35); + border-radius: 6px; + color: var(--danger); + background: transparent; + font: 600 9px var(--font-mono); +} +.note-delete-button:hover { + border-color: var(--danger); + background: rgba(255,124,112,.08); +} +.notes-title-field { + margin-top: 14px; +} +.note-editor .notes-field { + margin-top: 14px; +} +.note-editor .notes-field textarea, +.sheet-resource-workspace .note-editor .notes-field textarea { + min-height: 220px; +} +.note-card { + color: var(--ink); + text-align: left; + cursor: pointer; +} +.note-card:hover { + border-color: var(--line-strong); + background: #1b1f1c; +} +.note-card.selected { + border-color: var(--accent); + box-shadow: inset 3px 0 var(--accent); +} +.note-card h3 { + margin: 11px 0 0; + font: 700 14px var(--font-display); +} + +@media (max-width: 900px) { + .notes-category-panel { + display: grid; + grid-template-columns: repeat(5, minmax(125px, 1fr)); + gap: 4px; + overflow-x: auto; + padding: 10px; + } + .notes-category-panel > .sheet-overline, + .notes-category-panel > h2, + .notes-category-panel > .notes-prompt { + display: none; + } +} + +@media (max-width: 800px) { + .sheet-builder-steps { + grid-template-columns: repeat(3, minmax(72px, 1fr)); + } + .foundation-form .choice-grid { + grid-template-columns: minmax(0, 1fr); + } + .foundation-form .choice-card { + min-height: 124px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .foundation-form .choice-card:last-child { + border-bottom: 0; + } + .class-progression .class-starting-details { + grid-template-columns: minmax(0, 1fr); + } + .class-progression .class-starting-details > div { + border-right: 0; + border-bottom: 1px solid var(--line); + } + .class-progression .class-starting-details > .class-starting-equipment { + border-top: 0; + border-bottom: 0; + } + .inventory-auxiliary { + display: grid; + grid-template-columns: minmax(0, 1fr); + } + .notes-heading, + .notes-heading-actions, + .note-editor-toolbar { + align-items: stretch; + flex-direction: column; + } + .notes-heading-actions .primary-button, + .note-editor-toolbar label { + width: 100%; + } + .note-delete-button { + align-self: flex-start; + } +} + +@media (max-width: 700px) { + .character-library-layer, + .dice-window-layer, + .delete-confirmation-layer { + padding: 0; + align-items: end; + } + .character-library, + .dice-window, + .delete-confirmation-modal { + width: 100%; + max-height: calc(100dvh - 18px); + padding: 20px; + border-radius: 16px 16px 0 0; + } + .library-heading, + .dice-window-heading { + margin-bottom: 16px; + } + .library-heading h2, + .dice-window-heading h2 { + font-size: 23px; + } + .library-heading p, + .dice-window-heading p { + display: none; + } + .delete-confirmation-heading h2 { + font-size: 23px; + } + .character-list-item { + grid-template-columns: 42px minmax(0, 1fr); + padding: 10px; + } + .library-character-portrait { + width: 42px; + height: 42px; + } + .character-actions { + grid-column: 1 / -1; + grid-template-columns: minmax(0, 1fr) 72px; + } + .library-primary-actions { + grid-template-columns: minmax(0, 1fr); + } + .library-export-actions { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + .library-export-actions > span { + grid-column: 1 / -1; + } + .dice-window-layout { + grid-template-columns: minmax(0, 1fr); + } + .dice-composer, + .dice-results-panel { + padding: 14px; + } + .dice-quick-list { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .dice-window-controls { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .secret-roll-controls { + grid-template-columns: minmax(0, 1fr); + } + .dice-results-panel { + min-height: 0; + } + .dice-latest-result { + min-height: 124px; + } + .dice-latest-content > strong { + font-size: 46px; + } + .dice-history { + max-height: 180px; + } +} diff --git a/src/sheets.gleam b/src/sheets.gleam index baf2b6f..2c9f6b1 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -21,10 +21,10 @@ import sheets/browser import sheets/data.{ type Choice, type ClassFeature, type ClassProgression, type CompanionCatalogEntry, type Feat, type FeatureChoice, type InventoryItem, - type Monster, type MonsterFeature, type OriginDetails, type OriginSpecies, - type RuleFeature, type Spell, type SpellIdentity, type StartingItem, - CompanionCatalogEntry, FeatCategoryOptions, FixedOptions, InventoryItem, - SkillOptions, SkillOrToolOptions, StartingItem, ToolOptions, + type Monster, type OriginDetails, type OriginSpecies, type Spell, + type SpellIdentity, type StartingItem, CompanionCatalogEntry, + FeatCategoryOptions, FixedOptions, InventoryItem, SkillOptions, + SkillOrToolOptions, StartingItem, ToolOptions, } import sheets/peer.{ type CompanionInitiative, type DiceRoll, type Encounter, type InitiativeEntry, @@ -36,6 +36,7 @@ import sheets/peer.{ SnapshotEquipment, SnapshotFeat, SnapshotFeature, SnapshotResource, SnapshotSkill, SnapshotSpell, } +import sheets/ui import varasto const abilities_note = "Use the 2024 standard array: 15, 14, 13, 12, 10, 8" @@ -118,7 +119,13 @@ pub type SpellOrder { } pub type PageTab { - CharacterPage + CharacterOverviewPage + CharacterBuilderPage + CharacterInventoryPage + CharacterSpellsPage + CharacterCompanionsPage + CharacterNotesPage + ResourceManagementPage LiveTablePage SavedPlayersPage } @@ -250,6 +257,25 @@ pub type Coin { Copper } +pub type NoteCategory { + SessionNote + PeopleNote + QuestNote + LocationNote +} + +pub type CharacterNote { + CharacterNote(id: String, category: NoteCategory, title: String, body: String) +} + +pub type NoteFilter { + AllNotes + SessionNotes + PeopleNotes + QuestNotes + LocationNotes +} + pub type StoredCharacter { StoredCharacter( id: String, @@ -279,7 +305,7 @@ pub type StoredCharacter { hit_dice_spent: Int, max_hit_points_override: Int, armor_class_override: Int, - notes: String, + notes: List(CharacterNote), other_proficiencies: List(String), health_increases: List(HealthIncrease), spell_slot_uses: List(SpellSlotUse), @@ -355,7 +381,9 @@ pub type Model { max_hit_points_override: Int, armor_class_override: Int, health_configuration_open: Bool, - notes: String, + notes: List(CharacterNote), + active_note_id: String, + active_note_filter: NoteFilter, other_proficiencies: List(String), health_increases: List(HealthIncrease), spell_slot_uses: List(SpellSlotUse), @@ -372,6 +400,7 @@ pub type Model { custom_resource_recovery: String, pending_spell_name: String, pending_spell_level: Int, + inspected_spell_name: String, rest_kind: RestKind, rest_hit_dice_spent: Int, rest_hit_points_recovered: Int, @@ -391,6 +420,7 @@ pub type Model { custom_companion_hit_points: String, custom_companion_armor_class: String, custom_companion_initiative_bonus: String, + inspected_companion_id: String, custom_equipment_name: String, proficiency_choice: String, inspected_equipment_name: String, @@ -410,6 +440,7 @@ pub type Model { active_core_tab: CoreTab, active_progression_tab: ProgressionTab, active_resources_tab: ResourcesTab, + active_builder_tab: AbilitiesTab, active_page: PageTab, species: List(Choice), backgrounds: List(Choice), @@ -450,6 +481,7 @@ pub type Model { encounter_entries: List(InitiativeEntry), encounter_round: Int, encounter_active_id: String, + initiative_hit_point_amount: String, monster_name_input: String, monster_hit_points_input: String, monster_initiative_bonus_input: String, @@ -521,11 +553,13 @@ pub type Message { UserRolledMonsterInitiative(String) UserChangedInitiative(String, String) UserChangedInitiativeHitPoints(String, String) + UserChangedInitiativeHitPointAmount(String) UserChangedMonsterMaximumHitPoints(String, String) UserRemovedMonster(String) UserDuplicatedMonster(String) UserAdvancedInitiative UserStoppedInitiative + UserResetInitiative UserResetEncounter UserAddedInitiativeCondition(String, String) UserRemovedInitiativeCondition(String, String) @@ -549,6 +583,7 @@ pub type Message { UserChangedJoinCode(String) UserClickedHost UserClickedJoin + UserClickedCopyPlayerInvite(String) UserClickedLeave PeerSessionReady(String) PeerSessionFailed(String) @@ -583,6 +618,7 @@ pub type Message { UserChangedCompanionSearch(String) UserChangedCompanionTypeFilter(String) UserSelectedCompanionCatalog(String) + UserInspectedCompanion(String) UserAddedCatalogCompanion UserChangedCustomCompanionName(String) UserChangedCustomCompanionHitPoints(String) @@ -600,6 +636,7 @@ pub type Message { UserChangedCoin(Coin, String) UserChangedEquipmentChoice(String) UserAddedEquipment + UserBoughtEquipment UserChangedCustomEquipment(String) UserAddedCustomEquipment UserChangedCurrentHitPoints(String) @@ -607,7 +644,13 @@ pub type Message { UserChangedHitDiceSpent(String) UserChangedMaxHitPoints(String) UserChangedArmorClass(String) - UserChangedNotes(String) + UserCreatedNote + UserSelectedNote(String) + UserSelectedNoteFilter(NoteFilter) + UserChangedNoteTitle(String) + UserChangedNoteBody(String) + UserChangedNoteCategory(String) + UserDeletedNote(String) UserOpenedHealthConfiguration UserClosedHealthConfiguration UserChangedOtherProficiency(String) @@ -620,6 +663,7 @@ pub type Message { UserSelectedFeat(Int, String) UserSelectedFeatAbility(Int, String) UserToggledSpell(String, Int) + UserInspectedSpell(String) UserOpenedSpellCast(String, Int) UserCancelledSpellCast UserConfirmedSpellCast(Int) @@ -745,7 +789,9 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { hit_dice_spent: 0, max_hit_points_override: 0, armor_class_override: 0, - notes: "", + notes: [], + active_note_id: "", + active_note_filter: AllNotes, other_proficiencies: [], health_increases: [], spell_slot_uses: [], @@ -762,6 +808,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { custom_resource_recovery: "long", pending_spell_name: "", pending_spell_level: 0, + inspected_spell_name: "", rest_kind: NoRest, rest_hit_dice_spent: 0, rest_hit_points_recovered: 0, @@ -781,6 +828,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { custom_companion_hit_points: "1", custom_companion_armor_class: "10", custom_companion_initiative_bonus: "0", + inspected_companion_id: "", proficiency_choice: "", custom_equipment_name: "", inspected_equipment_name: "", @@ -809,6 +857,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { active_core_tab:, active_progression_tab:, active_resources_tab:, + active_builder_tab: initial_character_tab, active_page:, species: data.fallback_species(), health_configuration_open: False, @@ -841,6 +890,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { encounter_entries: [], encounter_round: 0, encounter_active_id: "", + initiative_hit_point_amount: "1", monster_name_input: "", monster_hit_points_input: "1", monster_initiative_bonus_input: "0", @@ -865,7 +915,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { fn initial_navigation() -> #(PageTab, AbilitiesTab) { case modem.initial_uri() { Ok(location) -> navigation_from_uri(location) - Error(Nil) -> #(CharacterPage, AbilityScoresTab) + Error(Nil) -> #(CharacterOverviewPage, AbilityScoresTab) } } @@ -873,8 +923,17 @@ fn navigation_from_uri(location: uri.Uri) -> #(PageTab, AbilitiesTab) { case location.fragment |> option.unwrap("") |> string.split("/") { ["saved-players"] -> #(SavedPlayersPage, AbilityScoresTab) ["live-table"] -> #(LiveTablePage, AbilityScoresTab) - ["character", tab] -> #(CharacterPage, abilities_tab_from_slug(tab)) - _ -> #(CharacterPage, AbilityScoresTab) + ["character", "resources"] -> #(ResourceManagementPage, AbilityScoresTab) + ["character", "overview"] -> #(CharacterOverviewPage, AbilityScoresTab) + ["character", "builder", tab] -> #( + CharacterBuilderPage, + abilities_tab_from_slug(tab), + ) + ["character", tab] -> { + let character_tab = abilities_tab_from_slug(tab) + #(page_for_character_tab(character_tab), character_tab) + } + _ -> #(CharacterOverviewPage, AbilityScoresTab) } } @@ -883,13 +942,34 @@ fn push_navigation(page: PageTab, tab: AbilitiesTab) -> Effect(Message) { "", option.None, option.Some(case page { + CharacterOverviewPage -> "character/overview" + CharacterBuilderPage -> "character/builder/" <> abilities_tab_slug(tab) + CharacterInventoryPage -> "character/equipment" + CharacterSpellsPage -> "character/spells" + CharacterCompanionsPage -> "character/companions" + CharacterNotesPage -> "character/notes" + ResourceManagementPage -> "character/resources" LiveTablePage -> "live-table" SavedPlayersPage -> "saved-players" - CharacterPage -> "character/" <> abilities_tab_slug(tab) }), ) } +fn page_for_character_tab(tab: AbilitiesTab) -> PageTab { + case tab { + EquipmentTab -> CharacterInventoryPage + SpellsTab -> CharacterSpellsPage + CompanionsTab -> CharacterCompanionsPage + NotesTab -> CharacterNotesPage + AbilityScoresTab + | ClassFeaturesTab + | SkillProficienciesTab + | OriginsTab + | FeatsTab + | LevelUpTab -> CharacterBuilderPage + } +} + fn abilities_tab_slug(tab: AbilitiesTab) -> String { case tab { AbilityScoresTab -> "ability-scores" @@ -943,6 +1023,7 @@ fn initial_section_tabs(tab: AbilitiesTab) -> SectionTabs { } fn select_character_tab(model: Model, tab: AbilitiesTab) -> Model { + let model = Model(..model, active_builder_tab: tab) case tab { AbilityScoresTab -> Model(..model, active_core_tab: CoreAbilityScores) SkillProficienciesTab -> @@ -1128,7 +1209,7 @@ fn new_stored_character() -> StoredCharacter { hit_dice_spent: 0, max_hit_points_override: 0, armor_class_override: 0, - notes: "", + notes: [], other_proficiencies: [], health_increases: [], spell_slot_uses: [], @@ -1307,9 +1388,9 @@ fn ensure_starting_items( ) -> List(EquipmentItem) { list.fold(starting_items, model.equipment, fn(equipment, starting_item) { let StartingItem(name:, quantity:) = starting_item - case name == "" || list.any(equipment, fn(item) { item.name == name }) { - True -> equipment - False -> { + case name { + "" -> equipment + name -> { let catalog_item = list.find(model.equipment_catalog, fn(item) { item.name == name }) let item = case catalog_item { @@ -1332,12 +1413,45 @@ fn ensure_starting_items( quantity:, ) } - [item, ..equipment] + add_equipment_item(equipment, item) } } }) } +fn remove_starting_items( + equipment: List(EquipmentItem), + starting_items: List(StartingItem), +) -> List(EquipmentItem) { + list.fold(starting_items, equipment, fn(equipment, starting_item) { + let StartingItem(name:, quantity:) = starting_item + equipment + |> list.flat_map(fn(item) { + case item.name == name, item.quantity > quantity { + False, _ -> [item] + True, True -> [ + EquipmentItem(..item, quantity: item.quantity - quantity), + ] + True, False -> [] + } + }) + }) +} + +fn starting_choice_gold(model: Model, id: String, value: String) -> Int { + case id, value, model.origin_details, model.class_progression { + "origin:background:equipment", "Equipment package", Ok(details), _ -> + details.starting_gold + "origin:background:equipment", "50 GP", Ok(details), _ -> + details.gold_alternative + "class:starting-equipment", "Equipment package", _, Ok(progression) -> + progression.starting_gold + "class:starting-equipment", "Gold alternative", _, Ok(progression) -> + progression.gold_alternative + _, _, _, _ -> 0 + } +} + fn add_equipment_item( equipment: List(EquipmentItem), incoming: EquipmentItem, @@ -1423,6 +1537,14 @@ fn upsert_feat( fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { case message { + UserInspectedSpell(name) -> #( + Model(..model, inspected_spell_name: name), + effect.none(), + ) + UserInspectedCompanion(id) -> #( + Model(..model, inspected_companion_id: id), + effect.none(), + ) SavedPlayerCharactersLoaded(characters) -> #( Model(..model, saved_player_characters: characters), effect.none(), @@ -1724,6 +1846,20 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { |> remove_non_skill_proficiency(previous) |> add_non_skill_proficiency(value) } + let equipment_without_previous = case + id, + previous, + model.origin_details, + model.class_progression + { + "origin:background:equipment", "Equipment package", Ok(details), _ -> + remove_starting_items(model.equipment, details.starting_items) + "class:starting-equipment", "Equipment package", _, Ok(progression) -> + remove_starting_items(model.equipment, progression.starting_items) + _, _, _, _ -> model.equipment + } + let equipment_model = + Model(..model, equipment: equipment_without_previous) let equipment = case id, value, @@ -1731,11 +1867,15 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { model.class_progression { "origin:background:equipment", "Equipment package", Ok(details), _ -> - ensure_starting_items(model, details.starting_items) + ensure_starting_items(equipment_model, details.starting_items) "class:starting-equipment", "Equipment package", _, Ok(progression) -> - ensure_starting_items(model, progression.starting_items) - _, _, _, _ -> model.equipment + ensure_starting_items(equipment_model, progression.starting_items) + _, _, _, _ -> equipment_without_previous } + let previous_starting_gold = starting_choice_gold(model, id, previous) + let selected_starting_gold = starting_choice_gold(model, id, value) + let gold = + int.max(0, model.gold - previous_starting_gold + selected_starting_gold) update_and_sync( model, Model( @@ -1744,13 +1884,17 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { skills:, other_proficiencies:, equipment:, + gold:, ), ) } - UserSelectedAbilitiesTab(tab) -> #( - select_character_tab(Model(..model, active_page: CharacterPage), tab), - push_navigation(CharacterPage, tab), - ) + UserSelectedAbilitiesTab(tab) -> { + let page = page_for_character_tab(tab) + #( + select_character_tab(Model(..model, active_page: page), tab), + push_navigation(page, tab), + ) + } UserSelectedPage(page) -> #( Model(..model, active_page: page), push_navigation(page, core_tab_to_character_tab(model.active_core_tab)), @@ -1875,13 +2019,22 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { UserChangedInitiative(id, value) -> change_initiative(model, id, value) UserChangedInitiativeHitPoints(id, value) -> change_initiative_hit_points(model, id, value) + UserChangedInitiativeHitPointAmount(value) -> #( + Model(..model, initiative_hit_point_amount: value), + effect.none(), + ) UserChangedMonsterMaximumHitPoints(id, value) -> change_monster_maximum_hit_points(model, id, value) UserRemovedMonster(id) -> remove_monster(model, id) UserDuplicatedMonster(id) -> duplicate_monster(model, id) UserAdvancedInitiative -> advance_initiative(model) UserStoppedInitiative -> stop_initiative(model) + UserResetInitiative -> reset_initiative(model) UserResetEncounter -> reset_encounter(model) + UserClickedCopyPlayerInvite(ticket) -> #( + model, + browser.copy_text(browser.invite_link(ticket)), + ) UserAddedInitiativeCondition(id, condition) -> change_initiative_conditions(model, id, fn(conditions) { add_initiative_condition(conditions, condition) @@ -2053,6 +2206,24 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { } } } + UserBoughtEquipment -> { + case selected_equipment_catalog_item(model) { + Error(Nil) -> #(model, effect.none()) + Ok(selected) -> + case spend_currency(model, selected.value) { + Error(Nil) -> #(model, effect.none()) + Ok(updated) -> + update_and_sync( + model, + Model( + ..updated, + equipment: add_inventory_selection(model, selected), + equipment_choice: "", + ), + ) + } + } + } UserChangedCustomEquipment(value) -> #( Model(..model, custom_equipment_name: value), effect.none(), @@ -2151,8 +2322,56 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ), ), ) - UserChangedNotes(value) -> - update_and_sync(model, Model(..model, notes: value)) + UserCreatedNote -> { + let category = note_filter_default_category(model.active_note_filter) + let note = + CharacterNote(id: browser.new_id(), category:, title: "", body: "") + update_and_sync( + model, + Model(..model, notes: [note, ..model.notes], active_note_id: note.id), + ) + } + UserSelectedNote(id) -> #(Model(..model, active_note_id: id), effect.none()) + UserSelectedNoteFilter(filter) -> #( + Model(..model, active_note_id: "", active_note_filter: filter), + effect.none(), + ) + UserChangedNoteTitle(title) -> + update_and_sync( + model, + update_active_note(model, fn(note) { CharacterNote(..note, title:) }), + ) + UserChangedNoteBody(body) -> + update_and_sync( + model, + update_active_note(model, fn(note) { CharacterNote(..note, body:) }), + ) + UserChangedNoteCategory(value) -> + case note_category_from_value(value) { + Error(Nil) -> #(model, effect.none()) + Ok(category) -> { + let updated = + update_active_note(model, fn(note) { + CharacterNote(..note, category:) + }) + let active_note_filter = case + note_filter_matches(model.active_note_filter, category) + { + True -> model.active_note_filter + False -> AllNotes + } + update_and_sync(model, Model(..updated, active_note_filter:)) + } + } + UserDeletedNote(id) -> + update_and_sync( + model, + Model( + ..model, + notes: list.filter(model.notes, fn(note) { note.id != id }), + active_note_id: "", + ), + ) UserChangedOtherProficiency(value) -> #( Model(..model, proficiency_choice: value), effect.none(), @@ -3818,30 +4037,6 @@ fn snapshot_exhaustion_level(snapshot: Snapshot) -> Int { }) } -fn condition_strings_to_character_conditions( - conditions: List(String), -) -> List(CharacterCondition) { - conditions - |> list.map(fn(condition) { - case string.split(condition, " ") { - ["Exhaustion", value] -> - CharacterCondition( - name: "Exhaustion", - value: value - |> int.parse - |> result.unwrap(0) - |> int.max(0) - |> int.min(6), - ) - _ -> CharacterCondition(name: condition, value: 0) - } - }) - |> list.filter(fn(condition) { - condition.name != "" - && { condition.name != "Exhaustion" || condition.value > 0 } - }) -} - fn character_conditions_to_strings( conditions: List(CharacterCondition), ) -> List(String) { @@ -3969,6 +4164,8 @@ fn load_stored_character( max_hit_points_override: character.max_hit_points_override, armor_class_override: character.armor_class_override, notes: character.notes, + active_note_id: "", + active_note_filter: AllNotes, other_proficiencies: character.other_proficiencies, health_increases: character.health_increases, spell_slot_uses: character.spell_slot_uses, @@ -4324,7 +4521,7 @@ fn stored_character_json(character: StoredCharacter) -> json.Json { #("hitDiceSpent", json.int(character.hit_dice_spent)), #("maxHitPointsOverride", json.int(character.max_hit_points_override)), #("armorClassOverride", json.int(character.armor_class_override)), - #("notes", json.string(character.notes)), + #("noteEntries", json.array(character.notes, character_note_json)), #( "otherProficiencies", json.array(character.other_proficiencies, json.string), @@ -4420,7 +4617,24 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { 0, decode.int, ) - use notes <- decode.optional_field("notes", "", decode.string) + use legacy_notes <- decode.optional_field("notes", "", decode.string) + use note_entries <- decode.optional_field( + "noteEntries", + [], + decode.list(character_note_decoder()), + ) + let notes = case note_entries, string.trim(legacy_notes) { + [], "" -> [] + [], body -> [ + CharacterNote( + id: "imported-note", + category: SessionNote, + title: "Imported note", + body:, + ), + ] + note_entries, _ -> note_entries + } use other_proficiencies <- decode.optional_field( "otherProficiencies", [], @@ -4518,6 +4732,26 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { )) } +fn character_note_json(note: CharacterNote) -> json.Json { + json.object([ + #("id", json.string(note.id)), + #("category", json.string(note_category_value(note.category))), + #("title", json.string(note.title)), + #("body", json.string(note.body)), + ]) +} + +fn character_note_decoder() -> decode.Decoder(CharacterNote) { + use id <- decode.field("id", decode.string) + use category_value <- decode.field("category", decode.string) + use title <- decode.optional_field("title", "", decode.string) + use body <- decode.optional_field("body", "", decode.string) + let category = + note_category_from_value(category_value) + |> result.unwrap(SessionNote) + decode.success(CharacterNote(id:, category:, title:, body:)) +} + fn spell_slot_use_json(slot: SpellSlotUse) -> json.Json { json.object([ #("level", json.int(slot.level)), @@ -4910,6 +5144,14 @@ fn peer_display_name(model: Model, fallback: String) -> String { } } +fn dice_roll_author(model: Model) -> String { + case model.connection, model.has_character { + Connected(role: Player, ..), True -> + character_display_name(model_to_stored_character(model)) + _, _ -> peer_display_name(model, "Unnamed adventurer") + } +} + fn upsert_snapshot( snapshots: List(Snapshot), incoming: Snapshot, @@ -4922,11 +5164,8 @@ fn upsert_snapshot( ] } -fn snapshots_match(left: Snapshot, right: Snapshot) -> Bool { - case left.character_id, right.character_id { - left_id, right_id if left_id != "" && right_id != "" -> left_id == right_id - _, _ -> left.owner == right.owner - } +pub fn snapshots_match(left: Snapshot, right: Snapshot) -> Bool { + left.character_id != "" && left.character_id == right.character_id } fn connection_ticket(connection: Connection) -> Result(String, Nil) { @@ -4940,14 +5179,12 @@ fn connection_ticket(connection: Connection) -> Result(String, Nil) { fn saved_player_matches( character: SavedPlayerCharacter, - table_ticket: String, snapshot: Snapshot, ) -> Bool { - character.table_ticket == table_ticket - && snapshots_match(character.snapshot, snapshot) + snapshots_match(character.snapshot, snapshot) } -fn save_player_snapshot( +pub fn save_player_snapshot( characters: List(SavedPlayerCharacter), table_ticket: String, snapshot: Snapshot, @@ -4955,13 +5192,19 @@ fn save_player_snapshot( ) -> List(SavedPlayerCharacter) { case list.find(characters, fn(character) { - saved_player_matches(character, table_ticket, snapshot) + saved_player_matches(character, snapshot) }) { Ok(saved) -> list.map(characters, fn(character) { case character.id == saved.id { - True -> SavedPlayerCharacter(..character, last_synced_at:, snapshot:) + True -> + SavedPlayerCharacter( + ..character, + table_ticket:, + last_synced_at:, + snapshot:, + ) False -> character } }) @@ -5030,7 +5273,7 @@ fn roll_dice(model: Model) -> #(Model, Effect(Message)) { let roll = DiceRoll( id: browser.new_id(), - author: peer_display_name(model, "Player"), + author: dice_roll_author(model), created_at: browser.now_iso(), mode: roll_mode_name(mode), dice_count:, @@ -5159,13 +5402,11 @@ fn receive_encounter( entry.kind == "player" && entry.owner == player_owner }) { - Ok(entry) -> { - let conditions = - condition_strings_to_character_conditions(entry.conditions) - let hit_points_changed = - effective_current_hit_points(model) != entry.current_hit_points - let conditions_changed = model.conditions != conditions - case model.has_character && { hit_points_changed || conditions_changed } { + Ok(entry) -> + case + model.has_character + && effective_current_hit_points(model) != entry.current_hit_points + { True -> update_from_encounter( Model( @@ -5176,12 +5417,10 @@ fn receive_encounter( 0, character_hit_points(model), ), - conditions:, ), ) False -> #(updated, effect.none()) } - } Error(Nil) -> #(updated, effect.none()) } } @@ -5880,6 +6119,10 @@ fn advance_initiative(model: Model) -> #(Model, Effect(Message)) { } fn stop_initiative(model: Model) -> #(Model, Effect(Message)) { + update_encounter(model, model.encounter_entries, 0, "") +} + +fn reset_initiative(model: Model) -> #(Model, Effect(Message)) { let entries = list.map(model.encounter_entries, fn(entry) { InitiativeEntry( @@ -5910,19 +6153,29 @@ fn change_initiative_conditions( id: String, change: fn(List(String)) -> List(String), ) -> #(Model, Effect(Message)) { - let entries = - list.map(model.encounter_entries, fn(entry) { - case entry.id == id { - True -> InitiativeEntry(..entry, conditions: change(entry.conditions)) - False -> entry - } + let player_owned = + list.any(model.encounter_entries, fn(entry) { + entry.id == id && entry.kind == "player" }) - update_encounter( - model, - entries, - model.encounter_round, - model.encounter_active_id, - ) + case player_owned { + True -> #(model, effect.none()) + False -> { + let entries = + list.map(model.encounter_entries, fn(entry) { + case entry.id == id { + True -> + InitiativeEntry(..entry, conditions: change(entry.conditions)) + False -> entry + } + }) + update_encounter( + model, + entries, + model.encounter_round, + model.encounter_active_id, + ) + } + } } fn initiative_condition_name(condition: String) -> String { @@ -6028,15 +6281,17 @@ fn upsert_dice_roll( fn view(model: Model) -> Element(Message) { let character_ready = model.library_initialized && model.has_character let workspace = case model.library_initialized, model.active_page { + True, ResourceManagementPage if model.has_character -> + view_resource_management_page(model) True, SavedPlayersPage -> view_saved_players_workspace(model) _, _ -> case model.connection { - Connected(role: GameMaster, ..) -> view_master_workspace(model) - Connecting(role: GameMaster) -> view_master_workspace(model) + Connected(role: GameMaster, ..) -> view_master_session_page(model) + Connecting(role: GameMaster) -> view_master_session_page(model) Connected(role: Player, ..) if !character_ready -> - view_table_workspace(model) + view_live_session_page(model) Connecting(role: Player) if !character_ready -> - view_table_workspace(model) + view_live_session_page(model) _ -> case model.library_initialized, model.has_character { False, _ -> view_character_loading() @@ -6045,30 +6300,9 @@ fn view(model: Model) -> Element(Message) { } } } - let shell_class = case model.connection { - Connected(role: GameMaster, ..) -> "app-shell game-master-shell" - Connecting(role: GameMaster) -> "app-shell game-master-shell" - _ -> "app-shell" - } - html.div([attribute.class(shell_class)], [ - view_header(model), - case - model.library_initialized, - model.has_character, - model.saved_player_characters, - model.connection - { - True, True, _, _ -> view_page_navigation(model) - True, False, [_, ..], _ -> view_page_navigation(model) - True, False, _, Connected(role: GameMaster, ..) -> - view_page_navigation(model) - True, False, _, Connecting(role: GameMaster) -> - view_page_navigation(model) - _, _, _, _ -> html.text("") - }, - view_rule_restriction_notice(model), - workspace, + html.div([attribute.class("sheet-root")], [ + view_application_shell(model, workspace), case character_ready, model.dice_open { True, False -> view_global_dice_button() _, _ -> html.text("") @@ -6099,3894 +6333,4820 @@ fn view(model: Model) -> Element(Message) { "" -> html.text("") _ -> view_delete_confirmation(model) }, - view_footer(), + case model.library_open { + True -> view_character_library(model) + False -> html.text("") + }, + case model.rules_data_settings_open { + True -> view_rules_data_settings(model) + False -> html.text("") + }, ]) } -fn view_master_workspace(model: Model) -> Element(Message) { - html.main([attribute.class("workspace master-workspace")], [ - html.div([attribute.class("builder master-console")], [ - view_collaboration(model), - view_book_restrictions(model), - view_initiative_tracker(model, InitiativeEditable), +fn view_application_shell( + model: Model, + workspace: Element(Message), +) -> Element(Message) { + let identity_name = case model.has_character, model.active_page { + _, SavedPlayersPage -> "Offline Library" + True, _ -> + case string.trim(model.character_name) { + "" -> "Unnamed Adventurer" + name -> name + } + False, _ -> + case model.connection { + Connected(role: GameMaster, ..) -> "Game Master" + Connecting(role: GameMaster) -> "Game Master" + _ -> "New Adventurer" + } + } + let identity_detail = case model.has_character, model.active_page { + _, SavedPlayersPage -> + int.to_string(list.length(model.saved_player_characters)) + <> " remote characters" + True, _ -> + case model.species_name, model.class_name { + "", "" -> "Level " <> int.to_string(model.level) <> " Adventurer" + species, class_name -> + species + <> case species != "" && class_name != "" { + True -> " " + False -> "" + } + <> class_name + <> " · Level " + <> int.to_string(model.level) + } + False, _ -> "No active character" + } + let more_selected = case model.active_page { + CharacterInventoryPage + | CharacterSpellsPage + | CharacterCompanionsPage + | CharacterNotesPage + | ResourceManagementPage + | SavedPlayersPage -> True + CharacterOverviewPage | CharacterBuilderPage | LiveTablePage -> False + } + let character_navigation = [ + application_navigation_item( + "Overview", + "01", + model.active_page == CharacterOverviewPage, + CharacterOverviewPage, + ), + application_navigation_item( + "Builder", + "02", + model.active_page == CharacterBuilderPage, + CharacterBuilderPage, + ), + application_navigation_item( + "Inventory", + "03", + model.active_page == CharacterInventoryPage, + CharacterInventoryPage, + ), + application_navigation_item( + "Spells", + "04", + model.active_page == CharacterSpellsPage, + CharacterSpellsPage, + ), + application_navigation_item( + "Companions", + "05", + model.active_page == CharacterCompanionsPage, + CharacterCompanionsPage, + ), + application_navigation_item( + "Notes", + "06", + model.active_page == CharacterNotesPage, + CharacterNotesPage, + ), + ] + let session_navigation = [ + application_navigation_item( + "Live table", + "●", + model.active_page == LiveTablePage, + LiveTablePage, + ), + application_navigation_item( + "Resource management", + "◫", + model.active_page == ResourceManagementPage, + ResourceManagementPage, + ), + application_navigation_item( + "Saved players", + "◆", + model.active_page == SavedPlayersPage, + SavedPlayersPage, + ), + ] + let mobile_navigation = [ + application_navigation_item( + "Overview", + "⌘", + model.active_page == CharacterOverviewPage, + CharacterOverviewPage, + ), + application_navigation_item( + "Builder", + "⌁", + model.active_page == CharacterBuilderPage, + CharacterBuilderPage, + ), + application_navigation_item( + "Session", + "◉", + model.active_page == LiveTablePage, + LiveTablePage, + ), + application_navigation_item( + "More", + "☰", + more_selected, + CharacterInventoryPage, + ), + ] + let mobile_status = case model.connection { + Connected(peer_count:, ..) -> int.to_string(peer_count) <> " CONNECTED" + Connecting(..) -> "CONNECTING" + ConnectionFailed(..) -> "OFFLINE" + Disconnected -> + case model.active_page { + SavedPlayersPage -> "OFFLINE READY" + _ -> "SAVED" + } + } + let footer = case model.active_page { + SavedPlayersPage -> "Library cached · Offline ready" + _ -> "Saved locally · Rules synced" + } + + ui.application( + ui.sidebar( + identity_name, + identity_detail, + case model.active_page { + SavedPlayersPage -> "AVAILABLE WITHOUT NETWORK" + _ -> "SWITCH CHARACTER" + }, + UserToggledCharacterLibrary, + character_navigation, + session_navigation, + footer, + ), + html.div([], [ + ui.topbar( + page_title(model.active_page), + page_search_hint(model.active_page), + UserToggledRulesDataSettings, + ), + ui.mobile_topbar(mobile_status, UserToggledCharacterLibrary), ]), - view_inspected_character(model), - ]) + html.div([attribute.class("sheet-page-stack")], [ + view_mobile_more_navigation(model, more_selected), + view_rule_restriction_notice(model), + workspace, + ]), + ui.mobile_navigation(mobile_navigation), + ) } -fn view_book_restrictions(model: Model) -> Element(Message) { - let sources = available_rule_sources(model) - let panel = "books" - let collapsed = live_table_panel_collapsed(model, panel) - html.section( - [ - attribute.classes([ - #("panel", True), - #("book-restrictions-panel", True), - #("collapsible-panel", True), - #("collapsed", collapsed), - ]), - ], - [ - collapsible_section_heading( - model, - panel, - "01", - "Allowed books", - "Disable sources that players cannot use for new selections", - ), - html.div( - [attribute.class("book-restriction-list")], - list.map(sources, fn(source) { - let blocked = list.contains(model.blocked_sources, source) - html.label( +fn application_navigation_item( + label: String, + marker: String, + selected: Bool, + page: PageTab, +) -> ui.NavigationItem(Message) { + ui.NavigationItem(label:, marker:, selected:, message: UserSelectedPage(page)) +} + +fn view_mobile_more_navigation( + model: Model, + visible: Bool, +) -> Element(Message) { + case visible { + False -> html.text("") + True -> { + let pages = [ + #("Inventory", CharacterInventoryPage), + #("Spells", CharacterSpellsPage), + #("Companions", CharacterCompanionsPage), + #("Notes", CharacterNotesPage), + #("Resources", ResourceManagementPage), + #("Saved", SavedPlayersPage), + ] + html.nav( + [ + attribute.class("sheet-mobile-more-navigation"), + attribute.aria_label("More character pages"), + ], + list.map(pages, fn(item) { + let #(label, page) = item + html.button( [ + attribute.type_("button"), attribute.classes([ - #("book-restriction", True), - #("blocked", blocked), - ]), - ], - [ - html.input([ - attribute.type_("checkbox"), - attribute.checked(!blocked), - event.on_check(fn(_) { UserToggledBlockedSource(source) }), - ]), - html.span([], [ - html.strong([], [html.text(rule_source_name(source))]), - html.small([], [ - html.text(case blocked { - True -> source <> " · Blocked for new selections" - False -> source <> " · Allowed" - }), - ]), + #("selected", model.active_page == page), ]), + attribute.aria_pressed(case model.active_page == page { + True -> "true" + False -> "false" + }), + event.on_click(UserSelectedPage(page)), ], + [html.text(label)], ) }), - ), - ], - ) + ) + } + } +} + +fn page_title(page: PageTab) -> String { + case page { + CharacterOverviewPage -> "Overview" + CharacterBuilderPage -> "Builder" + CharacterInventoryPage -> "Inventory" + CharacterSpellsPage -> "Spells" + CharacterCompanionsPage -> "Companions" + CharacterNotesPage -> "Notes" + ResourceManagementPage -> "Resource management" + LiveTablePage -> "Live table" + SavedPlayersPage -> "Saved players" + } +} + +fn page_search_hint(page: PageTab) -> String { + case page { + CharacterOverviewPage -> "Search character…" + CharacterBuilderPage -> "Search rules…" + CharacterInventoryPage -> "Search inventory…" + CharacterSpellsPage -> "Search spells or rules…" + CharacterCompanionsPage -> "Search companions…" + CharacterNotesPage -> "Search notes…" + ResourceManagementPage -> "Search resources…" + LiveTablePage -> "Search session…" + SavedPlayersPage -> "Search saved players…" + } +} + +fn view_master_session_page(model: Model) -> Element(Message) { + view_session_page(model, InitiativeEditable) } -fn view_initiative_tracker( +fn view_live_session_page(model: Model) -> Element(Message) { + view_session_page(model, InitiativeReadOnly) +} + +fn view_session_page( model: Model, access: InitiativeAccess, ) -> Element(Message) { - let editable = access == InitiativeEditable - let panel = "initiative" - let collapsed = live_table_panel_collapsed(model, panel) - html.section( - [ - attribute.classes([ - #("panel", True), - #("initiative-panel", True), - #("collapsible-panel", True), - #("collapsed", collapsed), + html.main([attribute.class("sheet-page live-table-page")], [ + case model.connection { + Disconnected -> view_session_connection_portal(model, "") + ConnectionFailed(reason:) -> view_session_connection_portal(model, reason) + Connecting(role:) -> view_session_connecting(role) + Connected(role:, ticket:, peer_count:) -> + view_connected_session(model, access, role, ticket, peer_count) + }, + ]) +} + +fn view_session_connection_portal( + model: Model, + error: String, +) -> Element(Message) { + html.div([attribute.class("session-connection-portal")], [ + html.section([attribute.class("session-connection-intro")], [ + html.span([attribute.class("session-live-label")], [ + html.text("LIVE TABLE · END-TO-END ENCRYPTED"), ]), - ], - [ - collapsible_section_heading( - model, - panel, - "02", - "Initiative tracker", - "Live turn order, hit points, and combatants", - ), - html.div([attribute.class("initiative-toolbar")], [ + html.h1([], [html.text("Bring the table together.")]), + html.p([], [ + html.text( + "Host an encounter or join one through Iroh. Character sheets synchronize directly between peers.", + ), + ]), + html.div([attribute.class("session-connection-proof")], [ + html.span([], [html.text("01")]), html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("ROUND")]), - html.strong([], [ - html.text(case model.encounter_round { - 0 -> "Not started" - round -> int.to_string(round) - }), - ]), + html.strong([], [html.text("No account required")]), + html.small([], [html.text("Your character stays on your device")]), + ]), + html.span([], [html.text("02")]), + html.div([], [ + html.strong([], [html.text("Encrypted by default")]), + html.small([], [html.text("Iroh connects the table peer to peer")]), ]), - case editable { - True -> - html.div([attribute.class("initiative-combat-controls")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("primary-button initiative-next-button"), - attribute.disabled(list.is_empty(model.encounter_entries)), - event.on_click(UserAdvancedInitiative), - ], - [ - html.text(case model.encounter_active_id { - "" -> "Start combat" - _ -> "Next turn" - }), - ], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.disabled(model.encounter_round == 0), - event.on_click(UserStoppedInitiative), - ], - [html.text("Stop combat")], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("initiative-reset-button"), - attribute.disabled(model.encounter_round == 0), - event.on_click(UserResetEncounter), - ], - [html.text("Reset encounter")], - ), - ]) - False -> view_player_initiative_button(model) - }, ]), - case access { - InitiativeEditable -> view_add_monster(model) - InitiativeReadOnly -> html.text("") - }, - case sorted_initiative(model.encounter_entries) { - [] -> - html.div([attribute.class("initiative-empty")], [ - html.strong([], [html.text("No combatants yet")]), - html.p([], [ - html.text(case access { - InitiativeEditable -> - "Connected players appear automatically. Add monsters below." - InitiativeReadOnly -> - "The Game Master has not started an encounter." - }), - ]), + ]), + html.section([attribute.class("session-connection-card")], [ + html.div([attribute.class("session-connection-heading")], [ + html.span([], [html.text("CONNECT")]), + html.h2([], [html.text("Enter the live table")]), + html.p([], [ + html.text("Choose the name everyone at the table will see."), + ]), + ]), + html.label([attribute.class("session-field")], [ + html.span([], [html.text("DISPLAY NAME")]), + html.input([ + attribute.type_("text"), + attribute.placeholder("How the table sees you"), + attribute.value(model.peer_name), + event.on_input(UserChangedPeerName), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("session-host-button"), + event.on_click(UserClickedHost), + ], + [html.text("Host as Game Master →")], + ), + html.div([attribute.class("session-connection-divider")], [ + html.span([], [html.text("OR JOIN A TABLE")]), + ]), + html.label([attribute.class("session-field")], [ + html.span([], [html.text("IROH INVITE")]), + html.input([ + attribute.type_("text"), + attribute.placeholder("Paste an invite code or link"), + attribute.value(model.join_code), + attribute.aria_label("Iroh table invite code"), + event.on_input(UserChangedJoinCode), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("session-join-button"), + attribute.disabled(string.trim(model.join_code) == ""), + event.on_click(UserClickedJoin), + ], + [html.text("Join as Player")], + ), + case error { + "" -> html.text("") + reason -> + html.p([attribute.class("session-connection-error")], [ + html.text(reason), ]) - entries -> - html.div( - [ - attribute.class("initiative-list"), - attribute.aria_live("polite"), - ], - list.map(entries, fn(entry) { - view_initiative_entry(model, entry, access) - }), - ) }, - ], - ) -} - -fn filtered_monsters(model: Model) -> List(Monster) { - let search = model.monster_search |> string.trim |> string.lowercase - list.filter(model.monster_catalog, fn(monster) { - let matches_search = - search == "" || string.contains(string.lowercase(monster.name), search) - let matches_type = - model.monster_type_filter == "" - || string.contains(monster.creature_type, model.monster_type_filter) - let matches_challenge = - model.monster_challenge_filter == "" - || monster.challenge_rating == model.monster_challenge_filter - matches_search && matches_type && matches_challenge - }) -} - -fn monster_type_options(monsters: List(Monster)) -> List(String) { - monsters - |> list.fold([], fn(options, monster) { - case list.contains(options, monster.creature_type) { - True -> options - False -> [monster.creature_type, ..options] - } - }) - |> list.sort(string.compare) + ]), + ]) } -fn monster_challenge_options(monsters: List(Monster)) -> List(String) { - [ - "0", - "1/8", - "1/4", - "1/2", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - "11", - "12", - "13", - "14", - "15", - "16", - "17", - "18", - "19", - "20", - "21", - "22", - "23", - "24", - "25", - "26", - "27", - "28", - "29", - "30", - ] - |> list.filter(fn(challenge_rating) { - list.any(monsters, fn(monster) { - monster.challenge_rating == challenge_rating - }) - }) +fn view_session_connecting(role: SessionRole) -> Element(Message) { + html.section([attribute.class("session-connecting-state")], [ + html.span([attribute.class("connection-spinner")], []), + html.span([attribute.class("session-live-label")], [ + html.text("OPENING ENCRYPTED SESSION"), + ]), + html.h1([], [ + html.text(case role { + GameMaster -> "Preparing your live table…" + Player -> "Joining the live table…" + }), + ]), + html.p([], [ + html.text(case role { + GameMaster -> "Creating an Iroh gossip session and invite." + Player -> "Connecting through the Iroh relay." + }), + ]), + ]) } -fn view_add_monster(model: Model) -> Element(Message) { - let monsters = filtered_monsters(model) - html.div([attribute.class("monster-form")], [ - html.div([attribute.class("monster-catalog-field")], [ - html.div([attribute.class("monster-filter-controls")], [ - field( - "Search monsters", - html.input([ - attribute.class("text-input"), - attribute.type_("search"), - attribute.placeholder("Goblin, dragon, mage…"), - attribute.value(model.monster_search), - event.on_input(UserChangedMonsterSearch), - ]), - ), - field( - "Type", - html.select( - [ - attribute.class("select-input"), - event.on_change(UserChangedMonsterTypeFilter), - ], - [ - html.option( - [ - attribute.value(""), - attribute.selected(model.monster_type_filter == ""), - ], - "All types", - ), - ..list.map(monster_type_options(model.monster_catalog), fn(kind) { - html.option( - [ - attribute.value(kind), - attribute.selected(model.monster_type_filter == kind), - ], - kind, - ) - }) - ], - ), - ), - field( - "Challenge rating", - html.select( - [ - attribute.class("select-input"), - event.on_change(UserChangedMonsterChallengeFilter), - ], - [ - html.option( - [ - attribute.value(""), - attribute.selected(model.monster_challenge_filter == ""), - ], - "All CRs", - ), - ..list.map( - monster_challenge_options(model.monster_catalog), - fn(challenge_rating) { - html.option( - [ - attribute.value(challenge_rating), - attribute.selected( - model.monster_challenge_filter == challenge_rating, - ), - ], - "CR " <> challenge_rating, - ) - }, - ) - ], +fn view_connected_session( + model: Model, + access: InitiativeAccess, + role: SessionRole, + ticket: String, + peer_count: Int, +) -> Element(Message) { + let entries = sorted_initiative(model.encounter_entries) + html.div([attribute.class("live-table-workspace")], [ + html.header([attribute.class("live-table-header")], [ + html.div([], [ + html.span([attribute.class("session-live-label")], [ + html.text( + "● LIVE · " + <> int.to_string(peer_count) + <> case peer_count { + 1 -> " CONNECTED" + _ -> " CONNECTED" + }, ), - ), + ]), + html.h1([], [html.text("Live table")]), + html.p([], [ + html.text("Initiative synchronized across the table"), + ]), ]), - field( - "2024 monster data", - html.select( - [ - attribute.class("select-input monster-catalog-select"), - attribute.disabled(model.monster_catalog_loading), - event.on_change(UserSelectedMonster), - ], - [ - html.option( + html.div([attribute.class("live-table-header-actions")], [ + case role { + GameMaster -> + html.button( [ - attribute.value(""), - attribute.selected(model.monster_catalog_selection == ""), + attribute.type_("button"), + attribute.class("session-invite-button"), + event.on_click(UserClickedCopyPlayerInvite(ticket)), ], - case model.monster_catalog_loading { - True -> "Loading monsters…" - False -> - case monsters { - [] -> "No monsters match these filters" - _ -> "Choose an XMM monster…" - } - }, - ), - ..list.map(monsters, fn(monster) { - html.option( - [ - attribute.value(monster.name), - attribute.selected( - model.monster_catalog_selection == monster.name, - ), - ], - monster.name <> " · CR " <> monster.challenge_rating, - ) - }) + [html.text("⌘ Copy player invite")], + ) + Player -> html.text("") + }, + html.button( + [ + attribute.type_("button"), + attribute.class("session-leave-button"), + event.on_click(UserClickedLeave), ], + [html.text("Leave table")], ), - ), - case model.monster_catalog_error { - "" -> - html.small([], [ - html.text( - int.to_string(list.length(monsters)) - <> " of " - <> int.to_string(list.length(model.monster_catalog)) - <> " monsters", - ), - ]) - error -> - html.small([attribute.class("monster-catalog-error")], [ - html.text(error), - ]) - }, - ]), - case model.monster_catalog_selection { - "" -> html.text("") - name -> - html.div([attribute.class("monster-preview")], [ - view_monster_stat_block(model, name, SelectedMonsterPreview), - ]) - }, - field( - "Monster", - html.input([ - attribute.class("text-input"), - attribute.placeholder("Goblin"), - attribute.value(model.monster_name_input), - event.on_input(UserChangedMonsterName), + case access { + InitiativeEditable -> + html.button( + [ + attribute.type_("button"), + attribute.class("session-next-turn-button"), + attribute.disabled(list.is_empty(entries)), + event.on_click(UserAdvancedInitiative), + ], + [ + html.text(case model.encounter_active_id { + "" -> "Start combat ▷" + _ -> "Next turn ▷" + }), + ], + ) + InitiativeReadOnly -> view_session_player_initiative_action(model) + }, ]), - ), - field( - "Max HP", - html.input([ - attribute.class("text-input"), - attribute.type_("number"), - attribute.min("1"), - attribute.max("99999"), - attribute.value(model.monster_hit_points_input), - event.on_input(UserChangedMonsterHitPoints), + ]), + html.div([attribute.class("live-table-columns")], [ + html.section([attribute.class("session-initiative-board")], [ + html.div([attribute.class("session-board-heading")], [ + html.div([], [ + html.h2([], [html.text("Initiative order")]), + html.p([], [ + html.text( + int.to_string(list.length(entries)) + <> case list.length(entries) { + 1 -> " combatant" + _ -> " combatants" + }, + ), + ]), + ]), + html.span([], [ + html.text(case model.encounter_round { + 0 -> "NOT STARTED" + round -> "ROUND " <> two_digits(round) + }), + ]), + ]), + case access { + InitiativeEditable -> view_session_combat_actions(model, entries) + InitiativeReadOnly -> html.text("") + }, + view_session_current_turn(model, access, entries), + view_session_turn_order(model, access, entries), + case access { + InitiativeEditable -> view_session_encounter_controls(model) + InitiativeReadOnly -> html.text("") + }, ]), - ), - field( - "Initiative bonus", - html.input([ - attribute.class("text-input"), - attribute.type_("number"), - attribute.min("-99"), - attribute.max("99"), - attribute.value(model.monster_initiative_bonus_input), - event.on_input(UserChangedMonsterInitiativeBonus), + html.aside([attribute.class("session-party-rail")], [ + view_session_party(model, ticket, role), + view_session_recent_activity(model), + case access { + InitiativeEditable -> view_session_rule_settings(model) + InitiativeReadOnly -> html.text("") + }, ]), - ), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button add-monster-button"), - attribute.disabled(string.trim(model.monster_name_input) == ""), - event.on_click(UserAddedMonster), - ], - [html.text("Add monster")], - ), + ]), ]) } -fn view_initiative_entry( +fn view_session_combat_actions( model: Model, - entry: InitiativeEntry, - access: InitiativeAccess, + entries: List(InitiativeEntry), ) -> Element(Message) { - let editable = access == InitiativeEditable - let monster = entry.kind == "monster" - let managed = entry.kind != "player" - html.article( + let empty = list.is_empty(entries) + let combat_active = model.encounter_active_id != "" + html.div( [ - attribute.classes([ - #("initiative-entry", True), - #("active", entry.id == model.encounter_active_id), - #("defeated", entry.current_hit_points <= 0), - ]), + attribute.class("session-combat-actions"), + attribute.aria_label("Combat controls"), ], [ - html.div([attribute.class("initiative-entry-heading")], [ - html.div([], [ - html.strong([], [html.text(entry.name)]), - html.span([], [ - html.text(case entry.kind { - "monster" -> "Monster" - "companion" -> "Companion" - _ -> "Player" - }), - ]), - ]), - case entry.id == model.encounter_active_id { - True -> - html.span([attribute.class("initiative-turn-badge")], [ - html.text("Current turn"), - ]) - False -> html.text("") - }, - ]), - case editable, monster { - False, True -> - html.div([attribute.class("initiative-values")], [ - initiative_number_field( - "Initiative", - entry.initiative, - access, - fn(value) { UserChangedInitiative(entry.id, value) }, - ), - view_monster_hit_point_bar(entry), - ]) - _, _ -> - html.div([attribute.class("initiative-values")], [ - initiative_number_field( - "Initiative", - entry.initiative, - access, - fn(value) { UserChangedInitiative(entry.id, value) }, - ), - initiative_number_field( - "Current HP", - entry.current_hit_points, - access, - fn(value) { UserChangedInitiativeHitPoints(entry.id, value) }, - ), - initiative_number_field( - "Max HP", - entry.maximum_hit_points, - case editable && managed { - True -> InitiativeEditable - False -> InitiativeReadOnly - }, - fn(value) { UserChangedMonsterMaximumHitPoints(entry.id, value) }, - ), - ]) - }, - view_initiative_details(entry, access), - case editable && monster { - True -> view_monster_stat_block(model, entry.name, TrackerStatBlock) - False -> html.text("") - }, - case editable && managed { - True -> - html.div([attribute.class("initiative-entry-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.aria_label("Roll " <> entry.name <> " initiative"), - event.on_click(UserRolledMonsterInitiative(entry.id)), - ], - [html.text("Roll secretly")], - ), - case monster { - True -> - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.aria_label(case entry.hidden { - True -> "Reveal " <> entry.name <> " to players" - False -> "Hide " <> entry.name <> " from players" - }), - event.on_click(UserToggledMonsterVisibility(entry.id)), - ], - [ - html.text(case entry.hidden { - True -> "Reveal to players" - False -> "Hide from players" - }), - ], - ) - False -> html.text("") - }, - case monster { - True -> - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.aria_label("Duplicate " <> entry.name), - event.on_click(UserDuplicatedMonster(entry.id)), - ], - [html.text("Duplicate")], - ) - False -> html.text("") - }, - html.button( - [ - attribute.type_("button"), - attribute.class("initiative-remove-button"), - attribute.aria_label("Remove " <> entry.name), - event.on_click(UserRemovedMonster(entry.id)), - ], - [html.text("Remove")], - ), - ]) - False -> html.text("") - }, + html.button( + [ + attribute.type_("button"), + attribute.disabled(!combat_active), + event.on_click(UserStoppedInitiative), + ], + [html.text("Stop combat")], + ), + html.button( + [ + attribute.type_("button"), + attribute.disabled(empty), + event.on_click(UserResetInitiative), + ], + [html.text("Reset initiative")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("danger-action"), + attribute.disabled(empty), + event.on_click(UserResetEncounter), + ], + [html.text("Reset combat")], + ), ], ) } -fn view_player_initiative_button(model: Model) -> Element(Message) { +fn view_session_player_initiative_action(model: Model) -> Element(Message) { let owner = peer_display_name(model, "Player") case list.find(model.encounter_entries, fn(entry) { entry.kind == "player" && entry.owner == owner }) { + Error(Nil) -> html.text("") Ok(entry) -> html.button( [ attribute.type_("button"), - attribute.class("primary-button player-initiative-button"), + attribute.class("session-player-roll-button"), attribute.disabled(entry.initiative_rolled), event.on_click(UserRolledPlayerInitiative), ], [ html.text(case entry.initiative_rolled { - True -> "Initiative rolled" + True -> "Initiative ready" False -> "Roll initiative" }), ], ) - Error(Nil) -> - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button player-initiative-button"), - attribute.disabled(True), - ], - [html.text("Waiting for tracker")], - ) - } -} - -fn view_monster_hit_point_bar(entry: InitiativeEntry) -> Element(Message) { - let percentage = case entry.maximum_hit_points <= 0 { - True -> 0 - False -> - entry.current_hit_points * 100 - |> int.floor_divide(entry.maximum_hit_points) - |> result.unwrap(0) - |> int.max(0) - |> int.min(100) } - html.div([attribute.class("monster-hit-point-summary")], [ - html.span([], [html.text("Hit points")]), - html.div( - [ - attribute.class("monster-hit-point-bar"), - attribute.role("progressbar"), - attribute.aria_label(entry.name <> " hit points"), - attribute.aria_valuemin("0"), - attribute.aria_valuemax("100"), - attribute.aria_valuenow(int.to_string(percentage)), - ], - [ - html.span( - [attribute.style("width", int.to_string(percentage) <> "%")], - [], - ), - ], - ), - html.strong([], [html.text(int.to_string(percentage) <> "%")]), - ]) } -fn view_initiative_condition_editor( - entry: InitiativeEntry, +fn view_session_current_turn( + model: Model, + access: InitiativeAccess, + entries: List(InitiativeEntry), ) -> Element(Message) { - let conditions = - entry.conditions - |> list.filter(fn(condition) { - initiative_condition_name(condition) != "Exhaustion" - }) - html.div([attribute.class("initiative-condition-editor")], [ - html.div( - [attribute.class("condition-list")], - list.map(conditions, fn(condition) { - html.span([attribute.class("condition-chip")], [ - html.text(condition), - html.button( - [ - attribute.type_("button"), - attribute.aria_label( - "Remove " <> condition <> " from " <> entry.name, - ), - event.on_click(UserRemovedInitiativeCondition( - entry.id, - initiative_condition_name(condition), - )), - ], - [html.text("×")], - ), - ]) - }), - ), - html.div([attribute.class("initiative-condition-controls")], [ - html.select( - [ - attribute.value(""), - attribute.aria_label("Add condition to " <> entry.name), - event.on_change(fn(condition) { - UserAddedInitiativeCondition(entry.id, condition) - }), - ], - [ - html.option([attribute.value("")], "Add condition…"), - ..list.map(standard_condition_names(), fn(condition) { - html.option( - [ - attribute.value(condition), - attribute.disabled( - list.any(conditions, fn(existing) { - initiative_condition_name(existing) == condition - }), - ), - ], - condition, - ) - }) - ], - ), - html.label([attribute.class("initiative-exhaustion-control")], [ - html.span([], [html.text("Exhaustion")]), - html.input([ - attribute.type_("number"), - attribute.min("0"), - attribute.max("6"), - attribute.value( - int.to_string(initiative_exhaustion_level(entry.conditions)), - ), - event.on_input(fn(value) { - UserChangedInitiativeExhaustion(entry.id, value) + case session_active_entry(entries, model.encounter_active_id) { + Error(Nil) -> + html.div([attribute.class("session-current-turn empty")], [ + html.span([attribute.class("session-live-label")], [ + html.text("ENCOUNTER READY"), + ]), + html.h3([], [html.text("Set the turn order.")]), + html.p([], [ + html.text(case entries { + [] -> "Add combatants to begin the encounter." + _ -> "Start combat when everyone has rolled initiative." }), ]), - ]), - ]), - ]) -} - -fn view_initiative_details( - entry: InitiativeEntry, - access: InitiativeAccess, -) -> Element(Message) { - case access { - InitiativeEditable -> - html.div([attribute.class("initiative-entry-details")], [ - field("Conditions", view_initiative_condition_editor(entry)), - field( - "Notes", - html.textarea( - [ - attribute.class("ticket-field"), - attribute.placeholder("Concentration, tactics, reminders…"), - attribute.value(entry.notes), - event.on_input(fn(value) { - UserChangedInitiativeNotes(entry.id, value) - }), - ], - entry.notes, - ), - ), ]) - InitiativeReadOnly -> - case entry.conditions, string.trim(entry.notes) { - [], "" -> html.text("") - conditions, notes -> - html.div([attribute.class("initiative-entry-details read-only")], [ - html.div( - [attribute.class("initiative-condition-list")], - list.map(conditions, fn(condition) { - html.span([], [html.text(condition)]) - }), - ), - case notes { - "" -> html.text("") - notes -> html.p([], [html.text(notes)]) - }, - ]) + Ok(entry) -> { + let next_name = case next_session_entry(entries, entry.id) { + Ok(next) -> next.name + Error(Nil) -> "No one" } - } -} - -fn view_monster_stat_block( - model: Model, - name: String, - presentation: MonsterStatPresentation, -) -> Element(Message) { - case list.find(model.monster_catalog, fn(monster) { monster.name == name }) { - Error(Nil) -> html.text("") - Ok(monster) -> - html.details( - [ - attribute.classes([ - #("monster-stat-block", True), - #("monster-stat-preview", presentation == SelectedMonsterPreview), + let health_percentage = session_health_percentage(entry) + let hit_point_amount = + parse_bounded(model.initiative_hit_point_amount, 1, 1, 99_999) + html.article([attribute.class("session-current-turn")], [ + html.div([attribute.class("session-current-identity")], [ + html.span([attribute.class("session-live-label")], [ + html.text("CURRENT TURN · " <> int.to_string(entry.initiative)), ]), - attribute.open(presentation == SelectedMonsterPreview), - ], - [ - html.summary([], [ - html.text(case presentation { - TrackerStatBlock -> - "View stat block · CR " - <> monster.challenge_rating - <> " · " - <> monster.source - SelectedMonsterPreview -> - "Monster preview · " - <> monster.name - <> " · CR " - <> monster.challenge_rating - }), - ]), - html.div([attribute.class("monster-stat-vitals")], [ - monster_stat("Armor Class", int.to_string(monster.armor_class)), - monster_stat( - "Hit Points", - int.to_string(monster.average_hit_points) - <> case monster.hit_point_formula { - "" -> "" - formula -> " (" <> formula <> ")" + html.h3([], [html.text(entry.name)]), + html.p([], [ + html.text( + session_entry_kind(entry) + <> " · " + <> case entry.conditions { + [] -> "Ready" + conditions -> string.join(conditions, " · ") }, ), - monster_stat("Speed", monster.speed), - monster_stat("Initiative", signed(monster.initiative_bonus)), ]), - html.p([attribute.class("monster-stat-identity")], [ + html.div([attribute.class("session-current-facts")], [ + html.span([], [html.text("NEXT " <> next_name)]), + html.span([], [ + html.text("INITIATIVE " <> int.to_string(entry.initiative)), + ]), + ]), + ]), + html.div([attribute.class("session-current-health")], [ + html.span([], [html.text("HIT POINTS")]), + html.strong([], [ html.text( - monster.size - <> " " - <> monster.creature_type - <> " · Challenge " - <> monster.challenge_rating, + int.to_string(entry.current_hit_points) + <> " / " + <> int.to_string(entry.maximum_hit_points), ), ]), - html.div([attribute.class("monster-ability-grid")], [ - monster_ability("STR", monster.strength), - monster_ability("DEX", monster.dexterity), - monster_ability("CON", monster.constitution), - monster_ability("INT", monster.intelligence), - monster_ability("WIS", monster.wisdom), - monster_ability("CHA", monster.charisma), + html.small([], [ + html.text(int.to_string(health_percentage) <> "%"), + ]), + html.div([attribute.class("session-health-track")], [ + html.span( + [ + attribute.style( + "width", + int.to_string(health_percentage) <> "%", + ), + ], + [], + ), ]), - monster_feature_section("Traits", monster.traits), - monster_feature_section("Actions", monster.actions), - monster_feature_section("Bonus actions", monster.bonus_actions), - monster_feature_section("Reactions", monster.reactions), - monster_feature_section( - "Legendary actions", - monster.legendary_actions, + case access { + InitiativeEditable -> + html.div([attribute.class("session-health-actions")], [ + view_session_health_action( + entry, + model.initiative_hit_point_amount, + "Damage", + "− Damage", + entry.current_hit_points <= 0, + int.max(0, entry.current_hit_points - hit_point_amount), + ), + view_session_health_action( + entry, + model.initiative_hit_point_amount, + "Heal", + "+ Heal", + entry.current_hit_points >= entry.maximum_hit_points, + int.min( + entry.maximum_hit_points, + entry.current_hit_points + hit_point_amount, + ), + ), + html.button( + [ + attribute.type_("button"), + attribute.class("session-mobile-next-turn"), + event.on_click(UserAdvancedInitiative), + ], + [html.text("Next turn →")], + ), + ]) + InitiativeReadOnly -> html.text("") + }, + ]), + ]) + } + } +} + +fn view_session_health_action( + entry: InitiativeEntry, + amount_value: String, + action_name: String, + label: String, + disabled: Bool, + next_hit_points: Int, +) -> Element(Message) { + let amount = parse_bounded(amount_value, 1, 1, 99_999) + html.div( + [ + attribute.classes([ + #("session-health-action-control", True), + #("damage", action_name == "Damage"), + #("heal", action_name == "Heal"), + ]), + ], + [ + html.label([attribute.class("session-health-amount-popover")], [ + html.span([], [html.text(string.uppercase(action_name) <> " AMOUNT")]), + html.input([ + attribute.type_("number"), + attribute.min("1"), + attribute.max("99999"), + attribute.value(amount_value), + attribute.aria_label(action_name <> " amount for " <> entry.name), + event.on_input(UserChangedInitiativeHitPointAmount), + ]), + html.small([], [html.text("HP")]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("session-health-action-button"), + attribute.disabled(disabled), + attribute.aria_label( + action_name + <> " " + <> int.to_string(amount) + <> " hit points " + <> case action_name { + "Damage" -> "to " + _ -> "for " + } + <> entry.name, ), + event.on_click(UserChangedInitiativeHitPoints( + entry.id, + int.to_string(next_hit_points), + )), ], - ) + [html.text(label)], + ), + ], + ) +} + +fn session_active_entry( + entries: List(InitiativeEntry), + active_id: String, +) -> Result(InitiativeEntry, Nil) { + case active_id { + "" -> Error(Nil) + id -> list.find(entries, fn(entry) { entry.id == id }) } } -fn monster_stat(label: String, value: String) -> Element(Message) { - html.div([], [ - html.span([], [html.text(label)]), - html.strong([], [html.text(value)]), - ]) +fn next_session_entry( + entries: List(InitiativeEntry), + active_id: String, +) -> Result(InitiativeEntry, Nil) { + case entries { + [] -> Error(Nil) + [first, ..] -> next_session_entry_loop(entries, active_id, first, False) + } } -fn monster_ability(label: String, score: Int) -> Element(Message) { - html.div([], [ - html.span([], [html.text(label)]), - html.strong([], [html.text(int.to_string(score))]), - html.small([], [html.text(signed(modifier(score)))]), - ]) +fn next_session_entry_loop( + entries: List(InitiativeEntry), + active_id: String, + first: InitiativeEntry, + found_active: Bool, +) -> Result(InitiativeEntry, Nil) { + case entries, found_active { + [], True -> Ok(first) + [], False -> Error(Nil) + [entry, ..], True -> Ok(entry) + [entry, ..rest], False -> + next_session_entry_loop(rest, active_id, first, entry.id == active_id) + } } -fn monster_feature_section( - title: String, - features: List(MonsterFeature), -) -> Element(Message) { - case features { - [] -> html.text("") - features -> - html.section([attribute.class("monster-feature-section")], [ - html.h4([], [html.text(title)]), - ..list.map(features, fn(feature) { - html.article([], [ - case feature.name { - "" -> html.text("") - name -> html.strong([], [html.text(name)]) - }, - html.p([], [html.text(feature.description)]), - ]) - }) - ]) +fn session_entry_kind(entry: InitiativeEntry) -> String { + case entry.kind { + "player" -> "Player" + "companion" -> "Companion" + "monster" -> "Monster" + kind -> string.capitalise(kind) } } -fn initiative_number_field( - label: String, - value: Int, - access: InitiativeAccess, - changed: fn(String) -> Message, -) -> Element(Message) { - html.label([attribute.class("initiative-number-field")], [ - html.span([], [html.text(label)]), - case access { - InitiativeEditable -> - html.input([ - attribute.class("text-input"), - attribute.type_("number"), - attribute.value(int.to_string(value)), - event.on_input(changed), - ]) - InitiativeReadOnly -> html.strong([], [html.text(int.to_string(value))]) - }, - ]) -} - -fn view_rule_restriction_notice(model: Model) -> Element(Message) { - case model.connection, model.blocked_sources { - Connected(role: Player, ..), [_, ..] -> - html.aside( - [ - attribute.class("rule-restriction-notice"), - attribute.role("status"), - ], - [ - html.strong([], [html.text("Some book selections are blocked")]), - html.p([], [ - html.text(case has_blocked_selection(model) { - True -> - "Your character already uses restricted content. Those selections remain, but other restricted options are disabled." - False -> - "The Game Master restricted " - <> blocked_source_names(model.blocked_sources) - <> ". Those options are disabled." - }), - ]), - ], - ) - Connected(role: GameMaster, ..), _ -> html.text("") - Connecting(role: _), _ -> html.text("") - Disconnected, _ -> html.text("") - ConnectionFailed(reason: _), _ -> html.text("") - _, [] -> html.text("") +fn session_health_percentage(entry: InitiativeEntry) -> Int { + case entry.maximum_hit_points <= 0 { + True -> 0 + False -> + entry.current_hit_points * 100 / entry.maximum_hit_points + |> int.max(0) + |> int.min(100) } } -fn available_rule_sources(model: Model) -> List(String) { - let progression_sources = case model.class_progression { - Ok(progression) -> [ - progression.source, - ..list.map(progression.features, fn(feature) { feature.source }) - ] - Error(_) -> [] +fn view_session_turn_order( + model: Model, + access: InitiativeAccess, + entries: List(InitiativeEntry), +) -> Element(Message) { + case entries { + [] -> + html.div([attribute.class("session-order-empty")], [ + html.span([], [html.text("+")]), + html.strong([], [html.text("No combatants yet")]), + html.p([], [ + html.text(case access { + InitiativeEditable -> + "Open encounter controls below to add a creature." + InitiativeReadOnly -> + "The Game Master has not added anyone to initiative." + }), + ]), + ]) + _ -> + html.div( + [attribute.class("session-turn-list")], + list.map(entries, fn(entry) { + view_session_turn_row(model, access, entry) + }), + ) } - let remote_sources = - model.remote_sheets - |> list.flat_map(fn(snapshot) { - list.append(snapshot.class_features, snapshot.origin_features) - |> list.map(fn(feature) { feature.source }) - }) - ["XPHB", "EFA", "Pugilist2024", "CthulhuTorchlight"] - |> list.append(list.map(model.species, fn(choice) { choice.source })) - |> list.append(list.map(model.backgrounds, fn(choice) { choice.source })) - |> list.append(list.map(model.feat_catalog, fn(feat) { feat.source })) - |> list.append(list.map(model.equipment_catalog, fn(item) { item.source })) - |> list.append(list.map(model.spell_catalog, fn(spell) { spell.source })) - |> list.append(progression_sources) - |> list.append(remote_sources) - |> list.filter(fn(source) { source != "" }) - |> list.unique } -fn rule_source_name(source: String) -> String { - case source { - "XPHB" -> "Player's Handbook (2024)" - "XMM" -> "Monster Manual (2024)" - "EFA" -> "Eberron: Forge of the Artificer" - "Pugilist2024" -> "The Pugilist Class (2024)" - "CthulhuTorchlight" -> "Cthulhu by Torchlight" - "TCE" -> "Tasha's Cauldron of Everything" - "SCC" -> "Strixhaven: A Curriculum of Chaos" - "ABH" -> "Astarion's Book of Hungers" - "FRHoF" -> "Forgotten Realms: Heroes of Faerûn" - "RHW" -> "Ravenloft: The Horrors Within" - "LFL" -> "Lorwyn: First Light" - "LLFLL" -> "Legends of the Forgotten Lands" - "AitFR-AVT" -> "Adventures in the Forgotten Realms: A Verdant Tomb" - source -> source - } +fn view_session_turn_row( + model: Model, + access: InitiativeAccess, + entry: InitiativeEntry, +) -> Element(Message) { + let active = entry.id == model.encounter_active_id + html.details( + [ + attribute.classes([ + #("session-turn-row", True), + #("active", active), + ]), + ], + [ + html.summary([], [ + html.strong([attribute.class("session-turn-initiative")], [ + html.text(int.to_string(entry.initiative)), + ]), + html.div([attribute.class("session-turn-identity")], [ + html.strong([], [html.text(entry.name)]), + html.small([], [ + html.text( + session_entry_kind(entry) + <> case entry.owner { + "" -> "" + owner -> " · " <> owner + }, + ), + ]), + ]), + html.span([attribute.class("session-turn-health")], [ + html.text( + int.to_string(entry.current_hit_points) + <> " / " + <> int.to_string(entry.maximum_hit_points) + <> " HP", + ), + ]), + html.span( + [ + attribute.classes([ + #("session-turn-status", True), + #("danger", entry.current_hit_points <= 0), + ]), + ], + [ + html.text(case entry.conditions { + [] -> + case active { + True -> "Current" + False -> "Ready" + } + [condition, ..] -> condition + }), + ], + ), + html.span([attribute.class("session-turn-disclosure")], [ + html.text("•••"), + ]), + ]), + case access { + InitiativeReadOnly -> + html.div([attribute.class("session-turn-readonly")], [ + html.span([], [ + html.text(case entry.conditions { + [] -> "No active encounter conditions" + conditions -> string.join(conditions, " · ") + }), + ]), + html.span([], [ + html.text(case entry.notes { + "" -> "No encounter notes" + notes -> notes + }), + ]), + ]) + InitiativeEditable -> view_session_turn_editor(entry) + }, + ], + ) } -fn blocked_source_names(sources: List(String)) -> String { - sources |> list.map(rule_source_name) |> string.join(", ") +fn view_session_turn_editor(entry: InitiativeEntry) -> Element(Message) { + html.div([attribute.class("session-turn-editor")], [ + html.div([attribute.class("session-turn-fields")], [ + session_number_input("INITIATIVE", entry.initiative, fn(value) { + UserChangedInitiative(entry.id, value) + }), + session_number_input("CURRENT HP", entry.current_hit_points, fn(value) { + UserChangedInitiativeHitPoints(entry.id, value) + }), + session_number_input("MAX HP", entry.maximum_hit_points, fn(value) { + UserChangedMonsterMaximumHitPoints(entry.id, value) + }), + ]), + view_session_conditions(entry), + html.label([attribute.class("session-turn-notes")], [ + html.span([], [html.text("ENCOUNTER NOTES")]), + html.input([ + attribute.type_("text"), + attribute.placeholder("Tactics, effects, reminders…"), + attribute.value(entry.notes), + event.on_input(fn(value) { UserChangedInitiativeNotes(entry.id, value) }), + ]), + ]), + case entry.kind { + "player" -> html.text("") + _ -> + html.div([attribute.class("session-turn-actions")], [ + html.button( + [ + attribute.type_("button"), + event.on_click(UserRolledMonsterInitiative(entry.id)), + ], + [html.text("Roll secretly")], + ), + case entry.kind { + "monster" -> + html.button( + [ + attribute.type_("button"), + event.on_click(UserToggledMonsterVisibility(entry.id)), + ], + [ + html.text(case entry.hidden { + True -> "Reveal" + False -> "Hide" + }), + ], + ) + _ -> html.text("") + }, + case entry.kind { + "monster" -> + html.button( + [ + attribute.type_("button"), + event.on_click(UserDuplicatedMonster(entry.id)), + ], + [html.text("Duplicate")], + ) + _ -> html.text("") + }, + html.button( + [ + attribute.type_("button"), + attribute.class("danger"), + event.on_click(UserRemovedMonster(entry.id)), + ], + [html.text("Remove")], + ), + ]) + }, + ]) } -fn source_is_blocked(model: Model, source: String) -> Bool { - source != "" - && { - list.contains(model.blocked_sources, source) - || list.contains(model.local_blocked_sources, source) +fn view_session_conditions(entry: InitiativeEntry) -> Element(Message) { + case entry.kind { + "player" -> view_session_player_conditions(entry) + _ -> view_session_managed_conditions(entry) } } -fn has_blocked_selection(model: Model) -> Bool { - source_is_blocked(model, selected_class_source(model.class_name)) - || source_is_blocked( - model, - selected_subclass_source(model, model.subclass_name), - ) - || source_is_blocked( - model, - selected_choice_source(model.species, model.species_name), - ) - || source_is_blocked( - model, - selected_choice_source(model.backgrounds, model.background_name), +fn view_session_player_conditions(entry: InitiativeEntry) -> Element(Message) { + html.div( + [ + attribute.class( + "session-condition-editor session-player-condition-summary", + ), + ], + [ + html.span([], [html.text("CONDITIONS · PLAYER OWNED")]), + case entry.conditions { + [] -> + html.p([], [html.text("No active conditions reported by the player.")]) + conditions -> + html.div( + [attribute.class("session-condition-list readonly")], + list.map(conditions, fn(condition) { + html.span([], [html.text(condition)]) + }), + ) + }, + html.small([], [ + html.text( + "The player manages these from Resource management; changes synchronize here.", + ), + ]), + ], ) - || list.any(model.feats, fn(selection) { - source_is_blocked( - model, - selected_feat_source(model.feat_catalog, selection.name), - ) - }) - || list.any(model.spells, fn(selection) { - source_is_blocked( - model, - selected_spell_source(model.spell_catalog, selection.name), - ) - }) - || list.any(model.equipment, fn(equipment) { - source_is_blocked( - model, - selected_equipment_source(model.equipment_catalog, equipment.name), - ) - }) - || list.any(model.other_proficiencies, fn(proficiency) { - source_is_blocked( - model, - selected_equipment_source(model.equipment_catalog, proficiency), - ) - }) } -fn selected_class_source(class_name: String) -> String { - class_choices() - |> list.find(fn(choice) { choice.name == class_name }) - |> result.map(fn(choice) { choice.source }) - |> result.unwrap("") -} - -fn selected_subclass_source(model: Model, subclass_name: String) -> String { - case model.class_progression { - Error(_) -> "" - Ok(progression) -> - progression.features - |> list.find(fn(feature) { feature.subclass == subclass_name }) - |> result.map(fn(feature) { feature.source }) - |> result.unwrap("") - } -} - -fn selected_choice_source(choices: List(Choice), name: String) -> String { - choices - |> list.find(fn(choice) { choice.name == name }) - |> result.map(fn(choice) { choice.source }) - |> result.unwrap("") +fn view_session_managed_conditions(entry: InitiativeEntry) -> Element(Message) { + let conditions = + list.filter(entry.conditions, fn(condition) { + initiative_condition_name(condition) != "Exhaustion" + }) + html.div([attribute.class("session-condition-editor")], [ + html.span([], [html.text("CONDITIONS")]), + html.div( + [attribute.class("session-condition-list")], + list.map(conditions, fn(condition) { + let name = initiative_condition_name(condition) + html.span([], [ + html.text(condition), + html.button( + [ + attribute.type_("button"), + attribute.aria_label( + "Remove " <> condition <> " from " <> entry.name, + ), + event.on_click(UserRemovedInitiativeCondition(entry.id, name)), + ], + [html.text("×")], + ), + ]) + }), + ), + html.div([attribute.class("session-condition-controls")], [ + html.select( + [ + attribute.value(""), + attribute.aria_label("Add condition to " <> entry.name), + event.on_change(fn(condition) { + UserAddedInitiativeCondition(entry.id, condition) + }), + ], + [ + html.option([attribute.value("")], "Add condition…"), + ..list.map(standard_condition_names(), fn(condition) { + html.option( + [ + attribute.value(condition), + attribute.disabled( + list.any(conditions, fn(existing) { + initiative_condition_name(existing) == condition + }), + ), + ], + condition, + ) + }) + ], + ), + html.label([], [ + html.span([], [html.text("EXHAUSTION")]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.max("6"), + attribute.value( + int.to_string(initiative_exhaustion_level(entry.conditions)), + ), + event.on_input(fn(value) { + UserChangedInitiativeExhaustion(entry.id, value) + }), + ]), + ]), + ]), + ]) } -fn selected_feat_source(feats: List(Feat), name: String) -> String { - feats - |> list.find(fn(feat) { feat.name == name }) - |> result.map(fn(feat) { feat.source }) - |> result.unwrap("") +fn session_number_input( + label: String, + value: Int, + on_input: fn(String) -> Message, +) -> Element(Message) { + html.label([], [ + html.span([], [html.text(label)]), + html.input([ + attribute.type_("number"), + attribute.value(int.to_string(value)), + event.on_input(on_input), + ]), + ]) } -fn selected_spell_source(spells: List(Spell), name: String) -> String { - spells - |> list.find(fn(spell) { spell.name == name }) - |> result.map(fn(spell) { spell.source }) - |> result.unwrap("") +fn session_filtered_monsters(model: Model) -> List(Monster) { + let search = model.monster_search |> string.trim |> string.lowercase + list.filter(model.monster_catalog, fn(monster) { + search == "" || string.contains(string.lowercase(monster.name), search) + }) } -fn selected_equipment_source( - equipment: List(InventoryItem), - name: String, -) -> String { - equipment - |> list.find(fn(item) { item.name == name }) - |> result.map(fn(item) { item.source }) - |> result.unwrap("") +fn view_session_encounter_controls(model: Model) -> Element(Message) { + let monsters = session_filtered_monsters(model) + html.details([attribute.class("session-encounter-controls")], [ + html.summary([], [ + html.div([], [ + html.span([], [html.text("ENCOUNTER CONTROLS")]), + html.strong([], [html.text("Add combatants and manage the round")]), + ]), + html.span([], [html.text("+")]), + ]), + html.div([attribute.class("session-encounter-form")], [ + html.label([], [ + html.span([], [html.text("SEARCH 5E.TOOLS")]), + html.input([ + attribute.type_("search"), + attribute.placeholder("Search creatures…"), + attribute.value(model.monster_search), + event.on_input(UserChangedMonsterSearch), + ]), + ]), + html.label([], [ + html.span([], [html.text("CREATURE")]), + html.select( + [ + attribute.value(model.monster_catalog_selection), + attribute.disabled(model.monster_catalog_loading), + event.on_change(UserSelectedMonster), + ], + [ + html.option( + [attribute.value("")], + case model.monster_catalog_loading { + True -> "Loading creatures…" + False -> "Choose from catalog…" + }, + ), + ..list.map(monsters, fn(monster) { + html.option( + [attribute.value(monster.name)], + monster.name <> " · CR " <> monster.challenge_rating, + ) + }) + ], + ), + ]), + html.label([], [ + html.span([], [html.text("NAME")]), + html.input([ + attribute.type_("text"), + attribute.placeholder("Custom combatant"), + attribute.value(model.monster_name_input), + event.on_input(UserChangedMonsterName), + ]), + ]), + html.label([], [ + html.span([], [html.text("HIT POINTS")]), + html.input([ + attribute.type_("number"), + attribute.min("1"), + attribute.value(model.monster_hit_points_input), + event.on_input(UserChangedMonsterHitPoints), + ]), + ]), + html.label([], [ + html.span([], [html.text("INITIATIVE BONUS")]), + html.input([ + attribute.type_("number"), + attribute.min("-99"), + attribute.max("99"), + attribute.value(model.monster_initiative_bonus_input), + event.on_input(UserChangedMonsterInitiativeBonus), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("session-add-combatant-button"), + attribute.disabled(string.trim(model.monster_name_input) == ""), + event.on_click(UserAddedMonster), + ], + [html.text("Add combatant +")], + ), + ]), + case model.monster_catalog_error { + "" -> html.text("") + reason -> + html.p([attribute.class("session-connection-error")], [ + html.text(reason), + ]) + }, + ]) } -fn view_character_loading() -> Element(Message) { - html.main([attribute.class("first-character-workspace")], [ - html.section([attribute.class("panel first-character-card")], [ - html.span([attribute.class("eyebrow")], [html.text("LOCAL LIBRARY")]), - html.h2([], [html.text("Loading your characters…")]), +fn view_session_party( + model: Model, + ticket: String, + role: SessionRole, +) -> Element(Message) { + html.section([attribute.class("session-party-card")], [ + html.div([attribute.class("session-party-heading")], [ + html.h2([], [html.text("Party")]), + html.span([], [ + html.text(int.to_string(list.length(model.remote_sheets)) <> " MEMBERS"), + ]), + ]), + html.div([attribute.class("session-party-tabs")], [ + html.span([attribute.class("active")], [html.text("Members")]), + html.button( + [ + attribute.type_("button"), + event.on_click(UserClickedCopyPlayerInvite(ticket)), + ], + [html.text("Copy invite")], + ), ]), + case model.remote_sheets { + [] -> + html.div([attribute.class("session-party-empty")], [ + html.strong([], [ + html.text(case role { + GameMaster -> "Waiting for players" + Player -> "No other player sheets yet" + }), + ]), + html.p([], [ + html.text("Connected character sheets appear here automatically."), + ]), + ]) + snapshots -> + html.div( + [attribute.class("session-party-list")], + list.map(snapshots, view_session_party_member), + ) + }, ]) } -fn view_first_character(model: Model) -> Element(Message) { - html.main([attribute.class("first-character-workspace")], [ - html.section([attribute.class("panel first-character-card")], [ - html.span([attribute.class("eyebrow")], [html.text("BEGIN YOUR STORY")]), - html.h2([], [html.text("Create your first character")]), - html.p([], [ +fn view_session_party_member(snapshot: Snapshot) -> Element(Message) { + let display_name = case string.trim(snapshot.character_name) { + "" -> snapshot.owner + name -> name + } + html.details([attribute.class("session-party-member")], [ + html.summary([], [ + html.span([attribute.class("session-party-avatar")], [ + html.text(companion_initials(display_name)), + ]), + html.div([], [ + html.strong([], [html.text(display_name)]), + html.small([], [ + html.text( + snapshot.class_name <> " · Level " <> int.to_string(snapshot.level), + ), + ]), + ]), + html.span([attribute.class("session-party-presence")], []), + ]), + html.div([attribute.class("session-party-member-details")], [ + html.span([], [ html.text( - "Start a new D&D 2024 character or import an existing Sheets backup.", + "HP " + <> int.to_string(snapshot.current_hit_points) + <> " / " + <> int.to_string(snapshot.hit_points), ), ]), - html.div([attribute.class("first-character-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("primary-button"), - event.on_click(UserCreatedCharacter), - ], - [html.text("Create character")], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserClickedImport), - ], - [html.text("Import JSON")], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button game-master-link"), - event.on_click(UserClickedHost), - ], - [html.text("Host as Game Master")], - ), + html.span([], [ + html.text("AC " <> int.to_string(snapshot.armor_class)), + ]), + html.span([], [ + html.text(case snapshot.conditions { + [] -> "Ready" + conditions -> string.join(conditions, " · ") + }), ]), - case model.connection { - ConnectionFailed(reason:) -> - html.p( - [attribute.class("connection-error"), attribute.role("alert")], - [html.text(reason)], - ) - _ -> html.text("") - }, - case model.import_status { - "" -> html.text("") - status -> - html.p([attribute.class("import-status"), attribute.role("status")], [ - html.text(status), - ]) - }, ]), ]) } -fn view_spell_cast_dialog(model: Model) -> Element(Message) { - let slot_levels = case model.pending_spell_level { - 0 -> [0] - level -> available_spell_slot_levels(model, level) - } - html.aside( - [ - attribute.class("health-choice-window resource-dialog"), - attribute.role("dialog"), - attribute.aria_modal(True), - attribute.aria_label("Cast spell"), - ], - [ - html.div([attribute.class("health-choice-card")], [ - html.span([attribute.class("eyebrow")], [html.text("CAST SPELL")]), - html.h2([], [html.text(model.pending_spell_name)]), - case model.concentration { - "" -> html.text("") - current -> - html.p([attribute.class("effect-note")], [ - html.text( - "If this spell requires concentration, it replaces " - <> current - <> ".", - ), - ]) - }, - case slot_levels { - [] -> - html.p( - [attribute.class("connection-error"), attribute.role("alert")], - [ - html.text("No spell slot of this level or higher is available."), - ], - ) - _ -> - html.div( - [attribute.class("cast-slot-options")], - list.map(slot_levels, fn(level) { - html.button( - [ - attribute.type_("button"), - attribute.class("primary-button"), - event.on_click(UserConfirmedSpellCast(level)), - ], - [ - html.text(case level { - 0 -> "Cast cantrip" - level -> "Use level " <> int.to_string(level) <> " slot" - }), - ], - ) - }), - ) - }, - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserCancelledSpellCast), - ], - [html.text("Cancel")], - ), - ]), - ], - ) -} - -fn view_rest_dialog(model: Model) -> Element(Message) { - let maximum_hit_points = character_hit_points(model) - let current_hit_points = effective_current_hit_points(model) - html.aside( - [ - attribute.class("health-choice-window resource-dialog"), - attribute.role("dialog"), - attribute.aria_modal(True), - attribute.aria_label(case model.rest_kind { - ShortRest -> "Short rest" - LongRest -> "Long rest" - NoRest -> "Rest" - }), - ], - [ - html.div([attribute.class("health-choice-card rest-card")], [ - html.span([attribute.class("eyebrow")], [ - html.text(case model.rest_kind { - ShortRest -> "SHORT REST" - LongRest -> "LONG REST" - NoRest -> "REST" - }), - ]), - html.h2([], [html.text("Recovery preview")]), - case model.rest_kind { - ShortRest -> +fn view_session_recent_activity(model: Model) -> Element(Message) { + html.section([attribute.class("session-activity-card")], [ + html.div([attribute.class("session-activity-heading")], [ + html.span([], [html.text("RECENT ACTIVITY")]), + html.small([], [html.text("LATEST 3")]), + ]), + case list.take(model.dice_rolls, 3) { + [] -> + html.p([], [ + html.text("Table rolls and encounter updates will appear here."), + ]) + rolls -> + html.div( + [attribute.class("session-activity-list")], + list.map(rolls, fn(roll) { html.div([], [ - html.p([], [ - html.text( - "HP " - <> int.to_string(current_hit_points) - <> " → " - <> int.to_string(int.min( - maximum_hit_points, - current_hit_points + model.rest_hit_points_recovered, - )), - ), - ]), - html.p([], [ + html.span([], [ html.text( - "Hit Dice " - <> int.to_string( - model.level - - model.hit_dice_spent - - model.rest_hit_dice_spent, - ) - <> " / " - <> int.to_string(model.level) - <> " d" - <> int.to_string(class_hit_die(model.class_name)), + roll.author <> " rolled " <> int.to_string(roll.total), ), ]), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.disabled( - model.hit_dice_spent + model.rest_hit_dice_spent - >= model.level - || current_hit_points + model.rest_hit_points_recovered - >= maximum_hit_points, - ), - event.on_click(UserSpentRestHitDie), - ], - [html.text("Spend Hit Die")], - ), - html.p([attribute.class("effect-note")], [ - html.text("Short-rest resources recover when you finish."), - ]), - ]) - LongRest -> - html.div([attribute.class("rest-summary")], [ - html.p([], [ - html.text( - "HP " - <> int.to_string(current_hit_points) - <> " → " - <> int.to_string(maximum_hit_points), - ), + html.small([], [ + html.text(case string.trim(roll.note) { + "" -> roll.mode + note -> note + }), ]), - html.p([], [html.text("Hit Dice → All available")]), - html.p([], [html.text("Spell slots → All restored")]), - html.p([], [html.text("Long-rest resources → Restored")]), - case exhaustion_level(model) { - 0 -> html.text("") - level -> - html.p([], [ - html.text( - "Exhaustion " - <> int.to_string(level) - <> " → " - <> int.to_string(level - 1), - ), - ]) - }, ]) - NoRest -> html.text("") - }, - html.div([attribute.class("rest-dialog-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserCancelledRest), - ], - [html.text("Cancel")], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("primary-button"), - event.on_click(UserCompletedRest), - ], - [ - html.text(case model.rest_kind { - ShortRest -> "Finish Short Rest" - LongRest -> "Complete Long Rest" - NoRest -> "Finish" - }), - ], - ), - ]), - ]), - ], - ) -} - -fn view_global_dice_button() -> Element(Message) { - html.button( - [ - attribute.type_("button"), - attribute.class("global-dice-button"), - attribute.aria_label("Open dice roller"), - event.on_click(UserOpenedDiceRoll("", 0)), - ], - [ - html.span([], [html.text("d20")]), - html.strong([], [html.text("Roll")]), - ], - ) -} - -fn view_page_navigation(model: Model) -> Element(Message) { - let pages = case model.connection, model.has_character { - Connected(role: GameMaster, ..), _ -> [ - #("Live Table", LiveTablePage), - #( - "Saved Players " - <> int.to_string(list.length(model.saved_player_characters)), - SavedPlayersPage, - ), - ] - Connecting(role: GameMaster), _ -> [ - #("Live Table", LiveTablePage), - #( - "Saved Players " - <> int.to_string(list.length(model.saved_player_characters)), - SavedPlayersPage, - ), - ] - _, True -> [ - #("Character", CharacterPage), - #("Live Table", LiveTablePage), - #( - "Saved Players " - <> int.to_string(list.length(model.saved_player_characters)), - SavedPlayersPage, - ), - ] - _, False -> [ - #( - "Saved Players " - <> int.to_string(list.length(model.saved_player_characters)), - SavedPlayersPage, - ), - ] - } - html.nav( - [ - attribute.class("page-navigation"), - attribute.aria_label("Application pages"), - ], - list.map(pages, fn(page) { - let #(label, page) = page - page_navigation_button(label, page, model.active_page) - }), - ) + }), + ) + }, + ]) } -fn page_navigation_button( - label: String, - page: PageTab, - active_page: PageTab, -) -> Element(Message) { - let selected = page == active_page - html.button( - [ - attribute.type_("button"), - attribute.classes([ - #("page-navigation-button", True), - #("selected", selected), +fn view_session_rule_settings(model: Model) -> Element(Message) { + let sources = available_rule_sources(model) + html.details([attribute.class("session-rule-settings")], [ + html.summary([], [ + html.div([], [ + html.span([], [html.text("TABLE RULES")]), + html.strong([], [html.text("Allowed source books")]), ]), - attribute.aria_pressed(case selected { - True -> "true" - False -> "false" + html.span([], [html.text("+")]), + ]), + html.div( + [attribute.class("session-rule-list")], + list.map(sources, fn(source) { + let blocked = list.contains(model.blocked_sources, source) + html.label([], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(!blocked), + event.on_check(fn(_) { UserToggledBlockedSource(source) }), + ]), + html.span([], [ + html.strong([], [html.text(rule_source_name(source))]), + html.small([], [ + html.text(case blocked { + True -> source <> " · Blocked" + False -> source <> " · Allowed" + }), + ]), + ]), + ]) }), - event.on_click(UserSelectedPage(page)), - ], - [html.text(label)], - ) + ), + ]) } -fn view_player_page(model: Model) -> Element(Message) { - case model.active_page { - CharacterPage -> view_player_workspace(model) - LiveTablePage -> view_table_workspace(model) - SavedPlayersPage -> view_saved_players_workspace(model) +fn view_rule_restriction_notice(model: Model) -> Element(Message) { + case model.connection, model.blocked_sources { + Connected(role: Player, ..), [_, ..] -> + html.aside( + [ + attribute.class("rule-restriction-notice"), + attribute.role("status"), + ], + [ + html.strong([], [html.text("Some book selections are blocked")]), + html.p([], [ + html.text(case has_blocked_selection(model) { + True -> + "Your character already uses restricted content. Those selections remain, but other restricted options are disabled." + False -> + "The Game Master restricted " + <> blocked_source_names(model.blocked_sources) + <> ". Those options are disabled." + }), + ]), + ], + ) + Connected(role: GameMaster, ..), _ -> html.text("") + Connecting(role: _), _ -> html.text("") + Disconnected, _ -> html.text("") + ConnectionFailed(reason: _), _ -> html.text("") + _, [] -> html.text("") + } +} + +fn available_rule_sources(model: Model) -> List(String) { + let progression_sources = case model.class_progression { + Ok(progression) -> [ + progression.source, + ..list.map(progression.features, fn(feature) { feature.source }) + ] + Error(_) -> [] + } + let remote_sources = + model.remote_sheets + |> list.flat_map(fn(snapshot) { + list.append(snapshot.class_features, snapshot.origin_features) + |> list.map(fn(feature) { feature.source }) + }) + ["XPHB", "EFA", "Pugilist2024", "CthulhuTorchlight"] + |> list.append(list.map(model.species, fn(choice) { choice.source })) + |> list.append(list.map(model.backgrounds, fn(choice) { choice.source })) + |> list.append(list.map(model.feat_catalog, fn(feat) { feat.source })) + |> list.append(list.map(model.equipment_catalog, fn(item) { item.source })) + |> list.append(list.map(model.spell_catalog, fn(spell) { spell.source })) + |> list.append(progression_sources) + |> list.append(remote_sources) + |> list.filter(fn(source) { source != "" }) + |> list.unique +} + +fn rule_source_name(source: String) -> String { + case source { + "XPHB" -> "Player's Handbook (2024)" + "XMM" -> "Monster Manual (2024)" + "EFA" -> "Eberron: Forge of the Artificer" + "Pugilist2024" -> "The Pugilist Class (2024)" + "CthulhuTorchlight" -> "Cthulhu by Torchlight" + "TCE" -> "Tasha's Cauldron of Everything" + "SCC" -> "Strixhaven: A Curriculum of Chaos" + "ABH" -> "Astarion's Book of Hungers" + "FRHoF" -> "Forgotten Realms: Heroes of Faerûn" + "RHW" -> "Ravenloft: The Horrors Within" + "LFL" -> "Lorwyn: First Light" + "LLFLL" -> "Legends of the Forgotten Lands" + "AitFR-AVT" -> "Adventures in the Forgotten Realms: A Verdant Tomb" + source -> source + } +} + +fn blocked_source_names(sources: List(String)) -> String { + sources |> list.map(rule_source_name) |> string.join(", ") +} + +fn source_is_blocked(model: Model, source: String) -> Bool { + source != "" + && { + list.contains(model.blocked_sources, source) + || list.contains(model.local_blocked_sources, source) + } +} + +fn has_blocked_selection(model: Model) -> Bool { + source_is_blocked(model, selected_class_source(model.class_name)) + || source_is_blocked( + model, + selected_subclass_source(model, model.subclass_name), + ) + || source_is_blocked( + model, + selected_choice_source(model.species, model.species_name), + ) + || source_is_blocked( + model, + selected_choice_source(model.backgrounds, model.background_name), + ) + || list.any(model.feats, fn(selection) { + source_is_blocked( + model, + selected_feat_source(model.feat_catalog, selection.name), + ) + }) + || list.any(model.spells, fn(selection) { + source_is_blocked( + model, + selected_spell_source(model.spell_catalog, selection.name), + ) + }) + || list.any(model.equipment, fn(equipment) { + source_is_blocked( + model, + selected_equipment_source(model.equipment_catalog, equipment.name), + ) + }) + || list.any(model.other_proficiencies, fn(proficiency) { + source_is_blocked( + model, + selected_equipment_source(model.equipment_catalog, proficiency), + ) + }) +} + +fn selected_class_source(class_name: String) -> String { + class_choices() + |> list.find(fn(choice) { choice.name == class_name }) + |> result.map(fn(choice) { choice.source }) + |> result.unwrap("") +} + +fn selected_subclass_source(model: Model, subclass_name: String) -> String { + case model.class_progression { + Error(_) -> "" + Ok(progression) -> + progression.features + |> list.find(fn(feature) { feature.subclass == subclass_name }) + |> result.map(fn(feature) { feature.source }) + |> result.unwrap("") } } -fn view_player_workspace(model: Model) -> Element(Message) { - html.main([attribute.class("workspace")], [ - html.div([attribute.class("builder")], [ - view_identity(model), - view_core_abilities(model), - view_character_progression(model), - view_character_resources(model), +fn selected_choice_source(choices: List(Choice), name: String) -> String { + choices + |> list.find(fn(choice) { choice.name == name }) + |> result.map(fn(choice) { choice.source }) + |> result.unwrap("") +} + +fn selected_feat_source(feats: List(Feat), name: String) -> String { + feats + |> list.find(fn(feat) { feat.name == name }) + |> result.map(fn(feat) { feat.source }) + |> result.unwrap("") +} + +fn selected_spell_source(spells: List(Spell), name: String) -> String { + spells + |> list.find(fn(spell) { spell.name == name }) + |> result.map(fn(spell) { spell.source }) + |> result.unwrap("") +} + +fn selected_equipment_source( + equipment: List(InventoryItem), + name: String, +) -> String { + equipment + |> list.find(fn(item) { item.name == name }) + |> result.map(fn(item) { item.source }) + |> result.unwrap("") +} + +fn view_character_loading() -> Element(Message) { + html.main([attribute.class("first-character-workspace")], [ + html.section([attribute.class("panel first-character-card")], [ + html.span([attribute.class("eyebrow")], [html.text("LOCAL LIBRARY")]), + html.h2([], [html.text("Loading your characters…")]), ]), - view_sheet(model), ]) } -fn view_table_workspace(model: Model) -> Element(Message) { - html.main([attribute.class("workspace table-workspace")], [ - html.div([attribute.class("table-main-column")], [ - view_collaboration(model), - case model.has_character { - True -> - html.section([attribute.class("panel session-dashboard-panel")], [ - view_play_resources(model), - ]) - False -> html.text("") +fn view_first_character(model: Model) -> Element(Message) { + html.main([attribute.class("first-character-workspace")], [ + html.section([attribute.class("panel first-character-card")], [ + html.span([attribute.class("eyebrow")], [html.text("BEGIN YOUR STORY")]), + html.h2([], [html.text("Create your first character")]), + html.p([], [ + html.text( + "Start a new D&D 2024 character or import an existing Sheets backup.", + ), + ]), + html.div([attribute.class("first-character-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserCreatedCharacter), + ], + [html.text("Create character")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserClickedImport), + ], + [html.text("Import JSON")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button game-master-link"), + event.on_click(UserClickedHost), + ], + [html.text("Host as Game Master")], + ), + ]), + case model.connection { + ConnectionFailed(reason:) -> + html.p( + [attribute.class("connection-error"), attribute.role("alert")], + [html.text(reason)], + ) + _ -> html.text("") }, - view_initiative_tracker(model, InitiativeReadOnly), - case model.has_character { - True -> html.text("") - False -> - html.section([attribute.class("panel invited-character-prompt")], [ - html.span([attribute.class("eyebrow")], [ - html.text("JOINED TABLE"), - ]), - html.h2([], [html.text("Create your character")]), - html.p([], [ - html.text( - "You are connected. Create a character to share it with the Game Master and roll your initiative.", - ), - ]), - html.button( - [ - attribute.type_("button"), - attribute.class("primary-button"), - event.on_click(UserCreatedCharacter), - ], - [html.text("Create character")], - ), + case model.import_status { + "" -> html.text("") + status -> + html.p([attribute.class("import-status"), attribute.role("status")], [ + html.text(status), ]) }, - case model.inspected_owner { - "" -> view_all_companion_sheets(model) - _ -> view_inspected_character(model) - }, ]), ]) } -fn view_saved_players_workspace(model: Model) -> Element(Message) { - html.main( - [attribute.class("workspace table-workspace saved-players-workspace")], +fn view_spell_cast_dialog(model: Model) -> Element(Message) { + let slot_levels = case model.pending_spell_level { + 0 -> [0] + level -> available_spell_slot_levels(model, level) + } + html.aside( [ - html.div([attribute.class("table-main-column")], [ - html.section([attribute.class("panel saved-player-library")], [ - html.div([attribute.class("saved-player-heading")], [ + attribute.class("health-choice-window resource-dialog"), + attribute.role("dialog"), + attribute.aria_modal(True), + attribute.aria_label("Cast spell"), + ], + [ + html.div([attribute.class("health-choice-card")], [ + html.span([attribute.class("eyebrow")], [html.text("CAST SPELL")]), + html.h2([], [html.text(model.pending_spell_name)]), + case model.concentration { + "" -> html.text("") + current -> + html.p([attribute.class("effect-note")], [ + html.text( + "If this spell requires concentration, it replaces " + <> current + <> ".", + ), + ]) + }, + case slot_levels { + [] -> + html.p( + [attribute.class("connection-error"), attribute.role("alert")], + [ + html.text("No spell slot of this level or higher is available."), + ], + ) + _ -> + html.div( + [attribute.class("cast-slot-options")], + list.map(slot_levels, fn(level) { + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserConfirmedSpellCast(level)), + ], + [ + html.text(case level { + 0 -> "Cast cantrip" + level -> "Use level " <> int.to_string(level) <> " slot" + }), + ], + ) + }), + ) + }, + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserCancelledSpellCast), + ], + [html.text("Cancel")], + ), + ]), + ], + ) +} + +fn view_rest_dialog(model: Model) -> Element(Message) { + let maximum_hit_points = character_hit_points(model) + let current_hit_points = effective_current_hit_points(model) + html.aside( + [ + attribute.class("health-choice-window resource-dialog"), + attribute.role("dialog"), + attribute.aria_modal(True), + attribute.aria_label(case model.rest_kind { + ShortRest -> "Short rest" + LongRest -> "Long rest" + NoRest -> "Rest" + }), + ], + [ + html.div([attribute.class("health-choice-card rest-card")], [ + html.span([attribute.class("eyebrow")], [ + html.text(case model.rest_kind { + ShortRest -> "SHORT REST" + LongRest -> "LONG REST" + NoRest -> "REST" + }), + ]), + html.h2([], [html.text("Recovery preview")]), + case model.rest_kind { + ShortRest -> html.div([], [ - html.span([attribute.class("eyebrow")], [ - html.text("OFFLINE LIBRARY"), + html.p([], [ + html.text( + "HP " + <> int.to_string(current_hit_points) + <> " → " + <> int.to_string(int.min( + maximum_hit_points, + current_hit_points + model.rest_hit_points_recovered, + )), + ), ]), - html.h2([], [html.text("Saved player characters")]), html.p([], [ html.text( - "Save characters from a live table to inspect them offline. Their copies sync automatically when you meet again at the same table.", + "Hit Dice " + <> int.to_string( + model.level + - model.hit_dice_spent + - model.rest_hit_dice_spent, + ) + <> " / " + <> int.to_string(model.level) + <> " d" + <> int.to_string(class_hit_die(model.class_name)), ), ]), - ]), - html.strong([], [ - html.text( - int.to_string(list.length(model.saved_player_characters)), - ), - ]), - ]), - case model.saved_player_characters { - [] -> - html.div([attribute.class("saved-player-empty")], [ - html.h3([], [html.text("No saved players yet")]), - html.p([], [ - html.text( - "Open a connected character on the Live Table tab and choose Save for offline.", + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled( + model.hit_dice_spent + model.rest_hit_dice_spent + >= model.level + || current_hit_points + model.rest_hit_points_recovered + >= maximum_hit_points, ), - ]), - ]) - characters -> - html.div( - [attribute.class("saved-player-grid")], - list.map(characters, fn(character) { - view_saved_player_card(model, character) - }), - ) - }, + event.on_click(UserSpentRestHitDie), + ], + [html.text("Spend Hit Die")], + ), + html.p([attribute.class("effect-note")], [ + html.text("Short-rest resources recover when you finish."), + ]), + ]) + LongRest -> + html.div([attribute.class("rest-summary")], [ + html.p([], [ + html.text( + "HP " + <> int.to_string(current_hit_points) + <> " → " + <> int.to_string(maximum_hit_points), + ), + ]), + html.p([], [html.text("Hit Dice → All available")]), + html.p([], [html.text("Spell slots → All restored")]), + html.p([], [html.text("Long-rest resources → Restored")]), + case exhaustion_level(model) { + 0 -> html.text("") + level -> + html.p([], [ + html.text( + "Exhaustion " + <> int.to_string(level) + <> " → " + <> int.to_string(level - 1), + ), + ]) + }, + ]) + NoRest -> html.text("") + }, + html.div([attribute.class("rest-dialog-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserCancelledRest), + ], + [html.text("Cancel")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserCompletedRest), + ], + [ + html.text(case model.rest_kind { + ShortRest -> "Finish Short Rest" + LongRest -> "Complete Long Rest" + NoRest -> "Finish" + }), + ], + ), ]), - view_saved_player_inspector(model), ]), ], ) } -fn view_saved_player_card( - model: Model, - character: SavedPlayerCharacter, -) -> Element(Message) { - let snapshot = character.snapshot - let name = snapshot_display_name(snapshot) - let selected = character.id == model.inspected_saved_player_character_id - let live = saved_player_is_live(model, character) - html.article([attribute.class("saved-player-entry")], [ - html.button( +fn view_global_dice_button() -> Element(Message) { + html.button( + [ + attribute.type_("button"), + attribute.class("global-dice-button"), + attribute.aria_label("Open dice roller"), + event.on_click(UserOpenedDiceRoll("", 0)), + ], + [ + html.span([], [html.text("d20")]), + html.strong([], [html.text("Roll")]), + ], + ) +} + +fn view_player_page(model: Model) -> Element(Message) { + case model.active_page { + CharacterOverviewPage -> view_character_overview(model) + CharacterBuilderPage -> view_focused_character_builder(model) + CharacterInventoryPage -> view_inventory_page(model) + CharacterSpellsPage -> view_spellbook_page(model) + CharacterCompanionsPage -> view_companions_page(model) + CharacterNotesPage -> view_notes_page(model) + LiveTablePage -> view_live_session_page(model) + ResourceManagementPage -> view_resource_management_page(model) + SavedPlayersPage -> view_saved_players_workspace(model) + } +} + +fn view_character_overview(model: Model) -> Element(Message) { + let maximum_hit_points = character_hit_points(model) + let current_hit_points = effective_current_hit_points(model) + let identity_title = case model.subclass_name, model.class_name { + "", "" -> "Adventurer" + "", class_name -> class_name + subclass_name, _ -> subclass_name + } + let status_chips = overview_status_chips(model) + let overview_features = case model.class_progression { + Ok(progression) -> + progression.features + |> list.filter(fn(feature) { + feature.level <= model.level + && { feature.subclass == "" || feature.subclass == model.subclass_name } + }) + |> list.take(4) + Error(_) -> [] + } + + html.main([attribute.class("sheet-page sheet-overview-page")], [ + ui.page_heading( + "CHARACTER OVERVIEW", + case string.trim(model.character_name) { + "" -> "Unnamed Adventurer" + name -> name + }, + "Everything you need at the table. Details stay one step away.", [ - attribute.type_("button"), - attribute.classes([ - #("remote-card", True), - #("selected", selected), - ]), - attribute.aria_pressed(case selected { - True -> "true" - False -> "false" - }), - event.on_click(UserInspectedSavedPlayerCharacter(character.id)), + ui.secondary_button("Edit", UserSelectedPage(CharacterBuilderPage)), + ui.primary_button("Start session", UserSelectedPage(LiveTablePage)), ], - [ - html.div([attribute.class("remote-avatar")], [ - html.text(string.slice(name, at_index: 0, length: 1)), + ), + html.section([attribute.class("sheet-character-hero")], [ + html.div([attribute.class("sheet-character-hero-copy")], [ + html.span([attribute.class("sheet-overline")], [ + html.text( + string.uppercase(case model.species_name { + "" -> "ADVENTURER" + species -> species + }) + <> " · LEVEL " + <> int.to_string(model.level), + ), ]), - html.div([], [ - html.strong([], [html.text(name)]), - html.span([], [ - html.text( - snapshot.owner - <> " · L" - <> int.to_string(snapshot.level) - <> " " - <> snapshot.class_name, - ), - ]), + html.h2([], [html.text(identity_title)]), + html.p([], [ + html.text(case model.background_name { + "" -> "Choose a background to complete your origin." + background -> background <> " background" + }), ]), - html.div([attribute.class("remote-vitals")], [ - html.span([], [ - html.text("AC " <> int.to_string(snapshot.armor_class)), - ]), - html.span([], [ - html.text("HP " <> int.to_string(snapshot.hit_points)), - ]), + ]), + html.div([attribute.class("sheet-character-level")], [ + html.span([], [html.text("LEVEL")]), + html.strong([], [html.text(two_digits(model.level))]), + html.small([], [ + html.text(case model.class_name { + "" -> "Adventurer" + class_name -> class_name + }), ]), - ], - ), - html.div([attribute.class("saved-player-meta")], [ - html.span( + ]), + ]), + html.div([attribute.class("sheet-overview-metrics")], [ + ui.metric(ui.Metric( + label: "ARMOR CLASS", + value: int.to_string(character_armor_class(model)), + detail: case equipped_armor_name(model.equipment) { + "" -> "unarmored" + armor -> armor + }, + marker: "AC", + )), + html.button( [ - attribute.classes([ - #("saved-player-status", True), - #("live", live), - ]), + attribute.type_("button"), + attribute.class("sheet-health-metric"), + event.on_click(UserOpenedHealthConfiguration), ], [ - html.text(case live { - True -> "LIVE · SYNCHRONIZED" - False -> "OFFLINE COPY" - }), + ui.metric(ui.Metric( + label: "HIT POINTS", + value: int.to_string(current_hit_points) + <> " / " + <> int.to_string(maximum_hit_points), + detail: case model.temporary_hit_points { + 0 -> "current / maximum" + temporary -> "+" <> int.to_string(temporary) <> " temporary" + }, + marker: "HP", + )), ], ), - html.span([], [ - html.text( - "Last synced " <> browser.format_timestamp(character.last_synced_at), + ui.metric(ui.Metric( + label: "INITIATIVE", + value: signed(modifier(effective_ability_score(model, Dexterity))), + detail: "dexterity", + marker: "INIT", + )), + ui.metric(ui.Metric( + label: "SPEED", + value: int.to_string(character_speed(model)) <> " ft.", + detail: "walking", + marker: "SPD", + )), + ]), + html.section([attribute.class("sheet-overview-abilities")], [ + html.div([attribute.class("sheet-section-heading")], [ + html.h2([], [html.text("Ability scores")]), + html.button( + [ + attribute.type_("button"), + event.on_click(UserSelectedPage(CharacterBuilderPage)), + ], + [html.text("EDIT SCORES →")], ), ]), - html.button( - [ - attribute.type_("button"), - attribute.aria_label("Remove saved copy of " <> name), - event.on_click(UserRemovedSavedPlayerCharacter(character.id)), - ], - [html.text("Remove")], + html.div( + [attribute.class("sheet-overview-ability-grid")], + list.map( + [ + Strength, + Dexterity, + Constitution, + Intelligence, + Wisdom, + Charisma, + ], + fn(ability) { view_overview_ability_card(model, ability) }, + ), ), ]), - ]) -} - -fn saved_player_is_live(model: Model, character: SavedPlayerCharacter) -> Bool { - case connection_ticket(model.connection) { - Error(Nil) -> False - Ok(table_ticket) -> - character.table_ticket == table_ticket - && list.any(model.remote_sheets, fn(snapshot) { - snapshot.owner == character.snapshot.owner - }) - } -} - -fn view_saved_player_inspector(model: Model) -> Element(Message) { - case - list.find(model.saved_player_characters, fn(character) { - character.id == model.inspected_saved_player_character_id - }) - { - Error(Nil) -> html.text("") - Ok(character) -> - html.div([attribute.class("inspector-stack saved-player-inspector")], [ - html.div([attribute.class("saved-player-inspector-actions")], [ + html.div([attribute.class("sheet-overview-lower")], [ + html.section([attribute.class("sheet-overview-resources")], [ + html.div([attribute.class("sheet-section-heading")], [ + html.h2([], [html.text("Resources")]), html.button( [ attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserInspectedSavedPlayerCharacter("")), + event.on_click(UserSelectedPage(CharacterSpellsPage)), ], - [html.text("Return to saved players")], + [html.text("MANAGE →")], ), + ]), + view_overview_resources(model), + ]), + html.section([attribute.class("sheet-overview-status")], [ + html.div([attribute.class("sheet-section-heading")], [ + html.h2([], [html.text("Current status")]), html.button( [ attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserRemovedSavedPlayerCharacter(character.id)), + event.on_click(UserSelectedPage(LiveTablePage)), ], - [html.text("Remove saved copy")], + [html.text("EDIT STATUS")], ), ]), - view_snapshot(character.snapshot), - ]) - } -} - -fn view_all_companion_sheets(model: Model) -> Element(Message) { - case model.remote_sheets { - [] -> - html.div([attribute.class("table-empty-state")], [ - html.span([attribute.class("eyebrow")], [html.text("Live table")]), - html.h2([], [html.text("No companion sheets yet")]), + html.div([attribute.class("sheet-status-chip-list")], status_chips), + html.span([attribute.class("sheet-overline")], [ + html.text("SESSION NOTE"), + ]), html.p([], [ - html.text("Connected characters will appear here in full."), + html.text( + case + list.find(model.notes, fn(note) { + note.category == SessionNote && string.trim(note.body) != "" + }) + { + Error(Nil) -> "No session note yet." + Ok(note) -> + note.body + |> string.split("\n") + |> list.first + |> result.unwrap(note.body) + }, + ), ]), - ]) - snapshots -> - html.div( - [attribute.class("table-sheet-list")], - list.map(snapshots, view_snapshot), - ) - } + ]), + ]), + html.section([attribute.class("sheet-overview-features")], [ + html.div([attribute.class("sheet-section-heading")], [ + html.h2([], [html.text("Class features")]), + html.button( + [ + attribute.type_("button"), + event.on_click(UserSelectedPage(CharacterBuilderPage)), + ], + [html.text("VIEW ALL →")], + ), + ]), + case overview_features { + [] -> + html.div([attribute.class("sheet-empty-inline")], [ + html.strong([], [html.text("No class features yet")]), + html.p([], [html.text("Choose a class in the builder to begin.")]), + ]) + features -> + html.div( + [attribute.class("sheet-overview-feature-grid")], + list.map(features, view_overview_feature_card), + ) + }, + ]), + ]) } -fn class_choice_incomplete_count(model: Model) -> Int { - case model.class_progression { - Error(_) -> 0 - Ok(progression) -> - progression.choices - |> list.filter(fn(choice) { - visible_feature_choice(choice, model) - && feature_choice_value(choice, model) == "" - }) - |> list.length - } +fn view_overview_ability_card( + model: Model, + ability: Ability, +) -> Element(Message) { + let score = effective_ability_score(model, ability) + let ability_label = ability_name(ability) + let proficient = + list.contains(class_saving_throw_proficiencies(model.class_name), ability) + let saving_throw = + modifier(score) + + case proficient { + True -> proficiency_bonus(model.level) + False -> 0 + } + + html.button( + [ + attribute.type_("button"), + attribute.class("sheet-overview-ability-card"), + attribute.aria_label("Roll " <> ability_label), + event.on_click(UserOpenedDiceRoll(ability_label, modifier(score))), + ], + [ + html.span([], [html.text(string.uppercase(ability_label))]), + html.strong([], [html.text(int.to_string(score))]), + html.em([], [html.text(signed(modifier(score)))]), + html.small([], [ + html.text( + "SAVE " + <> signed(saving_throw) + <> case proficient { + True -> " · PROF" + False -> "" + }, + ), + ]), + ], + ) } -fn origin_choice_incomplete_count(model: Model) -> Int { - case model.origin_details { - Error(_) -> 0 - Ok(details) -> - details.choices - |> list.filter(fn(choice) { - feature_selection_value(model.feature_selections, choice.id) == "" - }) - |> list.length +fn class_saving_throw_proficiencies(class_name: String) -> List(Ability) { + case class_name { + "Barbarian" -> [Strength, Constitution] + "Bard" -> [Dexterity, Charisma] + "Cleric" -> [Wisdom, Charisma] + "Druid" -> [Intelligence, Wisdom] + "Fighter" -> [Strength, Constitution] + "Monk" -> [Strength, Dexterity] + "Paladin" -> [Wisdom, Charisma] + "Ranger" -> [Strength, Dexterity] + "Rogue" -> [Dexterity, Intelligence] + "Sorcerer" -> [Constitution, Charisma] + "Warlock" -> [Wisdom, Charisma] + "Wizard" -> [Intelligence, Wisdom] + "Artificer" -> [Constitution, Intelligence] + _ -> [] } } -fn feat_choice_incomplete_count(model: Model) -> Int { - feat_levels(model.class_name, model.level) - |> list.filter(fn(level) { - let feat = selected_feat(model.feats, level) - feat.name == "" || feat_selection_requires_ability(model.feat_catalog, feat) - }) - |> list.length +fn view_overview_feature_card(feature: ClassFeature) -> Element(Message) { + html.article([attribute.class("sheet-overview-feature-card")], [ + html.div([], [ + html.span([], [html.text("LEVEL " <> int.to_string(feature.level))]), + html.h3([], [html.text(feature.name)]), + ]), + html.p([], [ + html.text(case string.trim(feature.description) { + "" -> "Feature details are available in the character builder." + description -> description + }), + ]), + ]) } -fn spell_selection_incomplete(model: Model) -> Bool { - maximum_spell_level(model.class_name, model.subclass_name, model.level) > 0 - && list.is_empty(model.spells) +fn view_overview_resources(model: Model) -> Element(Message) { + let spell_slots = + [1, 2, 3, 4, 5, 6, 7, 8, 9] + |> list.filter(fn(level) { spell_slot_maximum(model, level) > 0 }) + |> list.take(3) + |> list.map(fn(level) { + let maximum = spell_slot_maximum(model, level) + let ready = maximum - spell_slot_spent(model, level) + overview_resource_row( + "Spell slots · Level " <> int.to_string(level), + ready, + maximum, + int.to_string(ready) <> " of " <> int.to_string(maximum) <> " ready", + ) + }) + let class_resources = + model + |> class_resource_definitions() + |> list.take(2) + |> list.map(fn(resource) { + let ready = resource.maximum - resource_spent(model, resource.id) + overview_resource_row( + resource.name, + ready, + resource.maximum, + case ready == resource.maximum { + True -> "Available" + False -> int.to_string(ready) <> " ready" + }, + ) + }) + html.div( + [attribute.class("sheet-resource-list")], + list.append(spell_slots, class_resources), + ) } -fn tab_incomplete_count(model: Model, tab: AbilitiesTab) -> Int { - case tab { - ClassFeaturesTab -> class_choice_incomplete_count(model) - OriginsTab -> origin_choice_incomplete_count(model) - FeatsTab -> feat_choice_incomplete_count(model) - SpellsTab -> - case spell_selection_incomplete(model) { - True -> 1 - False -> 0 - } - LevelUpTab -> - case pending_health_level(model) { - Ok(_) -> 1 - Error(Nil) -> 0 - } - AbilityScoresTab -> 0 - SkillProficienciesTab -> 0 - EquipmentTab -> 0 - CompanionsTab -> 0 - NotesTab -> 0 - } +fn overview_resource_row( + label: String, + ready: Int, + maximum: Int, + detail: String, +) -> Element(Message) { + html.div([attribute.class("sheet-resource-row")], [ + html.div([attribute.class("sheet-resource-row-heading")], [ + html.strong([], [html.text(label)]), + html.span([], [html.text(detail)]), + ]), + html.div( + [attribute.class("sheet-resource-pips")], + levels() + |> list.take(int.max(1, maximum)) + |> list.map(fn(index) { + html.span( + [ + attribute.class(case index <= ready { + True -> "ready" + False -> "spent" + }), + ], + [], + ) + }), + ), + ]) } -fn feature_choice_value(choice: FeatureChoice, model: Model) -> String { - case choice.id { - "subclass" -> model.subclass_name - id -> feature_selection_value(model.feature_selections, id) +fn overview_status_chips(model: Model) -> List(Element(Message)) { + let conditions = + model.conditions + |> list.filter(fn(condition) { condition.name != "Exhaustion" }) + |> list.map(fn(condition) { + ui.status_chip(condition.name, condition_chip_tone(condition.name)) + }) + let concentration = case model.concentration { + "" -> [] + _ -> [ui.status_chip("Concentrating", ui.AccentChip)] } -} - -fn character_count(model: Model) -> Int { - case model.has_character { - False -> list.length(model.characters) - True -> - list.length(upsert_character( - model.characters, - model_to_stored_character(model), - )) + let inspiration = case model.inspiration { + True -> [ui.status_chip("Inspired", ui.NotificationChip)] + False -> [] + } + let exhaustion = case exhaustion_level(model) { + 0 -> [] + level -> [ + ui.status_chip("Exhaustion " <> int.to_string(level), ui.WarningChip), + ] + } + case + list.append( + conditions, + list.append(concentration, list.append(inspiration, exhaustion)), + ) + { + [] -> [ui.status_chip("Ready", ui.NeutralChip)] + chips -> chips } } -fn view_header(model: Model) -> Element(Message) { - let #(status_class, status_text) = case model.rules_data_url { - "" -> #("offline", "Rules data not configured") - _ -> - case model.species_loaded, model.backgrounds_loaded, model.catalog_error { - True, True, False -> #("ready", "Rules data synced") - True, True, True -> #("offline", "Using offline catalog") - _, _, _ -> #("loading", "Syncing rules data") - } +fn condition_chip_tone(name: String) -> ui.ChipTone { + case name { + "Poisoned" | "Unconscious" -> ui.DangerChip + "Prone" | "Restrained" | "Stunned" -> ui.WarningChip + _ -> ui.NeutralChip } +} - html.header([attribute.class("topbar")], [ - html.div([attribute.class("brand")], [ - html.div([attribute.class("brand-mark")], [html.text("S")]), - html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("D&D 2024")]), - html.h1([], [html.text("SHEETS")]), - ]), - ]), - html.div([attribute.class("topbar-center")], [ - html.span([attribute.class("edition-pill")], [html.text("2024 RULESET")]), - ]), - html.div([attribute.class("topbar-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("characters-button"), - attribute.aria_expanded(model.library_open), - event.on_click(UserToggledCharacterLibrary), - ], - [ - html.span([], [html.text("Characters")]), - html.strong([], [ - html.text(int.to_string(character_count(model))), +fn view_focused_character_builder(model: Model) -> Element(Message) { + html.main([attribute.class("sheet-page sheet-builder-page")], [ + ui.page_heading( + "CHARACTER BUILDER", + "Shape the next chapter", + "One decision at a time. Your current character stays playable as you build.", + [ + html.span([attribute.class("sheet-saved-indicator")], [ + html.span([attribute.class("sheet-status-light")], []), + html.text("All choices saved"), + ]), + ], + ), + view_builder_steps(model.active_builder_tab), + html.div([attribute.class("sheet-builder-layout")], [ + html.section([attribute.class("sheet-builder-decision")], [ + html.div([attribute.class("sheet-builder-decision-heading")], [ + html.div([], [ + html.span([attribute.class("sheet-overline")], [ + html.text( + "STEP " + <> int.to_string(builder_step_number(model.active_builder_tab)) + <> " OF 3", + ), + ]), + html.h2([], [ + html.text(builder_step_title(model.active_builder_tab)), + ]), + html.p([], [ + html.text(builder_step_description(model.active_builder_tab)), + ]), ]), - ], - ), - case model.has_character { - False -> html.text("") - True -> - html.button( - [ - attribute.type_("button"), - attribute.class("print-button"), - event.on_click(UserClickedPrint), - ], - [html.text("Print")], - ) - }, - html.button( - [ - attribute.type_("button"), - attribute.class("books-button"), - attribute.aria_expanded(model.rules_data_settings_open), - event.on_click(UserToggledRulesDataSettings), - ], - [html.text("Books")], - ), + ]), + html.div([attribute.class("sheet-builder-content")], [ + view_builder_content(model), + ]), + html.div([attribute.class("sheet-builder-actions")], [ + ui.secondary_button( + "← Back", + builder_previous_message(model.active_builder_tab), + ), + ui.primary_button( + builder_continue_label(model.active_builder_tab), + builder_next_message(model.active_builder_tab), + ), + ]), + ]), + html.aside([attribute.class("sheet-builder-summary")], [ + html.span([attribute.class("sheet-overline")], [ + html.text("BUILD SUMMARY"), + ]), + html.h3([], [html.text("Current character")]), + html.p([], [ + html.text( + case model.species_name { + "" -> "Species unset" + species -> species + } + <> " · " + <> case model.class_name { + "" -> "Class unset" + class_name -> class_name + }, + ), + ]), + html.div([attribute.class("sheet-builder-summary-score")], [ + html.span([], [html.text("PRIMARY SCORE")]), + html.strong([], [ + html.text( + int.to_string(effective_ability_score( + model, + character_primary_ability(model.class_name), + )), + ), + ]), + ]), + html.div([attribute.class("sheet-builder-summary-list")], [ + html.span([], [ + html.text( + int.to_string(list.length(model.skills)) <> " skill proficiencies", + ), + ]), + html.span([], [ + html.text(int.to_string(list.length(model.feats)) <> " feats"), + ]), + html.span([], [ + html.text(int.to_string(list.length(model.spells)) <> " spells"), + ]), + ]), + ]), + ]), + ]) +} + +fn character_primary_ability(class_name: String) -> Ability { + case class_name { + "Barbarian" | "Fighter" -> Strength + "Monk" | "Ranger" | "Rogue" -> Dexterity + "Artificer" | "Wizard" -> Intelligence + "Cleric" | "Druid" -> Wisdom + "Bard" | "Paladin" | "Sorcerer" | "Warlock" -> Charisma + _ -> Strength + } +} + +fn view_builder_steps(active: AbilitiesTab) -> Element(Message) { + let steps = [ + #("01", "Foundation", OriginsTab), + #("02", "Abilities", AbilityScoresTab), + #("03", "Class progression", ClassFeaturesTab), + ] + html.nav( + [ + attribute.class("sheet-builder-steps"), + attribute.aria_label("Character build steps"), + ], + list.map(steps, fn(step) { + let #(number, label, tab) = step + let selected = builder_step_number(active) == builder_step_number(tab) html.button( [ attribute.type_("button"), - attribute.class("data-status " <> status_class), - attribute.aria_expanded(model.rules_data_settings_open), - attribute.aria_label("Configure rules data URL"), - event.on_click(UserToggledRulesDataSettings), + attribute.classes([ + #("sheet-builder-step", True), + #("selected", selected), + ]), + attribute.aria_pressed(case selected { + True -> "true" + False -> "false" + }), + event.on_click(UserSelectedAbilitiesTab(tab)), ], [ - html.span([attribute.class("status-light")], []), - html.div([], [ - html.strong([], [html.text(status_text)]), - html.span([], [html.text("Browser data source")]), - ]), - html.span([attribute.class("data-status-mobile-label")], [ - html.text("Data"), - ]), + html.span([], [html.text(number)]), + html.strong([], [html.text(label)]), ], - ), - case model.library_open { - True -> view_character_library(model) - False -> html.text("") - }, - case model.rules_data_settings_open { - True -> view_rules_data_settings(model) - False -> html.text("") + ) + }), + ) +} + +fn view_builder_content(model: Model) -> Element(Message) { + case model.active_builder_tab { + OriginsTab -> view_identity(model) + AbilityScoresTab -> view_ability_scores(model) + SkillProficienciesTab -> view_skill_proficiencies(model) + ClassFeaturesTab -> view_class_features(model) + FeatsTab -> view_feats(model) + LevelUpTab -> view_level_up(model) + EquipmentTab -> view_equipment(model) + SpellsTab -> view_spells(model) + CompanionsTab -> view_companions(model) + NotesTab -> view_notes(model) + } +} + +fn builder_step_number(tab: AbilitiesTab) -> Int { + case tab { + OriginsTab -> 1 + AbilityScoresTab | SkillProficienciesTab -> 2 + ClassFeaturesTab + | FeatsTab + | LevelUpTab + | EquipmentTab + | SpellsTab + | CompanionsTab + | NotesTab -> 3 + } +} + +fn builder_step_title(tab: AbilitiesTab) -> String { + case builder_step_number(tab) { + 1 -> "Choose your foundation" + 2 -> "Set your ability scores" + _ -> "Shape class progression" + } +} + +fn builder_step_description(tab: AbilitiesTab) -> String { + case builder_step_number(tab) { + 1 -> + "Choose identity, species, background, and the origin details that define your character." + 2 -> + "Assign the 2024 standard array, then choose the skills your character has trained." + _ -> + "Review class features, subclass choices, feats, and level-by-level decisions." + } +} + +fn builder_previous_message(tab: AbilitiesTab) -> Message { + case builder_step_number(tab) { + 1 -> UserSelectedPage(CharacterOverviewPage) + 2 -> UserSelectedAbilitiesTab(OriginsTab) + _ -> UserSelectedAbilitiesTab(AbilityScoresTab) + } +} + +fn builder_next_message(tab: AbilitiesTab) -> Message { + case builder_step_number(tab) { + 1 -> UserSelectedAbilitiesTab(AbilityScoresTab) + 2 -> UserSelectedAbilitiesTab(ClassFeaturesTab) + _ -> UserSelectedPage(CharacterInventoryPage) + } +} + +fn builder_continue_label(tab: AbilitiesTab) -> String { + case builder_step_number(tab) { + 1 -> "Continue to abilities" + 2 -> "Continue to class" + _ -> "Continue to equipment" + } +} + +fn view_inventory_page(model: Model) -> Element(Message) { + html.main([attribute.class("sheet-page sheet-resource-page")], [ + ui.page_heading( + "EQUIPMENT LOADOUT · " + <> int.to_string(list.length(model.equipment)) + <> " ITEMS", + "Inventory & Equipment", + "Carry, equip, consume, and inspect everything on your sheet.", + [], + ), + html.section([attribute.class("sheet-resource-workspace")], [ + view_equipment(model), + ]), + ]) +} + +fn view_spellbook_page(model: Model) -> Element(Message) { + html.main([attribute.class("sheet-page sheet-resource-page")], [ + ui.page_heading( + "SPELLBOOK", + "Spellbook", + case model.class_name { + "" -> int.to_string(list.length(model.spells)) <> " spells selected" + class_name -> + class_name + <> " " + <> int.to_string(model.level) + <> " · " + <> int.to_string(list.length(model.spells)) + <> " known" }, + [], + ), + html.section([attribute.class("sheet-resource-workspace")], [ + view_spells(model), ]), ]) } -fn view_rules_data_settings(model: Model) -> Element(Message) { - html.div( - [ - attribute.class("rules-data-settings"), - attribute.role("dialog"), - attribute.aria_label("Rules data source"), - ], - [ - html.div([attribute.class("rules-data-settings-heading")], [ - html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("DATA SOURCE")]), - html.h2([], [html.text("Rules data URL")]), +fn view_companions_page(model: Model) -> Element(Message) { + html.main([attribute.class("sheet-page sheet-resource-page")], [ + ui.page_heading( + "SAVED ROSTER", + "Companions", + "Keep familiars, summons, and objects ready for the table.", + [], + ), + html.section([attribute.class("sheet-resource-workspace")], [ + view_companions(model), + ]), + ]) +} + +fn view_notes_page(model: Model) -> Element(Message) { + html.main([attribute.class("sheet-page sheet-notes-page")], [ + ui.page_heading( + "CHARACTER RECORD · NOTES", + "Character notes", + "An autosaved journal for sessions, people, and open threads.", + [], + ), + html.section([attribute.class("sheet-resource-workspace")], [ + view_notes(model), + ]), + ]) +} + +fn view_resource_management_page(model: Model) -> Element(Message) { + let maximum_hit_points = character_hit_points(model) + let current_hit_points = effective_current_hit_points(model) + let available_hit_dice = int.max(0, model.level - model.hit_dice_spent) + let spell_slots_ready = total_spell_slots_ready(model) + let class_resources_ready = total_class_resources_ready(model) + let condition_count = + model.conditions + |> list.filter(fn(condition) { condition.name != "Exhaustion" }) + |> list.length + + html.main([attribute.class("sheet-page resource-management-page")], [ + ui.page_heading( + "RESOURCE MANAGEMENT", + "Adventure resources", + "Spend deliberately, recover with confidence, and keep every limited-use feature visible.", + [], + ), + html.section([attribute.class("resource-command-hero")], [ + html.div([attribute.class("resource-command-copy")], [ + html.span([attribute.class("resource-kicker")], [ + html.text(case current_hit_points { + 0 -> "AT 0 HIT POINTS · DEATH SAVES ACTIVE" + _ -> "READY STATE · LEVEL " <> int.to_string(model.level) + }), ]), - html.button( - [ - attribute.type_("button"), - attribute.aria_label("Close rules data settings"), - event.on_click(UserToggledRulesDataSettings), - ], - [html.text("×")], - ), - ]), - html.p([], [ - html.text( - "Enter the base URL of a compatible rules data directory. It must contain races.json, backgrounds.json, feats.json, items-base.json, class/, generated/, and spells/.", - ), - ]), - html.label([attribute.class("rules-data-url-field")], [ - html.span([], [html.text("DATA DIRECTORY URL")]), - html.input([ - attribute.type_("url"), - attribute.class("text-input"), - attribute.placeholder("https://example.com/data/"), - attribute.value(model.rules_data_url_input), - event.on_input(UserChangedRulesDataUrl), + html.h2([], [ + html.text(case current_hit_points { + 0 -> "Stabilize before the next move." + _ -> "Ready for the next encounter." + }), + ]), + html.p([], [ + html.text( + "Your health, spell slots, class pools, conditions, and prepared magic are managed here—not at the table.", + ), ]), ]), - case model.rules_data_url_error { - "" -> html.text("") - reason -> - html.p([attribute.class("rules-data-error")], [html.text(reason)]) - }, - html.div([attribute.class("rules-data-actions")], [ + html.div([attribute.class("resource-rest-controls")], [ html.button( [ attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserChangedRulesDataUrl("")), + attribute.class("resource-rest-button"), + event.on_click(UserOpenedRest(ShortRest)), + ], + [ + html.span([], [html.text("SHORT REST")]), + html.strong([], [html.text("Recover tactically")]), ], - [html.text("Clear")], ), html.button( [ attribute.type_("button"), - attribute.class("primary-button"), - event.on_click(UserSavedRulesDataUrl), + attribute.class("resource-rest-button primary"), + event.on_click(UserOpenedRest(LongRest)), + ], + [ + html.span([], [html.text("LONG REST")]), + html.strong([], [html.text("Restore fully")]), ], - [html.text("Save & load")], ), + case model.rest_undo { + Ok(_) -> + html.button( + [ + attribute.type_("button"), + attribute.class("resource-undo-button"), + event.on_click(UserUndidRest), + ], + [html.text("Undo last rest ↶")], + ) + Error(Nil) -> html.text("") + }, ]), - html.small([], [ - html.text("The URL is stored only in this browser."), + ]), + html.section( + [ + attribute.class("resource-vitals"), + attribute.aria_label("Resource readiness"), + ], + [ + resource_vital( + "CURRENT HP", + int.to_string(current_hit_points) + <> " / " + <> int.to_string(maximum_hit_points), + "Hit points", + current_hit_points == 0, + ), + resource_vital( + "TEMP HP", + signed(model.temporary_hit_points), + "Temporary buffer", + False, + ), + resource_vital( + "HIT DICE", + int.to_string(available_hit_dice) + <> " / " + <> int.to_string(model.level), + "Available to spend", + False, + ), + resource_vital( + "SPELL SLOTS", + int.to_string(spell_slots_ready), + "Ready to cast", + False, + ), + resource_vital( + "CLASS POOLS", + int.to_string(class_resources_ready), + "Uses remaining", + False, + ), + resource_vital( + "ACTIVE EFFECTS", + int.to_string(condition_count + exhaustion_level(model)), + "Conditions and exhaustion", + condition_count > 0 || exhaustion_level(model) > 0, + ), + ], + ), + html.div([attribute.class("resource-management-layout")], [ + html.div([attribute.class("resource-management-ledger")], [ + view_resource_spell_slots(model), + view_resource_class_pools(model), + view_resource_prepared_spells(model), ]), - view_local_book_settings(model), + html.aside([attribute.class("resource-management-sidebar")], [ + view_resource_status(model), + view_resource_recovery(model), + ]), + ]), + ]) +} + +fn resource_vital( + label: String, + value: String, + detail: String, + warning: Bool, +) -> Element(Message) { + html.article( + [ + attribute.classes([ + #("resource-vital", True), + #("warning", warning), + ]), + ], + [ + html.span([], [html.text(label)]), + html.strong([], [html.text(value)]), + html.small([], [html.text(detail)]), ], ) } -fn view_local_book_settings(model: Model) -> Element(Message) { - let sources = available_rule_sources(model) - html.section([attribute.class("local-book-settings")], [ - html.h3([], [html.text("Character books")]), - html.p([], [ - html.text( - "Choose which books this browser can use for new character options. Game Master restrictions are applied in addition to these choices.", - ), - ]), - html.div( - [attribute.class("local-book-list")], - list.map(sources, fn(source) { - let blocked = list.contains(model.local_blocked_sources, source) - html.label([attribute.class("local-book-option")], [ - html.input([ - attribute.type_("checkbox"), - attribute.checked(!blocked), - event.on_check(fn(_) { UserToggledLocalBlockedSource(source) }), - ]), - html.span([], [ - html.strong([], [html.text(rule_source_name(source))]), - html.small([], [ - html.text( - source - <> case blocked { - True -> " · Disabled" - False -> " · Enabled" - }, - ), - ]), - ]), - ]) - }), - ), - ]) +fn total_spell_slots_ready(model: Model) -> Int { + [1, 2, 3, 4, 5, 6, 7, 8, 9] + |> list.fold(0, fn(total, level) { + total + + int.max( + 0, + spell_slot_maximum(model, level) - spell_slot_spent(model, level), + ) + }) } -fn view_delete_confirmation(model: Model) -> Element(Message) { - let character_name = case - list.find(model.characters, fn(character) { - character.id == model.pending_delete_character_id +fn total_class_resources_ready(model: Model) -> Int { + let core_total = + class_resource_definitions(model) + |> list.fold(0, fn(total, resource) { + total + int.max(0, resource.maximum - resource_spent(model, resource.id)) }) - { - Ok(character) -> character_display_name(character) - Error(Nil) -> "this character" - } - html.div([attribute.class("health-config-layer delete-confirmation-layer")], [ - html.aside( - [ - attribute.class("health-config-modal delete-confirmation-modal"), - attribute.role("alertdialog"), - attribute.aria_label("Confirm character deletion"), - ], - [ - html.span([attribute.class("eyebrow")], [ - html.text("DESTRUCTIVE ACTION"), - ]), - html.h2([], [html.text("Delete " <> character_name <> "?")]), - html.p([], [ - html.text( - "This permanently removes the character from this browser. This action cannot be undone.", - ), - ]), - html.div([attribute.class("health-choice-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserCancelledCharacterDeletion), - ], - [html.text("Cancel")], - ), + model.custom_resources + |> list.fold(core_total, fn(total, resource) { + total + int.max(0, resource.maximum - resource.spent) + }) +} + +fn view_resource_spell_slots(model: Model) -> Element(Message) { + let spell_slots = + [1, 2, 3, 4, 5, 6, 7, 8, 9] + |> list.filter(fn(level) { spell_slot_maximum(model, level) > 0 }) + + resource_section( + "SPELLCASTING", + case model.class_name { + "Warlock" -> "Pact Magic" + _ -> "Spell slots" + }, + "Spend a slot when a spell is cast. Cantrips never consume one.", + case spell_slots { + [] -> + html.div([attribute.class("resource-empty-state")], [ + html.span([], [html.text("◇")]), + html.strong([], [html.text("No spell slots at this level")]), + html.p([], [ + html.text( + "Spell-slot pools appear here when your class progression grants them.", + ), + ]), + ]) + levels -> + html.div( + [attribute.class("resource-pool-grid")], + list.map(levels, fn(level) { + view_resource_pool( + "slot:" <> int.to_string(level), + "Level " <> int.to_string(level), + spell_slot_maximum(model, level), + spell_slot_spent(model, level), + case model.class_name { + "Warlock" -> "Pact slot" + _ -> "Long rest" + }, + UserSpentSpellSlot(level), + UserRestoredSpellSlot(level), + False, + ) + }), + ) + }, + ) +} + +fn view_resource_class_pools(model: Model) -> Element(Message) { + let resources = class_resource_definitions(model) + let pool_elements = + list.append( + list.map(resources, fn(resource) { + view_resource_pool( + resource.id, + resource.name, + resource.maximum, + resource_spent(model, resource.id), + resource.source + <> case resource.die_sides { + 0 -> "" + sides -> " · d" <> int.to_string(sides) + }, + UserSpentResource(resource.id), + UserRestoredResource(resource.id), + False, + ) + }), + list.map(model.custom_resources, fn(resource) { + view_resource_pool( + resource.id, + resource.name, + resource.maximum, + resource.spent, + "Custom · " <> recovery_label(resource.recovery), + UserSpentResource(resource.id), + UserRestoredResource(resource.id), + True, + ) + }), + ) + + resource_section( + "LIMITED-USE FEATURES", + "Class resources", + "Track class features and manual pools without inferring limits from rules prose.", + html.div([], [ + case pool_elements { + [] -> + html.div([attribute.class("resource-empty-state compact")], [ + html.strong([], [html.text("No class pools configured")]), + html.p([], [ + html.text("Add a custom counter for any limited-use feature."), + ]), + ]) + elements -> html.div([attribute.class("resource-pool-grid")], elements) + }, + html.form( + [ + attribute.class("resource-custom-form"), + event.on_submit(fn(_) { UserAddedCustomResource }), + ], + [ + html.div([], [ + html.label([], [html.text("RESOURCE NAME")]), + html.input([ + attribute.type_("text"), + attribute.placeholder("Arcane Recovery"), + attribute.value(model.custom_resource_name), + event.on_input(UserChangedCustomResourceName), + ]), + ]), + html.div([], [ + html.label([], [html.text("MAXIMUM")]), + html.input([ + attribute.type_("number"), + attribute.min("1"), + attribute.max("999"), + attribute.value(model.custom_resource_maximum), + event.on_input(UserChangedCustomResourceMaximum), + ]), + ]), + html.div([], [ + html.label([], [html.text("RECOVERY")]), + html.select( + [ + attribute.value(model.custom_resource_recovery), + event.on_change(UserChangedCustomResourceRecovery), + ], + [ + html.option([attribute.value("long")], "Long rest"), + html.option([attribute.value("short-all")], "Short rest"), + html.option([attribute.value("short-1")], "+1 on short rest"), + html.option([attribute.value("manual")], "Manual"), + ], + ), + ]), html.button( [ - attribute.type_("button"), - attribute.class("danger-button"), - event.on_click(UserConfirmedCharacterDeletion), + attribute.type_("submit"), + attribute.class("resource-add-button"), ], - [html.text("Delete character")], + [html.text("Add resource +")], ), - ]), - ], - ), + ], + ), + ]), + ) +} + +fn resource_section( + overline: String, + title: String, + description: String, + content: Element(Message), +) -> Element(Message) { + html.section([attribute.class("resource-ledger-section")], [ + html.div([attribute.class("resource-section-heading")], [ + html.div([], [ + html.span([], [html.text(overline)]), + html.h2([], [html.text(title)]), + html.p([], [html.text(description)]), + ]), + ]), + content, ]) } -fn view_character_library(model: Model) -> Element(Message) { - let characters = case model.has_character { - True -> upsert_character(model.characters, model_to_stored_character(model)) - False -> model.characters +fn view_resource_pool( + id: String, + name: String, + maximum: Int, + spent: Int, + source: String, + spend: Message, + restore: Message, + removable: Bool, +) -> Element(Message) { + let remaining = int.max(0, maximum - spent) + let percentage = case maximum > 0 { + True -> remaining * 100 / maximum + False -> 0 } - html.aside([attribute.class("character-library")], [ - html.div([attribute.class("library-heading")], [ + + html.article([attribute.class("resource-pool-card")], [ + html.div([attribute.class("resource-pool-heading")], [ html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("LOCAL LIBRARY")]), - html.h2([], [html.text("Characters")]), + html.strong([], [html.text(name)]), + html.small([], [html.text(source)]), ]), + case removable { + True -> + html.button( + [ + attribute.type_("button"), + attribute.class("resource-delete-button"), + attribute.aria_label("Delete " <> name), + event.on_click(UserRemovedCustomResource(id)), + ], + [html.text("×")], + ) + False -> html.text("") + }, + ]), + html.div([attribute.class("resource-pool-value")], [ + html.strong([], [html.text(int.to_string(remaining))]), + html.span([], [html.text(" / " <> int.to_string(maximum))]), + ]), + html.div([attribute.class("resource-pool-progress")], [ + html.span( + [attribute.style("width", int.to_string(percentage) <> "%")], + [], + ), + ]), + html.div([attribute.class("resource-pool-actions")], [ html.button( [ attribute.type_("button"), - attribute.aria_label("Close character library"), - event.on_click(UserToggledCharacterLibrary), + attribute.aria_label("Spend " <> name), + attribute.disabled(spent >= maximum), + event.on_click(spend), ], - [html.text("×")], - ), - ]), - html.div( - [attribute.class("character-list")], - list.map(characters, fn(character) { - let active = character.id == model.character_id - html.article( - [ - attribute.classes([ - #("character-list-item", True), - #("active", active), - ]), - ], - [ - html.button( - [ - attribute.type_("button"), - attribute.class("character-switch"), - event.on_click(UserSelectedCharacter(character.id)), - ], - [ - html.strong([], [html.text(character_display_name(character))]), - html.span([], [ - html.text( - "L" - <> int.to_string(character.level) - <> " · " - <> character.class_name - <> " · " - <> character.species_name, - ), - ]), - ], - ), - html.div([attribute.class("character-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.aria_label( - "Duplicate " <> character_display_name(character), - ), - event.on_click(UserDuplicatedCharacter(character.id)), - ], - [html.text("Duplicate")], - ), - html.button( - [ - attribute.type_("button"), - attribute.aria_label( - "Delete " <> character_display_name(character), - ), - event.on_click(UserDeletedCharacter(character.id)), - ], - [html.text("Delete")], - ), - ]), - ], - ) - }), - ), - case model.import_status { - "" -> html.text("") - status -> html.p([attribute.class("import-status")], [html.text(status)]) - }, - html.div([attribute.class("library-primary-actions")], [ - html.button( - [attribute.type_("button"), event.on_click(UserCreatedCharacter)], - [html.text("+ New character")], + [html.text("− Spend")], ), html.button( - [attribute.type_("button"), event.on_click(UserClickedImport)], - [html.text("Import JSON")], + [ + attribute.type_("button"), + attribute.aria_label("Restore " <> name), + attribute.disabled(spent == 0), + event.on_click(restore), + ], + [html.text("+ Restore")], ), ]), - case model.has_character { - False -> html.text("") - True -> - html.div([attribute.class("library-export-actions")], [ - html.button( - [ - attribute.type_("button"), - event.on_click(UserClickedExportCharacter), - ], - [html.text("Export current")], - ), + ]) +} + +fn view_resource_status(model: Model) -> Element(Message) { + html.section([attribute.class("resource-side-card resource-status-card")], [ + html.div([attribute.class("resource-side-heading")], [ + html.span([], [html.text("STATUS")]), + html.h2([], [html.text("Character state")]), + html.p([], [ + html.text( + "Persistent effects remain on your sheet and synchronize with the table.", + ), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.classes([ + #("resource-status-toggle", True), + #("active", model.inspiration), + ]), + attribute.aria_pressed(case model.inspiration { + True -> "true" + False -> "false" + }), + event.on_click(UserToggledInspiration), + ], + [ + html.span([], [html.text("INSPIRATION")]), + html.strong([], [ + html.text(case model.inspiration { + True -> "Ready to spend" + False -> "Spent" + }), + ]), + ], + ), + html.div([attribute.class("resource-concentration")], [ + html.div([], [ + html.span([], [html.text("CONCENTRATION")]), + html.strong([], [ + html.text(case model.concentration { + "" -> "No active spell" + spell -> spell + }), + ]), + ]), + case model.concentration { + "" -> html.text("") + _ -> html.button( [ attribute.type_("button"), - event.on_click(UserClickedExportLibrary), + attribute.aria_label("Clear concentration"), + event.on_click(UserClearedConcentration), ], - [html.text("Export all")], - ), - ]) - }, + [html.text("Clear")], + ) + }, + ]), + view_resource_death_saves(model), + view_resource_conditions(model), ]) } -fn view_collaboration(model: Model) -> Element(Message) { - let content = case model.connection { - Disconnected -> view_connect_form(model, "") - ConnectionFailed(reason:) -> view_connect_form(model, reason) - Connecting(role:) -> - html.div([attribute.class("connection-wait")], [ - html.span([attribute.class("connection-spinner")], []), +fn view_resource_death_saves(model: Model) -> Element(Message) { + case effective_current_hit_points(model) { + 0 -> + html.div([attribute.class("resource-death-saves")], [ html.div([], [ - html.strong([], [html.text("Opening encrypted table…")]), - html.p([], [ - html.text(case role { - GameMaster -> "Creating an Iroh gossip session" - Player -> "Joining through the Iroh relay" - }), + html.span([], [html.text("DEATH SAVES")]), + html.strong([], [ + html.text( + int.to_string(model.death_save_successes) + <> " successes · " + <> int.to_string(model.death_save_failures) + <> " failures", + ), ]), ]), - ]) - Connected(role:, ticket:, peer_count:) -> - html.div([], [ - html.div([attribute.class("session-bar")], [ - html.div([attribute.class("session-state")], [ - html.span([], []), - html.div([], [ - html.strong([], [ - html.text(case role { - GameMaster -> "Hosting table" - Player -> "Connected to table" - }), - ]), - html.small([], [ - html.text( - int.to_string(peer_count) - <> case peer_count { - 1 -> " peer connected" - _ -> " peers connected" - }, - ), - ]), - ]), - ]), + html.div([attribute.class("resource-death-save-actions")], [ html.button( [ attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserClickedLeave), + event.on_click(UserRecordedDeathSaveSuccess), ], - [html.text("Leave table")], + [html.text("+ Success")], ), - ]), - case role { - GameMaster -> - html.div([attribute.class("table-invite-link")], [ - html.span([], [html.text("Player invite link")]), - html.a( - [ - attribute.href(browser.invite_link(ticket)), - attribute.target("_blank"), - attribute.rel("noopener noreferrer"), - ], - [html.text(browser.invite_link(ticket))], - ), - ]) - Player -> html.text("") - }, - view_remote_sheets(model), - ]) - } - - let panel = "collaboration" - let collapsed = live_table_panel_collapsed(model, panel) - html.section( - [ - attribute.classes([ - #("panel", True), - #("collaboration-panel", True), - #("collapsible-panel", True), - #("collapsed", collapsed), - ]), - ], - [ - collapsible_section_heading( - model, - panel, - "00", - "Live table", - "End-to-end encrypted character sharing over Iroh", - ), - content, - ], - ) -} - -fn view_health_configuration(model: Model) -> Element(Message) { - let maximum = character_hit_points(model) - let available_hit_dice = case model.level - model.hit_dice_spent { - available if available < 0 -> 0 - available -> available - } - html.div([attribute.class("health-config-layer")], [ - html.aside( - [ - attribute.class("health-config-modal"), - attribute.role("dialog"), - attribute.aria_label("Hit point configuration"), - ], - [ - html.div([attribute.class("health-config-heading")], [ - html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("Health")]), - html.h2([], [html.text("Hit point configuration")]), - html.p([], [ - html.text( - int.to_string(effective_current_hit_points(model)) - <> " current · " - <> int.to_string(maximum) - <> " maximum · " - <> int.to_string(available_hit_dice) - <> " Hit Dice available", - ), - ]), - ]), html.button( [ attribute.type_("button"), - attribute.class("health-config-close"), - attribute.aria_label("Close hit point configuration"), - event.on_click(UserClosedHealthConfiguration), + event.on_click(UserRecordedDeathSaveFailure(1)), ], - [html.text("×")], - ), - ]), - html.div([attribute.class("health-config-grid")], [ - resource_control( - "CURRENT HP", - effective_current_hit_points(model), - UserChangedCurrentHitPoints, - ), - resource_control( - "TEMP HP", - model.temporary_hit_points, - UserChangedTemporaryHitPoints, + [html.text("− Failure")], ), - resource_control( - "HIT DICE SPENT", - model.hit_dice_spent, - UserChangedHitDiceSpent, + html.button( + [ + attribute.type_("button"), + event.on_click(UserRecordedDeathSaveFailure(2)), + ], + [html.text("Natural 1")], ), - resource_control( - "MAX HP OVERRIDE", - model.max_hit_points_override, - UserChangedMaxHitPoints, + html.button( + [ + attribute.type_("button"), + event.on_click(UserRecordedDeathSaveNaturalTwenty), + ], + [html.text("Natural 20")], ), - ]), - html.p([attribute.class("health-config-help")], [ - html.text( - "Set maximum HP override to 0 to use the automatic class, level, Constitution, and feat calculation.", + html.button( + [ + attribute.type_("button"), + event.on_click(UserResetDeathSaves), + ], + [html.text("Reset")], ), ]), - ], + ]) + _ -> html.text("") + } +} + +fn view_resource_conditions(model: Model) -> Element(Message) { + html.div([attribute.class("resource-conditions")], [ + html.div([attribute.class("resource-condition-heading")], [ + html.span([], [html.text("CONDITIONS")]), + html.strong([], [ + html.text(case model.conditions { + [] -> "None active" + conditions -> int.to_string(list.length(conditions)) <> " active" + }), + ]), + ]), + html.div( + [attribute.class("resource-condition-list")], + model.conditions + |> list.filter(fn(condition) { condition.name != "Exhaustion" }) + |> list.map(fn(condition) { + html.span([], [ + html.text(condition.name), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Remove " <> condition.name), + event.on_click(UserRemovedCondition(condition.name)), + ], + [html.text("×")], + ), + ]) + }), ), + html.div([attribute.class("resource-condition-controls")], [ + html.select( + [ + attribute.value(model.condition_choice), + event.on_change(UserChangedConditionChoice), + ], + [ + html.option([attribute.value("")], "Add condition…"), + ..list.map(standard_condition_names(), fn(name) { + html.option( + [ + attribute.value(name), + attribute.disabled( + list.any(model.conditions, fn(condition) { + condition.name == name + }), + ), + ], + name, + ) + }) + ], + ), + html.button( + [ + attribute.type_("button"), + attribute.disabled(model.condition_choice == ""), + event.on_click(UserAddedCondition), + ], + [html.text("Apply")], + ), + ]), + html.label([attribute.class("resource-exhaustion")], [ + html.span([], [html.text("EXHAUSTION")]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.max("6"), + attribute.value(int.to_string(exhaustion_level(model))), + event.on_input(UserChangedExhaustion), + ]), + html.small([], [ + html.text(case exhaustion_level(model) { + 0 -> "No exhaustion penalty" + level -> + "−" + <> int.to_string(level * 2) + <> " to D20 Tests · speed −" + <> int.to_string(level * 5) + <> " ft" + }), + ]), + ]), ]) } -fn view_health_choice(model: Model, level: Int) -> Element(Message) { - let hit_die = class_hit_die(model.class_name) - let average = hit_die_average(hit_die) - let constitution = modifier(effective_ability_score(model, Constitution)) - html.aside( - [ - attribute.class("health-choice-window"), - attribute.role("dialog"), - attribute.aria_label("Choose health increase"), - ], - [ - html.span([attribute.class("eyebrow")], [html.text("LEVEL UP")]), - html.h2([], [html.text("Level " <> int.to_string(level) <> " health")]), +fn view_resource_recovery(model: Model) -> Element(Message) { + let available_hit_dice = int.max(0, model.level - model.hit_dice_spent) + html.section([attribute.class("resource-side-card resource-recovery-card")], [ + html.div([attribute.class("resource-side-heading")], [ + html.span([], [html.text("RECOVERY")]), + html.h2([], [html.text("Rest and restore")]), html.p([], [ html.text( - "Choose the fixed average or roll your d" - <> int.to_string(hit_die) - <> " Hit Die. Your " - <> signed(constitution) - <> " Constitution modifier is applied automatically.", + "Every rest opens a consequence preview before changing your character.", ), ]), - html.div([attribute.class("health-choice-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserUsedAverageHealth), - ], - [ - html.strong([], [ - html.text( - "Use average · +" - <> int.to_string(health_gain(average, constitution)), - ), - ]), - html.small([], [ - html.text(int.to_string(average) <> signed(constitution)), - ]), - ], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("primary-button"), - event.on_click(UserOpenedHealthRoll), - ], - [ - html.strong([], [ - html.text("Roll d" <> int.to_string(hit_die)), - ]), - html.small([], [html.text("Minimum 1 HP after modifier")]), - ], + ]), + html.div([attribute.class("resource-recovery-readout")], [ + html.span([], [html.text("HIT DICE AVAILABLE")]), + html.strong([], [ + html.text( + int.to_string(available_hit_dice) + <> " / " + <> int.to_string(model.level), ), ]), - ], - ) -} - -fn view_dice_window(model: Model) -> Element(Message) { - let dice_count = parse_bounded(model.dice_count, 1, 1, 20) - let configured_roll = case model.dice_target { - HealthRoll(level: _) -> True - InitiativeRoll(entry_id: _) -> True - TableRoll -> False - } - let supports_roll_mode = model.dice_sides == 20 && dice_count == 1 - html.div([attribute.class("dice-window-layer")], [ - html.section( + ]), + html.button( [ - attribute.class("dice-window"), - attribute.role("dialog"), - attribute.aria_label("Dice roller"), + attribute.type_("button"), + attribute.class("resource-recovery-action"), + event.on_click(UserOpenedRest(ShortRest)), ], [ - html.div([attribute.class("dice-window-heading")], [ - html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("DICE")]), - html.h2([], [html.text("Dice roller")]), + html.div([], [ + html.strong([], [html.text("Short rest")]), + html.small([], [ + html.text("Spend Hit Dice · recover short-rest pools"), ]), - html.button( - [ - attribute.type_("button"), - attribute.aria_label("Close dice roller"), - event.on_click(UserClosedDiceRoll), - ], - [html.text("×")], - ), ]), - case supports_roll_mode { - True -> - html.div( - [ - attribute.class("roll-mode-group"), - attribute.aria_label("Roll mode"), - ], - [ - roll_mode_button("Normal", NormalRoll, model.dice_roll_mode), - roll_mode_button( - "Advantage", - AdvantageRoll, - model.dice_roll_mode, - ), - roll_mode_button( - "Disadvantage", - DisadvantageRoll, - model.dice_roll_mode, - ), - ], - ) - False -> html.text("") - }, - html.div([attribute.class("dice-window-controls")], [ - field( - "Dice", - html.input([ - attribute.class("text-input dice-count"), - attribute.type_("number"), - attribute.min("1"), - attribute.max("20"), - attribute.value(model.dice_count), - attribute.disabled(configured_roll), - event.on_input(UserChangedDiceCount), - ]), - ), - field( - "Die", - html.select( - [ - attribute.class("select-input dice-sides"), - attribute.disabled(configured_roll), - event.on_change(UserChangedDiceSides), - ], - list.map([4, 6, 8, 10, 12, 20, 100], fn(sides) { - html.option( - [ - attribute.value(int.to_string(sides)), - attribute.selected(model.dice_sides == sides), - ], - "d" <> int.to_string(sides), - ) - }), - ), - ), - field( - "Modifier", - html.input([ - attribute.class("text-input dice-modifier"), - attribute.type_("number"), - attribute.min("-99"), - attribute.max("99"), - attribute.value(model.dice_modifier), - attribute.disabled(configured_roll), - event.on_input(UserChangedDiceModifier), - ]), - ), - field( - "What is this roll for?", - html.input([ - attribute.class("text-input"), - attribute.placeholder("Perception, attack, saving throw…"), - attribute.value(model.dice_note), - attribute.disabled(configured_roll), - event.on_input(UserChangedDiceNote), - ]), - ), - ]), - view_secret_roll_controls(model), - case supports_roll_mode, model.inspiration { - True, True -> - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button inspiration-roll-button"), - event.on_click(UserSpentInspirationOnRoll), - ], - [html.text("Spend Inspiration for Advantage")], - ) - _, _ -> html.text("") - }, - html.button( - [ - attribute.type_("button"), - attribute.class("primary-button dice-roll-button"), - event.on_click(UserRolledDice), - ], - [ - html.text( - "Roll " - <> int.to_string(dice_count) - <> "d" - <> int.to_string(model.dice_sides) - <> case supports_roll_mode { - True -> " · " <> roll_mode_name(model.dice_roll_mode) - False -> "" - } - <> case int.parse(model.dice_modifier) { - Ok(0) -> "" - Ok(modifier) -> " " <> signed(bounded_modifier(modifier)) - Error(_) -> "" - }, - ), - ], - ), - html.div([attribute.class("dice-window-history")], [ - html.div([attribute.class("dice-window-history-heading")], [ - html.strong([], [html.text("Roll history")]), - html.span([], [ - html.text( - int.to_string(list.length(model.dice_rolls)) <> " rolls", - ), - ]), + html.span([], [html.text("→")]), + ], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("resource-recovery-action"), + event.on_click(UserOpenedRest(LongRest)), + ], + [ + html.div([], [ + html.strong([], [html.text("Long rest")]), + html.small([], [ + html.text("Restore HP, Hit Dice, spell slots, and long-rest pools"), ]), - case model.dice_rolls { - [] -> - html.p([attribute.class("dice-empty")], [ - html.text("Rolls from everyone at the table will appear here."), - ]) - rolls -> - html.div( - [attribute.class("dice-history"), attribute.aria_live("polite")], - list.map(rolls, view_dice_roll), - ) - }, ]), + html.span([], [html.text("→")]), ], ), ]) } -fn view_secret_roll_controls(model: Model) -> Element(Message) { - case model.dice_target { - HealthRoll(level: _) -> html.text("") - InitiativeRoll(entry_id: _) -> html.text("") - TableRoll -> - html.div([attribute.class("secret-roll-controls")], [ - html.label([], [ - html.input([ - attribute.type_("checkbox"), - attribute.checked(model.dice_secret), - event.on_check(fn(_) { UserToggledDiceSecret }), - ]), - html.span([], [ - html.strong([], [ - html.text(case model.connection { - Connected(role: GameMaster, ..) -> "Secret roll" - Connected(role: Player, ..) -> "Secret roll" - _ -> "Keep roll private" - }), - ]), - html.small([], [ - html.text("Only you see the result unless you share it."), - ]), +fn view_resource_prepared_spells(model: Model) -> Element(Message) { + let prepared_spells = + model.spells + |> list.filter(fn(spell) { spell.prepared }) + |> list.sort(fn(left, right) { + case int.compare(left.level, right.level) { + order.Eq -> string.compare(left.name, right.name) + ordering -> ordering + } + }) + + resource_section( + "PREPARED MAGIC", + "Spells ready to cast", + "Open the casting workflow here; the Live Table remains focused on turn order.", + case prepared_spells { + [] -> + html.div([attribute.class("resource-empty-state compact")], [ + html.strong([], [html.text("No prepared spells")]), + html.p([], [ + html.text("Prepare spells in the Spellbook, then cast them here."), ]), - ]), - case model.dice_secret, model.connection { - True, Connected(role: Player, ..) -> - html.label([], [ - html.input([ - attribute.type_("checkbox"), - attribute.checked(model.dice_share_with_master), - event.on_check(fn(_) { UserToggledDiceShareWithMaster }), - ]), - html.span([], [ - html.strong([], [html.text("Share with Game Master")]), - html.small([], [ - html.text("Other players will not receive this roll."), + ]) + spells -> + html.div( + [attribute.class("resource-prepared-list")], + list.map(spells, fn(spell) { + html.article([], [ + html.div([], [ + html.span([], [ + html.text(case spell.level { + 0 -> "CANTRIP" + level -> "LEVEL " <> int.to_string(level) + }), ]), + html.strong([], [html.text(spell.name)]), ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Cast " <> spell.name), + event.on_click(UserOpenedSpellCast(spell.name, spell.level)), + ], + [html.text("Cast →")], + ), ]) - _, _ -> html.text("") - }, - ]) + }), + ) + }, + ) +} + +fn two_digits(value: Int) -> String { + case value < 10 { + True -> "0" <> int.to_string(value) + False -> int.to_string(value) } } -fn roll_mode_button( - label: String, - mode: RollMode, - selected_mode: RollMode, -) -> Element(Message) { - let selected = mode == selected_mode - html.button( +fn view_saved_players_workspace(model: Model) -> Element(Message) { + html.main( + [attribute.class("workspace table-workspace saved-players-workspace")], [ - attribute.type_("button"), - attribute.classes([#("selected", selected)]), - attribute.aria_pressed(case selected { - True -> "true" - False -> "false" - }), - event.on_click(UserSelectedRollMode(mode)), + html.div([attribute.class("table-main-column")], [ + html.section([attribute.class("panel saved-player-library")], [ + html.div([attribute.class("saved-player-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [ + html.text("OFFLINE LIBRARY"), + ]), + html.h2([], [html.text("Saved player characters")]), + html.p([], [ + html.text( + "Save characters from a live table to inspect them offline. Their copies sync automatically when you meet again at the same table.", + ), + ]), + ]), + html.strong([], [ + html.text( + int.to_string(list.length(model.saved_player_characters)), + ), + ]), + ]), + case model.saved_player_characters { + [] -> + html.div([attribute.class("saved-player-empty")], [ + html.h3([], [html.text("No saved players yet")]), + html.p([], [ + html.text( + "Open a connected character on the Live Table tab and choose Save for offline.", + ), + ]), + ]) + characters -> + html.div( + [attribute.class("saved-player-grid")], + list.map(characters, fn(character) { + view_saved_player_card(model, character) + }), + ) + }, + ]), + view_saved_player_inspector(model), + ]), ], - [html.text(label)], ) } -fn view_dice_roll(roll: DiceRoll) -> Element(Message) { - html.article([attribute.class("dice-roll")], [ - html.div([attribute.class("dice-roll-heading")], [ - html.strong([], [html.text(roll.author)]), +fn view_saved_player_card( + model: Model, + character: SavedPlayerCharacter, +) -> Element(Message) { + let snapshot = character.snapshot + let name = snapshot_display_name(snapshot) + let selected = character.id == model.inspected_saved_player_character_id + let live = saved_player_is_live(model, character) + html.article([attribute.class("saved-player-entry")], [ + html.button( + [ + attribute.type_("button"), + attribute.classes([ + #("remote-card", True), + #("selected", selected), + ]), + attribute.aria_pressed(case selected { + True -> "true" + False -> "false" + }), + event.on_click(UserInspectedSavedPlayerCharacter(character.id)), + ], + [ + html.div([attribute.class("remote-avatar")], [ + html.text(string.slice(name, at_index: 0, length: 1)), + ]), + html.div([], [ + html.strong([], [html.text(name)]), + html.span([], [ + html.text( + snapshot.owner + <> " · L" + <> int.to_string(snapshot.level) + <> " " + <> snapshot.class_name, + ), + ]), + ]), + html.div([attribute.class("remote-vitals")], [ + html.span([], [ + html.text("AC " <> int.to_string(snapshot.armor_class)), + ]), + html.span([], [ + html.text("HP " <> int.to_string(snapshot.hit_points)), + ]), + ]), + ], + ), + html.div([attribute.class("saved-player-meta")], [ + html.span( + [ + attribute.classes([ + #("saved-player-status", True), + #("live", live), + ]), + ], + [ + html.text(case live { + True -> "LIVE · SYNCHRONIZED" + False -> "OFFLINE COPY" + }), + ], + ), html.span([], [ html.text( - roll.mode <> " · " <> browser.format_timestamp(roll.created_at), + "Last synced " <> browser.format_timestamp(character.last_synced_at), ), ]), - ]), - case roll.audience { - "secret" -> - html.span([attribute.class("dice-audience-badge")], [ - html.text("Secret"), - ]) - "gm" -> - html.span([attribute.class("dice-audience-badge")], [ - html.text("Shared with GM"), - ]) - _ -> html.text("") - }, - case roll.note { - "" -> html.text("") - note -> html.p([attribute.class("dice-roll-note")], [html.text(note)]) - }, - html.div([attribute.class("dice-roll-result")], [ - html.span([], [ - html.text(dice_roll_expression(roll)), - ]), - html.span([], [ - html.text(case roll.modifier { - 0 -> "" - modifier -> signed(modifier) - }), - ]), - html.strong([], [html.text(int.to_string(roll.total))]), - ]), - ]) -} - -fn dice_roll_expression(roll: DiceRoll) -> String { - let expression = - int.to_string(roll.dice_count) <> "d" <> int.to_string(roll.dice_sides) - let values = - roll.results - |> list.map(int.to_string) - |> string.join(", ") - case roll.mode, roll.results { - "Advantage", [_, _] -> - expression <> " [" <> values <> "] → " <> int.to_string(roll.kept) - "Disadvantage", [_, _] -> - expression <> " [" <> values <> "] → " <> int.to_string(roll.kept) - _, [_] -> expression <> " [" <> values <> "]" - _, _ -> expression <> " [" <> values <> "]" - } -} - -fn view_connect_form(model: Model, error: String) -> Element(Message) { - html.div([], [ - html.div([attribute.class("peer-name-row")], [ - field( - "Display name", - html.input([ - attribute.class("text-input"), - attribute.placeholder("How the table sees you"), - attribute.value(model.peer_name), - event.on_input(UserChangedPeerName), - ]), - ), html.button( [ attribute.type_("button"), - attribute.class("primary-button"), - event.on_click(UserClickedHost), + attribute.aria_label("Remove saved copy of " <> name), + event.on_click(UserRemovedSavedPlayerCharacter(character.id)), ], - [html.text("Host as Game Master")], + [html.text("Remove")], ), ]), - html.div([attribute.class("join-row")], [ - html.div([attribute.class("join-divider")], [ - html.span([], [html.text("OR JOIN A TABLE")]), - ]), - html.div([attribute.class("join-controls")], [ - html.input([ - attribute.class("text-input code-input"), - attribute.placeholder("Paste Iroh invite code"), - attribute.value(model.join_code), - attribute.aria_label("Iroh table invite code"), - event.on_input(UserChangedJoinCode), - ]), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button join-button"), - event.on_click(UserClickedJoin), - ], - [html.text("Join as Player")], - ), - ]), - ]), - case error { - "" -> - html.span([attribute.class("peer-note")], [ - html.text( - "Iroh browser peers use a relay while remaining end-to-end encrypted.", - ), - ]) - reason -> - html.p([attribute.class("connection-error")], [html.text(reason)]) - }, ]) } -fn view_remote_sheets(model: Model) -> Element(Message) { - case model.remote_sheets { - [] -> - html.p([attribute.class("empty-roster")], [ - html.text("Connected character sheets will appear here."), - ]) - snapshots -> - html.div([attribute.class("remote-roster")], [ - html.div([attribute.class("roster-heading")], [ - html.span([attribute.class("roster-label")], [ - html.text("TABLE CHARACTERS"), - ]), - case model.connection, model.inspected_owner { - Connected(role: Player, ..), owner if owner != "" -> - html.button( - [ - attribute.type_("button"), - event.on_click(UserInspectedCharacter("")), - ], - [html.text("Return to your sheet")], - ) - _, _ -> html.text("") - }, - ]), - html.div( - [attribute.class("remote-grid")], - list.map(snapshots, fn(snapshot) { - let name = snapshot_display_name(snapshot) - let selected = snapshot.owner == model.inspected_owner - html.button( - [ - attribute.type_("button"), - attribute.classes([ - #("remote-card", True), - #("selected", selected), - ]), - attribute.aria_pressed(case selected { - True -> "true" - False -> "false" - }), - event.on_click(UserInspectedCharacter(snapshot.owner)), - ], - [ - html.div([attribute.class("remote-avatar")], [ - html.text(string.slice(name, at_index: 0, length: 1)), - ]), - html.div([], [ - html.strong([], [html.text(name)]), - html.span([], [ - html.text( - snapshot.owner - <> " · L" - <> int.to_string(snapshot.level) - <> " " - <> snapshot.class_name, - ), - ]), - ]), - html.div([attribute.class("remote-vitals")], [ - html.span([], [ - html.text("AC " <> int.to_string(snapshot.armor_class)), - ]), - html.span([], [ - html.text( - "HP " - <> int.to_string(snapshot.current_hit_points) - <> "/" - <> int.to_string(snapshot.hit_points), - ), - ]), - ]), - html.div( - [attribute.class("remote-resource-summary")], - list.append( - list.map(list.take(snapshot.resources, 5), fn(resource) { - html.span([], [ - html.text( - resource.name - <> " " - <> int.to_string(resource.current) - <> "/" - <> int.to_string(resource.maximum), - ), - ]) - }), - case snapshot.concentration { - "" -> [] - _ -> [html.span([], [html.text("Concentrating")])] - }, - ), - ), - ], - ) - }), - ), - ]) +fn saved_player_is_live(model: Model, character: SavedPlayerCharacter) -> Bool { + case connection_ticket(model.connection) { + Error(Nil) -> False + Ok(table_ticket) -> + character.table_ticket == table_ticket + && list.any(model.remote_sheets, fn(snapshot) { + snapshot.owner == character.snapshot.owner + }) } } -fn view_inspected_character(model: Model) -> Element(Message) { +fn view_saved_player_inspector(model: Model) -> Element(Message) { case - list.find(model.remote_sheets, fn(snapshot) { - snapshot.owner == model.inspected_owner + list.find(model.saved_player_characters, fn(character) { + character.id == model.inspected_saved_player_character_id }) { - Error(Nil) -> - html.aside([attribute.class("sheet master-inspector empty-inspector")], [ - html.div([attribute.class("sheet-accent")], []), - html.div([attribute.class("inspector-empty-state")], [ - html.span([attribute.class("eyebrow")], [ - html.text("Game Master view"), - ]), - html.h2([], [html.text("Waiting for characters")]), - html.p([], [ - html.text( - "Share the invite code with players. Select a character here when they join.", - ), - ]), - ]), - ]) - Ok(snapshot) -> - html.div([attribute.class("inspector-stack")], [ - html.div([attribute.class("live-inspector-actions")], [ - case model.connection { - Connected(role: Player, ..) -> - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserInspectedCharacter("")), - ], - [html.text("Return to your sheet")], - ) - _ -> html.text("") - }, + Error(Nil) -> html.text("") + Ok(character) -> + html.div([attribute.class("inspector-stack saved-player-inspector")], [ + html.div([attribute.class("saved-player-inspector-actions")], [ html.button( [ attribute.type_("button"), attribute.class("secondary-button"), - event.on_click(UserSavedRemoteCharacter(snapshot.owner)), + event.on_click(UserInspectedSavedPlayerCharacter("")), ], + [html.text("Return to saved players")], + ), + html.button( [ - html.text(case remote_snapshot_is_saved(model, snapshot) { - True -> "Update offline copy" - False -> "Save for offline" - }), + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserRemovedSavedPlayerCharacter(character.id)), ], + [html.text("Remove saved copy")], ), ]), - view_snapshot(snapshot), + view_snapshot(character.snapshot), ]) } } -fn remote_snapshot_is_saved(model: Model, snapshot: Snapshot) -> Bool { - case connection_ticket(model.connection) { - Error(Nil) -> False - Ok(table_ticket) -> - list.any(model.saved_player_characters, fn(character) { - saved_player_matches(character, table_ticket, snapshot) - }) - } +fn spell_selection_incomplete(model: Model) -> Bool { + maximum_spell_level(model.class_name, model.subclass_name, model.level) > 0 + && list.is_empty(model.spells) } -fn portrait_content( - portrait_url: String, - display_name: String, -) -> Element(message) { - case portrait_url { - "" -> - html.span([attribute.class("portrait-initial")], [ - html.text(string.slice(display_name, at_index: 0, length: 1)), - ]) - portrait_url -> - html.img([ - attribute.class("portrait-image"), - attribute.src(portrait_url), - attribute.alt(display_name <> " portrait"), - ]) +fn feature_choice_value(choice: FeatureChoice, model: Model) -> String { + case choice.id { + "subclass" -> model.subclass_name + id -> feature_selection_value(model.feature_selections, id) } } -fn view_remote_portrait( - portrait_url portrait_url: String, - display_name display_name: String, - level level: Int, -) -> Element(message) { - html.div([attribute.class("portrait")], [ - portrait_content(portrait_url, display_name), - html.div([attribute.class("level-badge")], [ - html.small([], [html.text("LVL")]), - html.strong([], [html.text(int.to_string(level))]), - ]), - ]) -} - -fn view_character_portrait( - model: Model, - display_name: String, -) -> Element(Message) { - html.div([attribute.class("portrait editable-portrait")], [ - portrait_content(model.portrait_url, display_name), - html.div([attribute.class("portrait-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("portrait-upload-button"), - attribute.aria_label(case model.portrait_url { - "" -> "Add character portrait" - _ -> "Change character portrait" - }), - event.on_click(UserClickedPortrait), - ], - [ - html.text(case model.portrait_url { - "" -> "Add portrait" - _ -> "Change" - }), - ], - ), - case model.portrait_url { +fn view_rules_data_settings(model: Model) -> Element(Message) { + html.div( + [ + attribute.class("rules-data-settings"), + attribute.role("dialog"), + attribute.aria_label("Rules data source"), + ], + [ + html.div([attribute.class("rules-data-settings-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("DATA SOURCE")]), + html.h2([], [html.text("Rules data URL")]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Close rules data settings"), + event.on_click(UserToggledRulesDataSettings), + ], + [html.text("×")], + ), + ]), + html.p([], [ + html.text( + "Enter the base URL of a compatible rules data directory. It must contain races.json, backgrounds.json, feats.json, items-base.json, class/, generated/, and spells/.", + ), + ]), + html.label([attribute.class("rules-data-url-field")], [ + html.span([], [html.text("DATA DIRECTORY URL")]), + html.input([ + attribute.type_("url"), + attribute.class("text-input"), + attribute.placeholder("https://example.com/data/"), + attribute.value(model.rules_data_url_input), + event.on_input(UserChangedRulesDataUrl), + ]), + ]), + case model.rules_data_url_error { "" -> html.text("") - _ -> - html.button( - [ - attribute.type_("button"), - attribute.class("portrait-remove-button"), - attribute.aria_label("Remove character portrait"), - event.on_click(UserRemovedPortrait), - ], - [html.text("Remove")], - ) + reason -> + html.p([attribute.class("rules-data-error")], [html.text(reason)]) }, - ]), - case model.portrait_error { - "" -> html.text("") - reason -> - html.p([attribute.class("portrait-error"), attribute.role("alert")], [ - html.text(reason), - ]) - }, - html.div([attribute.class("level-badge")], [ - html.small([], [html.text("LVL")]), - html.strong([], [html.text(int.to_string(model.level))]), - ]), - ]) -} - -fn view_snapshot(snapshot: Snapshot) -> Element(Message) { - let display_name = snapshot_display_name(snapshot) - let class_identity = case snapshot.subclass_name { - "" -> snapshot.class_name - subclass -> snapshot.class_name <> " · " <> subclass - } - html.aside([attribute.class("sheet master-inspector")], [ - html.div([attribute.class("sheet-accent")], []), - view_remote_portrait( - portrait_url: snapshot.portrait_url, - display_name:, - level: snapshot.level, - ), - html.div([attribute.class("sheet-title inspector-title")], [ - html.div([attribute.class("inspector-heading")], [ - html.span([attribute.class("eyebrow")], [ - html.text("Character inspection"), - ]), - html.span([attribute.class("read-only-badge")], [html.text("READ ONLY")]), - ]), - html.h2([], [html.text(display_name)]), - html.p([], [ - html.text( - snapshot.species_name - <> " · " - <> snapshot.background_name - <> " · " - <> class_identity, + html.div([attribute.class("rules-data-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserChangedRulesDataUrl("")), + ], + [html.text("Clear")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserSavedRulesDataUrl), + ], + [html.text("Save & load")], ), ]), - html.small([attribute.class("inspector-owner")], [ - html.text("Played by " <> snapshot.owner), + html.small([], [ + html.text("The URL is stored only in this browser."), ]), - ]), - html.div([attribute.class("combat-grid inspector-combat-grid")], [ - stat_block( - "ARMOR CLASS", - int.to_string(snapshot.armor_class), - "Current defense", - ), - stat_block( - "HIT POINTS", - int.to_string(snapshot.current_hit_points) - <> " / " - <> int.to_string(snapshot.hit_points), - "Current / maximum", - ), - rollable_stat_block( - "INITIATIVE", - d20_test_modifier( - snapshot.initiative, - snapshot_exhaustion_level(snapshot), - ), - "Dexterity", - display_name <> " initiative", - ), - stat_block("SPEED", int.to_string(snapshot.speed) <> " ft", "Movement"), - ]), - html.div([attribute.class("inspector-quick-stats")], [ - snapshot_quick_stat("PROFICIENCY", signed(snapshot.proficiency_bonus)), - snapshot_quick_stat( - "PASSIVE PERCEPTION", - int.to_string(snapshot.passive_perception), + view_local_book_settings(model), + ], + ) +} + +fn view_local_book_settings(model: Model) -> Element(Message) { + let sources = available_rule_sources(model) + html.section([attribute.class("local-book-settings")], [ + html.h3([], [html.text("Character books")]), + html.p([], [ + html.text( + "Choose which books this browser can use for new character options. Game Master restrictions are applied in addition to these choices.", ), - snapshot_quick_stat("HIT DIE", "d" <> int.to_string(snapshot.hit_die)), - snapshot_quick_stat("SPELL SAVE", case snapshot.spell_save_dc { - 0 -> "—" - score -> int.to_string(score) - }), - snapshot_quick_stat("GOLD", int.to_string(snapshot.gold) <> " gp"), ]), - view_snapshot_resources(snapshot), - view_snapshot_abilities(snapshot), - view_snapshot_skills(snapshot), - snapshot_section( - "TOOL & OTHER PROFICIENCIES", - list.length(snapshot.other_proficiencies), - list.map(snapshot.other_proficiencies, fn(proficiency) { - html.span([attribute.class("inspector-proficiency")], [ - html.text(proficiency), + html.div( + [attribute.class("local-book-list")], + list.map(sources, fn(source) { + let blocked = list.contains(model.local_blocked_sources, source) + html.label([attribute.class("local-book-option")], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(!blocked), + event.on_check(fn(_) { UserToggledLocalBlockedSource(source) }), + ]), + html.span([], [ + html.strong([], [html.text(rule_source_name(source))]), + html.small([], [ + html.text( + source + <> case blocked { + True -> " · Disabled" + False -> " · Enabled" + }, + ), + ]), + ]), ]) }), ), - view_snapshot_choices(snapshot), - view_snapshot_features("ORIGIN FEATURES", snapshot.origin_features), - view_snapshot_features("CLASS FEATURES", snapshot.class_features), - view_snapshot_equipment(snapshot), - view_snapshot_feats(snapshot), - view_snapshot_spells(snapshot), ]) } -fn view_snapshot_resources(snapshot: Snapshot) -> Element(Message) { - let resources = - list.map(snapshot.resources, fn(resource) { - html.span([attribute.class("inspector-proficiency")], [ - html.text( - resource.name - <> " " - <> int.to_string(resource.current) - <> "/" - <> int.to_string(resource.maximum), - ), - ]) +fn view_delete_confirmation(model: Model) -> Element(Message) { + let character_name = case + list.find(model.characters, fn(character) { + character.id == model.pending_delete_character_id }) - let inspiration = case snapshot.inspiration { - True -> [ - html.span([attribute.class("inspector-proficiency")], [ - html.text("Inspiration"), - ]), - ] - False -> [] - } - let concentration = case snapshot.concentration { - "" -> [] - spell -> [ - html.span([attribute.class("inspector-proficiency")], [ - html.text("Concentrating: " <> spell), - ]), - ] + { + Ok(character) -> character_display_name(character) + Error(Nil) -> "this character" } - let conditions = - list.map(snapshot.conditions, fn(condition) { - html.span([attribute.class("inspector-proficiency")], [ - html.text(condition), - ]) - }) - let status = - list.append( - resources, - list.append(inspiration, list.append(concentration, conditions)), - ) - snapshot_section("PLAY RESOURCES", list.length(status), status) -} - -fn snapshot_quick_stat(label: String, value: String) -> Element(message) { - html.div([], [ - html.span([], [html.text(label)]), - html.strong([], [html.text(value)]), - ]) -} - -fn snapshot_section( - title: String, - count: Int, - content: List(Element(message)), -) -> Element(message) { - html.section([attribute.class("inspector-section")], [ - html.div([attribute.class("inspector-section-heading")], [ - html.h3([], [html.text(title)]), - html.span([], [html.text(int.to_string(count))]), - ]), - case content { - [] -> - html.p([attribute.class("inspector-empty")], [ - html.text("Nothing selected."), - ]) - content -> - html.div([attribute.class("inspector-section-content")], content) - }, + html.div([attribute.class("delete-confirmation-layer")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("delete-confirmation-backdrop"), + attribute.aria_label("Cancel character deletion"), + event.on_click(UserCancelledCharacterDeletion), + ], + [], + ), + html.section( + [ + attribute.class("delete-confirmation-modal"), + attribute.role("alertdialog"), + attribute.aria_modal(True), + attribute.aria_label("Confirm character deletion"), + ], + [ + html.div([attribute.class("delete-confirmation-heading")], [ + html.span([attribute.class("eyebrow")], [ + html.text("DESTRUCTIVE ACTION"), + ]), + html.h2([], [html.text("Delete " <> character_name <> "?")]), + html.p([], [ + html.text( + "This permanently removes the character and its local data from this browser.", + ), + ]), + ]), + html.div([attribute.class("delete-confirmation-warning")], [ + html.strong([], [html.text("This cannot be undone")]), + html.span([], [ + html.text("Export the character first if you may need it later."), + ]), + ]), + html.div([attribute.class("delete-confirmation-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserCancelledCharacterDeletion), + ], + [html.text("Keep character")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("danger-button"), + event.on_click(UserConfirmedCharacterDeletion), + ], + [html.text("Delete character")], + ), + ]), + ], + ), ]) } -fn view_snapshot_abilities(snapshot: Snapshot) -> Element(Message) { - let display_name = snapshot_display_name(snapshot) - let abilities = [ - #("Strength", "STR", snapshot.abilities.strength, Strength), - #("Dexterity", "DEX", snapshot.abilities.dexterity, Dexterity), - #("Constitution", "CON", snapshot.abilities.constitution, Constitution), - #("Intelligence", "INT", snapshot.abilities.intelligence, Intelligence), - #("Wisdom", "WIS", snapshot.abilities.wisdom, Wisdom), - #("Charisma", "CHA", snapshot.abilities.charisma, Charisma), - ] - snapshot_section("ABILITY SCORES", list.length(abilities), [ - html.div( - [attribute.class("inspector-ability-grid")], - list.map(abilities, fn(ability) { - let #(name, abbreviation, score, kind) = ability - let saving_throw = - modifier(score) - + case class_save_proficient(snapshot.class_name, kind) { - True -> snapshot.proficiency_bonus - False -> 0 - } - html.div([attribute.class("inspector-ability")], [ - html.span([], [html.text(abbreviation)]), - html.strong([], [html.text(int.to_string(score))]), - html.div([attribute.class("inspector-ability-rolls")], [ - html.button( +fn view_character_library(model: Model) -> Element(Message) { + let characters = case model.has_character { + True -> upsert_character(model.characters, model_to_stored_character(model)) + False -> model.characters + } + html.div([attribute.class("character-library-layer")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("character-library-backdrop"), + attribute.aria_label("Close character library"), + event.on_click(UserToggledCharacterLibrary), + ], + [], + ), + html.section( + [ + attribute.class("character-library"), + attribute.role("dialog"), + attribute.aria_modal(True), + attribute.aria_label("Switch character"), + ], + [ + html.div([attribute.class("library-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [ + html.text("CHARACTER LIBRARY"), + ]), + html.h2([], [html.text("Switch character")]), + html.p([], [ + html.text("Choose an adventurer or manage your offline library."), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("library-close"), + attribute.aria_label("Close character library"), + event.on_click(UserToggledCharacterLibrary), + ], + [html.text("×")], + ), + ]), + html.div( + [attribute.class("character-list")], + list.map(characters, fn(character) { + let active = character.id == model.character_id + let display_name = character_display_name(character) + html.article( [ - attribute.type_("button"), - attribute.class("roll-modifier"), - event.on_click(UserOpenedDiceRoll( - display_name <> " " <> name <> " check", - d20_test_modifier( - modifier(score), - snapshot_exhaustion_level(snapshot), - ), - )), + attribute.classes([ + #("character-list-item", True), + #("active", active), + ]), ], - [html.text(signed(modifier(score)) <> " check")], - ), - html.button( [ - attribute.type_("button"), - attribute.class("roll-modifier"), - event.on_click(UserOpenedDiceRoll( - display_name <> " " <> name <> " save", - d20_test_modifier( - saving_throw, - snapshot_exhaustion_level(snapshot), + html.div([attribute.class("library-character-portrait")], [ + portrait_content(character.portrait_url, display_name), + ]), + html.div([attribute.class("library-character-copy")], [ + html.div([attribute.class("library-character-name")], [ + html.strong([], [html.text(display_name)]), + case active { + True -> + html.span([attribute.class("library-current-chip")], [ + html.text("CURRENT"), + ]) + False -> html.text("") + }, + ]), + html.span([], [ + html.text( + "Level " + <> int.to_string(character.level) + <> " " + <> character.class_name, + ), + ]), + html.small([], [html.text(character.species_name)]), + ]), + html.div([attribute.class("character-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("character-switch"), + attribute.disabled(active), + event.on_click(UserSelectedCharacter(character.id)), + ], + [ + html.text(case active { + True -> "Current" + False -> "Open" + }), + ], ), - )), + html.details([attribute.class("character-more-actions")], [ + html.summary( + [ + attribute.aria_label( + "More actions for " <> display_name, + ), + ], + [html.text("More")], + ), + html.div([], [ + html.button( + [ + attribute.type_("button"), + event.on_click(UserDuplicatedCharacter(character.id)), + ], + [html.text("Duplicate")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("danger-action"), + event.on_click(UserDeletedCharacter(character.id)), + ], + [html.text("Delete")], + ), + ]), + ]), + ]), ], - [html.text(signed(saving_throw) <> " save")], - ), + ) + }), + ), + case model.import_status { + "" -> html.text("") + status -> + html.p([attribute.class("import-status")], [html.text(status)]) + }, + html.div([attribute.class("library-primary-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserCreatedCharacter), + ], + [html.text("+ New character")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserClickedImport), + ], + [html.text("Import JSON")], + ), + ]), + case model.has_character { + False -> html.text("") + True -> + html.div([attribute.class("library-export-actions")], [ + html.span([], [html.text("EXPORT")]), + html.button( + [ + attribute.type_("button"), + event.on_click(UserClickedExportCharacter), + ], + [html.text("Current character")], + ), + html.button( + [ + attribute.type_("button"), + event.on_click(UserClickedExportLibrary), + ], + [html.text("Full library")], + ), + ]) + }, + ], + ), + ]) +} + +fn view_health_configuration(model: Model) -> Element(Message) { + let maximum = character_hit_points(model) + let available_hit_dice = case model.level - model.hit_dice_spent { + available if available < 0 -> 0 + available -> available + } + html.div([attribute.class("health-config-layer")], [ + html.aside( + [ + attribute.class("health-config-modal"), + attribute.role("dialog"), + attribute.aria_label("Hit point configuration"), + ], + [ + html.div([attribute.class("health-config-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("Health")]), + html.h2([], [html.text("Hit point configuration")]), + html.p([], [ + html.text( + int.to_string(effective_current_hit_points(model)) + <> " current · " + <> int.to_string(maximum) + <> " maximum · " + <> int.to_string(available_hit_dice) + <> " Hit Dice available", + ), + ]), ]), - html.span([attribute.class("sr-only")], [html.text(name)]), - ]) - }), + html.button( + [ + attribute.type_("button"), + attribute.class("health-config-close"), + attribute.aria_label("Close hit point configuration"), + event.on_click(UserClosedHealthConfiguration), + ], + [html.text("×")], + ), + ]), + html.div([attribute.class("health-config-grid")], [ + resource_control( + "CURRENT HP", + effective_current_hit_points(model), + UserChangedCurrentHitPoints, + ), + resource_control( + "TEMP HP", + model.temporary_hit_points, + UserChangedTemporaryHitPoints, + ), + resource_control( + "HIT DICE SPENT", + model.hit_dice_spent, + UserChangedHitDiceSpent, + ), + resource_control( + "MAX HP OVERRIDE", + model.max_hit_points_override, + UserChangedMaxHitPoints, + ), + ]), + html.p([attribute.class("health-config-help")], [ + html.text( + "Set maximum HP override to 0 to use the automatic class, level, Constitution, and feat calculation.", + ), + ]), + ], ), ]) } -fn view_snapshot_skills(snapshot: Snapshot) -> Element(Message) { - let display_name = snapshot_display_name(snapshot) - snapshot_section("SKILLS", list.length(snapshot.skills), [ - html.div( - [attribute.class("inspector-skill-grid")], - list.map(snapshot.skills, fn(skill) { - html.div( +fn view_health_choice(model: Model, level: Int) -> Element(Message) { + let hit_die = class_hit_die(model.class_name) + let average = hit_die_average(hit_die) + let constitution = modifier(effective_ability_score(model, Constitution)) + html.aside( + [ + attribute.class("health-choice-window"), + attribute.role("dialog"), + attribute.aria_label("Choose health increase"), + ], + [ + html.span([attribute.class("eyebrow")], [html.text("LEVEL UP")]), + html.h2([], [html.text("Level " <> int.to_string(level) <> " health")]), + html.p([], [ + html.text( + "Choose the fixed average or roll your d" + <> int.to_string(hit_die) + <> " Hit Die. Your " + <> signed(constitution) + <> " Constitution modifier is applied automatically.", + ), + ]), + html.div([attribute.class("health-choice-actions")], [ + html.button( [ - attribute.classes([ - #("inspector-skill", True), - #("proficient", skill.proficient), + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserUsedAverageHealth), + ], + [ + html.strong([], [ + html.text( + "Use average · +" + <> int.to_string(health_gain(average, constitution)), + ), + ]), + html.small([], [ + html.text(int.to_string(average) <> signed(constitution)), ]), ], + ), + html.button( [ - html.span([attribute.class("skill-dot")], []), - html.span([], [html.text(skill.name)]), - html.small([], [html.text(skill.ability)]), + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserOpenedHealthRoll), + ], + [ + html.strong([], [ + html.text("Roll d" <> int.to_string(hit_die)), + ]), + html.small([], [html.text("Minimum 1 HP after modifier")]), + ], + ), + ]), + ], + ) +} + +fn view_dice_window(model: Model) -> Element(Message) { + let dice_count = parse_bounded(model.dice_count, 1, 1, 20) + let configured_roll = case model.dice_target { + HealthRoll(level: _) -> True + InitiativeRoll(entry_id: _) -> True + TableRoll -> False + } + let supports_roll_mode = model.dice_sides == 20 && dice_count == 1 + html.div([attribute.class("dice-window-layer")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("dice-window-backdrop"), + attribute.aria_label("Close dice roller"), + event.on_click(UserClosedDiceRoll), + ], + [], + ), + html.section( + [ + attribute.class("dice-window"), + attribute.role("dialog"), + attribute.aria_modal(True), + attribute.aria_label("Dice roller"), + ], + [ + html.div([attribute.class("dice-window-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("TABLE DICE")]), + html.h2([], [html.text("Dice roller")]), + html.p([], [ + html.text( + "Build a roll, then keep the result private or share it.", + ), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Close dice roller"), + event.on_click(UserClosedDiceRoll), + ], + [html.text("×")], + ), + ]), + html.div([attribute.class("dice-window-layout")], [ + html.div([attribute.class("dice-composer")], [ + html.div([attribute.class("dice-quick-section")], [ + html.div([attribute.class("dice-section-heading")], [ + html.strong([], [html.text("Quick dice")]), + html.span([], [html.text("CHOOSE A DIE")]), + ]), + html.div([attribute.class("dice-quick-list")], [ + quick_dice_button(4, model.dice_sides, configured_roll), + quick_dice_button(6, model.dice_sides, configured_roll), + quick_dice_button(8, model.dice_sides, configured_roll), + quick_dice_button(10, model.dice_sides, configured_roll), + quick_dice_button(12, model.dice_sides, configured_roll), + quick_dice_button(20, model.dice_sides, configured_roll), + quick_dice_button(100, model.dice_sides, configured_roll), + ]), + ]), + case supports_roll_mode { + True -> + html.div( + [ + attribute.class("roll-mode-group"), + attribute.aria_label("Roll mode"), + ], + [ + roll_mode_button("Normal", NormalRoll, model.dice_roll_mode), + roll_mode_button( + "Advantage", + AdvantageRoll, + model.dice_roll_mode, + ), + roll_mode_button( + "Disadvantage", + DisadvantageRoll, + model.dice_roll_mode, + ), + ], + ) + False -> html.text("") + }, + html.div([attribute.class("dice-window-controls")], [ + field( + "Dice", + html.input([ + attribute.class("text-input dice-count"), + attribute.type_("number"), + attribute.min("1"), + attribute.max("20"), + attribute.value(model.dice_count), + attribute.disabled(configured_roll), + event.on_input(UserChangedDiceCount), + ]), + ), + field( + "Die", + html.select( + [ + attribute.class("select-input dice-sides"), + attribute.disabled(configured_roll), + event.on_change(UserChangedDiceSides), + ], + list.map([4, 6, 8, 10, 12, 20, 100], fn(sides) { + html.option( + [ + attribute.value(int.to_string(sides)), + attribute.selected(model.dice_sides == sides), + ], + "d" <> int.to_string(sides), + ) + }), + ), + ), + field( + "Modifier", + html.input([ + attribute.class("text-input dice-modifier"), + attribute.type_("number"), + attribute.min("-99"), + attribute.max("99"), + attribute.value(model.dice_modifier), + attribute.disabled(configured_roll), + event.on_input(UserChangedDiceModifier), + ]), + ), + field( + "What is this roll for?", + html.input([ + attribute.class("text-input"), + attribute.placeholder("Perception, attack, saving throw…"), + attribute.value(model.dice_note), + attribute.disabled(configured_roll), + event.on_input(UserChangedDiceNote), + ]), + ), + ]), + view_secret_roll_controls(model), + case supports_roll_mode, model.inspiration { + True, True -> + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button inspiration-roll-button"), + event.on_click(UserSpentInspirationOnRoll), + ], + [html.text("Spend Inspiration for Advantage")], + ) + _, _ -> html.text("") + }, html.button( [ attribute.type_("button"), - attribute.class("roll-modifier"), - attribute.aria_label( - "Roll " <> display_name <> " " <> skill.name, - ), - event.on_click(UserOpenedDiceRoll( - display_name <> " " <> skill.name, - d20_test_modifier( - skill.score, - snapshot_exhaustion_level(snapshot), + attribute.class("primary-button dice-roll-button"), + event.on_click(UserRolledDice), + ], + [ + html.span([], [html.text("ROLL")]), + html.strong([], [ + html.text( + int.to_string(dice_count) + <> "d" + <> int.to_string(model.dice_sides) + <> case supports_roll_mode { + True -> " · " <> roll_mode_name(model.dice_roll_mode) + False -> "" + } + <> case int.parse(model.dice_modifier) { + Ok(0) -> "" + Ok(modifier) -> " " <> signed(bounded_modifier(modifier)) + Error(_) -> "" + }, ), - )), + ]), ], - [html.text(signed(skill.score))], ), - ], - ) - }), - ), - ]) -} - -fn view_snapshot_choices(snapshot: Snapshot) -> Element(Message) { - snapshot_section( - "CHARACTER CHOICES", - list.length(snapshot.choices), - list.map(snapshot.choices, fn(choice) { - html.div([attribute.class("inspector-choice")], [ - html.span([], [html.text(choice.label)]), - html.strong([], [html.text(choice.value)]), - ]) - }), - ) -} - -fn view_snapshot_features( - title: String, - features: List(SnapshotFeature), -) -> Element(Message) { - snapshot_section( - title, - list.length(features), - list.map(features, fn(feature) { - html.article([attribute.class("inspector-feature")], [ - html.div([], [ - html.h4([], [html.text(feature.name)]), - html.span([], [ - html.text( - feature.category - <> " · " - <> feature.source - <> case feature.level > 1 { - True -> " · Level " <> int.to_string(feature.level) - False -> "" + ]), + html.aside([attribute.class("dice-results-panel")], [ + html.div([attribute.class("dice-latest-result")], [ + html.span([], [html.text("LATEST RESULT")]), + html.div( + [attribute.class("dice-latest-content")], + case model.dice_rolls { + [] -> [ + html.strong([], [html.text("—")]), + html.p([], [html.text("Ready when you are.")]), + ] + [roll, ..] -> [ + html.strong([], [html.text(int.to_string(roll.total))]), + html.p([], [ + html.text( + dice_roll_expression(roll) + <> case roll.modifier { + 0 -> "" + modifier -> " " <> signed(modifier) + }, + ), + ]), + ] + }, + ), + ]), + html.div([attribute.class("dice-window-history")], [ + html.div([attribute.class("dice-window-history-heading")], [ + html.strong([], [html.text("Roll history")]), + html.span([], [ + html.text( + int.to_string(list.length(model.dice_rolls)) <> " rolls", + ), + ]), + ]), + case model.dice_rolls { + [] -> + html.p([attribute.class("dice-empty")], [ + html.text( + "Rolls from everyone at the table will appear here.", + ), + ]) + rolls -> + html.div( + [ + attribute.class("dice-history"), + attribute.aria_live("polite"), + ], + list.map(rolls, view_dice_roll), + ) }, - ), + ]), ]), ]), - case feature.description { - "" -> html.text("") - description -> html.p([], [html.text(description)]) - }, - ]) - }), - ) + ], + ), + ]) } -fn view_snapshot_equipment(snapshot: Snapshot) -> Element(Message) { - let equipment = - snapshot.equipment - |> list.filter(fn(item) { item.quantity > 0 || item.equipped }) - snapshot_section( - "EQUIPMENT", - list.length(equipment), - list.map(equipment, fn(item) { - html.div([attribute.class("inspector-equipment")], [ - html.div([], [ - html.strong([], [html.text(item.name)]), - html.small([], [html.text(item.category)]), - ]), - html.span([], [ - html.text( - case item.equipped { - True -> "EQUIPPED · " - False -> "" - } - <> "×" - <> int.to_string(item.quantity), - ), - ]), - ]) - }), +fn quick_dice_button( + sides: Int, + selected_sides: Int, + disabled: Bool, +) -> Element(Message) { + html.button( + [ + attribute.type_("button"), + attribute.classes([#("selected", sides == selected_sides)]), + attribute.disabled(disabled), + attribute.aria_pressed(case sides == selected_sides { + True -> "true" + False -> "false" + }), + event.on_click(UserChangedDiceSides(int.to_string(sides))), + ], + [html.text("d" <> int.to_string(sides))], ) } -fn view_snapshot_feats(snapshot: Snapshot) -> Element(Message) { - snapshot_section( - "FEATS", - list.length(snapshot.feats), - list.map(snapshot.feats, fn(feat) { - html.article([attribute.class("inspector-feature")], [ - html.div([], [ - html.h4([], [html.text(feat.name)]), +fn view_secret_roll_controls(model: Model) -> Element(Message) { + case model.dice_target { + HealthRoll(level: _) -> html.text("") + InitiativeRoll(entry_id: _) -> html.text("") + TableRoll -> + html.div([attribute.class("secret-roll-controls")], [ + html.label([], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(model.dice_secret), + event.on_check(fn(_) { UserToggledDiceSecret }), + ]), html.span([], [ - html.text( - "Level " - <> int.to_string(feat.level) - <> case feat.ability { - "" -> "" - ability -> " · +" <> ability - }, - ), + html.strong([], [ + html.text(case model.connection { + Connected(role: GameMaster, ..) -> "Secret roll" + Connected(role: Player, ..) -> "Secret roll" + _ -> "Keep roll private" + }), + ]), + html.small([], [ + html.text("Only you see the result unless you share it."), + ]), ]), ]), - case feat.description { - "" -> html.text("") - description -> html.p([], [html.text(description)]) + case model.dice_secret, model.connection { + True, Connected(role: Player, ..) -> + html.label([], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(model.dice_share_with_master), + event.on_check(fn(_) { UserToggledDiceShareWithMaster }), + ]), + html.span([], [ + html.strong([], [html.text("Share with Game Master")]), + html.small([], [ + html.text("Other players will not receive this roll."), + ]), + ]), + ]) + _, _ -> html.text("") }, ]) - }), - ) + } } -fn view_snapshot_spells(snapshot: Snapshot) -> Element(Message) { - let spell_note = case snapshot.spellcasting_ability { - "" -> "" - ability -> ability <> " · Save DC " <> int.to_string(snapshot.spell_save_dc) - } - snapshot_section( - "SPELLS", - list.length(snapshot.spells), - list.append( - case spell_note { - "" -> [] - note -> [ - html.p([attribute.class("inspector-spellcasting")], [html.text(note)]), - ] - }, - list.map(snapshot.spells, fn(spell) { - html.article([attribute.class("inspector-spell")], [ - html.div([], [ - html.div([], [ - html.h4([], [html.text(spell.name)]), - html.span([], [ - html.text(case spell.level { - 0 -> "Cantrip" - level -> "Level " <> int.to_string(level) - }), - ]), - ]), - html.span([attribute.class("spell-status")], [ - html.text(case spell.prepared, spell.ritual { - True, True -> "PREPARED · RITUAL" - True, False -> "PREPARED" - False, True -> "RITUAL" - False, False -> spell.school - }), - ]), - ]), - case spell.description { - "" -> html.text("") - description -> html.p([], [html.text(description)]) - }, - ]) +fn roll_mode_button( + label: String, + mode: RollMode, + selected_mode: RollMode, +) -> Element(Message) { + let selected = mode == selected_mode + html.button( + [ + attribute.type_("button"), + attribute.classes([#("selected", selected)]), + attribute.aria_pressed(case selected { + True -> "true" + False -> "false" }), - ), + event.on_click(UserSelectedRollMode(mode)), + ], + [html.text(label)], ) } -fn snapshot_display_name(snapshot: Snapshot) -> String { - case string.trim(snapshot.character_name) { - "" -> "Unnamed Adventurer" - name -> name +fn view_dice_roll(roll: DiceRoll) -> Element(Message) { + html.article([attribute.class("dice-roll")], [ + html.div([attribute.class("dice-roll-heading")], [ + html.strong([], [html.text(roll.author)]), + html.span([], [ + html.text( + roll.mode <> " · " <> browser.format_timestamp(roll.created_at), + ), + ]), + ]), + case roll.audience { + "secret" -> + html.span([attribute.class("dice-audience-badge")], [ + html.text("Secret"), + ]) + "gm" -> + html.span([attribute.class("dice-audience-badge")], [ + html.text("Shared with GM"), + ]) + _ -> html.text("") + }, + case roll.note { + "" -> html.text("") + note -> html.p([attribute.class("dice-roll-note")], [html.text(note)]) + }, + html.div([attribute.class("dice-roll-result")], [ + html.span([], [ + html.text(dice_roll_expression(roll)), + ]), + html.span([], [ + html.text(case roll.modifier { + 0 -> "" + modifier -> signed(modifier) + }), + ]), + html.strong([], [html.text(int.to_string(roll.total))]), + ]), + ]) +} + +fn dice_roll_expression(roll: DiceRoll) -> String { + let expression = + int.to_string(roll.dice_count) <> "d" <> int.to_string(roll.dice_sides) + let values = + roll.results + |> list.map(int.to_string) + |> string.join(", ") + case roll.mode, roll.results { + "Advantage", [_, _] -> + expression <> " [" <> values <> "] → " <> int.to_string(roll.kept) + "Disadvantage", [_, _] -> + expression <> " [" <> values <> "] → " <> int.to_string(roll.kept) + _, [_] -> expression <> " [" <> values <> "]" + _, _ -> expression <> " [" <> values <> "]" + } +} + +fn portrait_content( + portrait_url: String, + display_name: String, +) -> Element(message) { + case portrait_url { + "" -> + html.span([attribute.class("portrait-initial")], [ + html.text(string.slice(display_name, at_index: 0, length: 1)), + ]) + portrait_url -> + html.img([ + attribute.class("portrait-image"), + attribute.src(portrait_url), + attribute.alt(display_name <> " portrait"), + ]) } } -fn background_description(model: Model) -> String { - case model.origin_details { - Ok(details) -> { - let description = - details.background_features - |> list.map(fn(feature) { feature.name <> " " <> feature.description }) - |> string.join(" · ") - case string.length(description) > 180 { - True -> string.slice(description, at_index: 0, length: 177) <> "…" - False -> description - } - } - Error(_) -> "Origin feat and proficiencies" - } +fn view_remote_portrait( + portrait_url portrait_url: String, + display_name display_name: String, + level level: Int, +) -> Element(message) { + html.div([attribute.class("portrait")], [ + portrait_content(portrait_url, display_name), + html.div([attribute.class("level-badge")], [ + html.small([], [html.text("LVL")]), + html.strong([], [html.text(int.to_string(level))]), + ]), + ]) } -fn view_identity(model: Model) -> Element(Message) { - html.section([attribute.class("panel identity-panel")], [ - section_heading("01", "Character foundation", "Choose your core identity"), - html.div([attribute.class("identity-grid")], [ - required_field( - "Character name", - string.trim(model.character_name) == "", - html.input([ - attribute.class("text-input name-input"), - attribute.placeholder("Name your hero"), - attribute.value(model.character_name), - event.on_input(UserChangedName), +fn view_snapshot(snapshot: Snapshot) -> Element(Message) { + let display_name = snapshot_display_name(snapshot) + let class_identity = case snapshot.subclass_name { + "" -> snapshot.class_name + subclass -> snapshot.class_name <> " · " <> subclass + } + html.aside([attribute.class("sheet master-inspector")], [ + html.div([attribute.class("sheet-accent")], []), + view_remote_portrait( + portrait_url: snapshot.portrait_url, + display_name:, + level: snapshot.level, + ), + html.div([attribute.class("sheet-title inspector-title")], [ + html.div([attribute.class("inspector-heading")], [ + html.span([attribute.class("eyebrow")], [ + html.text("Character inspection"), ]), - ), - field( - "Level", - html.select( - [attribute.class("select-input"), event.on_change(UserChangedLevel)], - list.map(levels(), fn(level) { - html.option( - [ - attribute.value(int.to_string(level)), - attribute.selected(level == model.level), - ], - "Level " <> int.to_string(level), - ) - }), + html.span([attribute.class("read-only-badge")], [html.text("READ ONLY")]), + ]), + html.h2([], [html.text(display_name)]), + html.p([], [ + html.text( + snapshot.species_name + <> " · " + <> snapshot.background_name + <> " · " + <> class_identity, ), - ), + ]), + html.small([attribute.class("inspector-owner")], [ + html.text("Played by " <> snapshot.owner), + ]), ]), - html.div([attribute.class("choice-grid")], [ - choice_select( - model, - "Class", - "Your training and core features", - model.class_name, - list.map(class_choices(), fn(item) { #(item.name, item.source) }), - UserChangedClass, + html.div([attribute.class("combat-grid inspector-combat-grid")], [ + stat_block( + "ARMOR CLASS", + int.to_string(snapshot.armor_class), + "Current defense", ), - choice_select( - model, - "Species", - "Innate traits and movement", - model.species_name, - list.map(model.species, fn(item) { #(item.name, item.source) }), - UserChangedSpecies, + stat_block( + "HIT POINTS", + int.to_string(snapshot.current_hit_points) + <> " / " + <> int.to_string(snapshot.hit_points), + "Current / maximum", ), - choice_select( - model, - "Background", - background_description(model), - model.background_name, - list.map(model.backgrounds, fn(item) { #(item.name, item.source) }), - UserChangedBackground, + rollable_stat_block( + "INITIATIVE", + d20_test_modifier( + snapshot.initiative, + snapshot_exhaustion_level(snapshot), + ), + "Dexterity", + display_name <> " initiative", + ), + stat_block("SPEED", int.to_string(snapshot.speed) <> " ft", "Movement"), + ]), + html.div([attribute.class("inspector-quick-stats")], [ + snapshot_quick_stat("PROFICIENCY", signed(snapshot.proficiency_bonus)), + snapshot_quick_stat( + "PASSIVE PERCEPTION", + int.to_string(snapshot.passive_perception), ), + snapshot_quick_stat("HIT DIE", "d" <> int.to_string(snapshot.hit_die)), + snapshot_quick_stat("SPELL SAVE", case snapshot.spell_save_dc { + 0 -> "—" + score -> int.to_string(score) + }), + snapshot_quick_stat("GOLD", int.to_string(snapshot.gold) <> " gp"), ]), + view_snapshot_resources(snapshot), + view_snapshot_abilities(snapshot), + view_snapshot_skills(snapshot), + snapshot_section( + "TOOL & OTHER PROFICIENCIES", + list.length(snapshot.other_proficiencies), + list.map(snapshot.other_proficiencies, fn(proficiency) { + html.span([attribute.class("inspector-proficiency")], [ + html.text(proficiency), + ]) + }), + ), + view_snapshot_choices(snapshot), + view_snapshot_features("ORIGIN FEATURES", snapshot.origin_features), + view_snapshot_features("CLASS FEATURES", snapshot.class_features), + view_snapshot_equipment(snapshot), + view_snapshot_feats(snapshot), + view_snapshot_spells(snapshot), ]) } -fn view_core_abilities(model: Model) -> Element(Message) { - html.section([attribute.class("panel progression-panel")], [ - section_heading("02", "Abilities", "Ability scores and skill proficiencies"), - html.div( - [ - attribute.class("progression-tabs"), - attribute.role("tablist"), - attribute.aria_label("Abilities"), - ], - [ - progression_tab( - "Ability scores", - model.active_core_tab == CoreAbilityScores, - AbilityScoresTab, - tab_incomplete_count(model, AbilityScoresTab), - ), - progression_tab( - "Skills", - model.active_core_tab == CoreSkillProficiencies, - SkillProficienciesTab, - tab_incomplete_count(model, SkillProficienciesTab), +fn view_snapshot_resources(snapshot: Snapshot) -> Element(Message) { + let resources = + list.map(snapshot.resources, fn(resource) { + html.span([attribute.class("inspector-proficiency")], [ + html.text( + resource.name + <> " " + <> int.to_string(resource.current) + <> "/" + <> int.to_string(resource.maximum), ), - ], + ]) + }) + let inspiration = case snapshot.inspiration { + True -> [ + html.span([attribute.class("inspector-proficiency")], [ + html.text("Inspiration"), + ]), + ] + False -> [] + } + let concentration = case snapshot.concentration { + "" -> [] + spell -> [ + html.span([attribute.class("inspector-proficiency")], [ + html.text("Concentrating: " <> spell), + ]), + ] + } + let conditions = + list.map(snapshot.conditions, fn(condition) { + html.span([attribute.class("inspector-proficiency")], [ + html.text(condition), + ]) + }) + let status = + list.append( + resources, + list.append(inspiration, list.append(concentration, conditions)), + ) + snapshot_section("PLAY RESOURCES", list.length(status), status) +} + +fn snapshot_quick_stat(label: String, value: String) -> Element(message) { + html.div([], [ + html.span([], [html.text(label)]), + html.strong([], [html.text(value)]), + ]) +} + +fn snapshot_section( + title: String, + count: Int, + content: List(Element(message)), +) -> Element(message) { + html.section([attribute.class("inspector-section")], [ + html.div([attribute.class("inspector-section-heading")], [ + html.h3([], [html.text(title)]), + html.span([], [html.text(int.to_string(count))]), + ]), + case content { + [] -> + html.p([attribute.class("inspector-empty")], [ + html.text("Nothing selected."), + ]) + content -> + html.div([attribute.class("inspector-section-content")], content) + }, + ]) +} + +fn view_snapshot_abilities(snapshot: Snapshot) -> Element(Message) { + let display_name = snapshot_display_name(snapshot) + let abilities = [ + #("Strength", "STR", snapshot.abilities.strength, Strength), + #("Dexterity", "DEX", snapshot.abilities.dexterity, Dexterity), + #("Constitution", "CON", snapshot.abilities.constitution, Constitution), + #("Intelligence", "INT", snapshot.abilities.intelligence, Intelligence), + #("Wisdom", "WIS", snapshot.abilities.wisdom, Wisdom), + #("Charisma", "CHA", snapshot.abilities.charisma, Charisma), + ] + snapshot_section("ABILITY SCORES", list.length(abilities), [ + html.div( + [attribute.class("inspector-ability-grid")], + list.map(abilities, fn(ability) { + let #(name, abbreviation, score, kind) = ability + let saving_throw = + modifier(score) + + case class_save_proficient(snapshot.class_name, kind) { + True -> snapshot.proficiency_bonus + False -> 0 + } + html.div([attribute.class("inspector-ability")], [ + html.span([], [html.text(abbreviation)]), + html.strong([], [html.text(int.to_string(score))]), + html.div([attribute.class("inspector-ability-rolls")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("roll-modifier"), + event.on_click(UserOpenedDiceRoll( + display_name <> " " <> name <> " check", + d20_test_modifier( + modifier(score), + snapshot_exhaustion_level(snapshot), + ), + )), + ], + [html.text(signed(modifier(score)) <> " check")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("roll-modifier"), + event.on_click(UserOpenedDiceRoll( + display_name <> " " <> name <> " save", + d20_test_modifier( + saving_throw, + snapshot_exhaustion_level(snapshot), + ), + )), + ], + [html.text(signed(saving_throw) <> " save")], + ), + ]), + html.span([attribute.class("sr-only")], [html.text(name)]), + ]) + }), ), - case model.active_core_tab { - CoreAbilityScores -> view_ability_scores(model) - CoreSkillProficiencies -> view_skill_proficiencies(model) - }, ]) } -fn view_character_progression(model: Model) -> Element(Message) { - html.section([attribute.class("panel progression-panel")], [ - section_heading( - "03", - "Class progression", - "Class features, origins, feats, and level advancement", - ), +fn view_snapshot_skills(snapshot: Snapshot) -> Element(Message) { + let display_name = snapshot_display_name(snapshot) + snapshot_section("SKILLS", list.length(snapshot.skills), [ html.div( - [ - attribute.class("progression-tabs"), - attribute.role("tablist"), - attribute.aria_label("Class progression"), - ], - [ - progression_tab( - "Class features", - model.active_progression_tab == ProgressionClassFeatures, - ClassFeaturesTab, - tab_incomplete_count(model, ClassFeaturesTab), - ), - progression_tab( - "Origins", - model.active_progression_tab == ProgressionOrigins, - OriginsTab, - tab_incomplete_count(model, OriginsTab), - ), - progression_tab( - "Feats", - model.active_progression_tab == ProgressionFeats, - FeatsTab, - tab_incomplete_count(model, FeatsTab), - ), - progression_tab( - "Level up", - model.active_progression_tab == ProgressionLevelUp, - LevelUpTab, - tab_incomplete_count(model, LevelUpTab), - ), - ], - ), - case model.active_progression_tab { - ProgressionClassFeatures -> view_class_features(model) - ProgressionOrigins -> view_origins(model) - ProgressionFeats -> view_feats(model) - ProgressionLevelUp -> view_level_up(model) - }, - ]) -} - -fn view_play_resources(model: Model) -> Element(Message) { - let spell_slots = - [1, 2, 3, 4, 5, 6, 7, 8, 9] - |> list.filter(fn(level) { spell_slot_maximum(model, level) > 0 }) - let resources = class_resource_definitions(model) - html.div([attribute.class("play-resources")], [ - html.div([attribute.class("play-resources-heading")], [ - html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("PLAY RESOURCES")]), - html.h3([], [html.text("Session dashboard")]), - ]), - html.div([attribute.class("rest-actions")], [ - html.button( + [attribute.class("inspector-skill-grid")], + list.map(snapshot.skills, fn(skill) { + html.div( [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserOpenedRest(ShortRest)), + attribute.classes([ + #("inspector-skill", True), + #("proficient", skill.proficient), + ]), ], - [html.text("Short rest")], - ), - html.button( [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserOpenedRest(LongRest)), - ], - [html.text("Long rest")], - ), - case model.rest_undo { - Ok(_) -> + html.span([attribute.class("skill-dot")], []), + html.span([], [html.text(skill.name)]), + html.small([], [html.text(skill.ability)]), html.button( [ attribute.type_("button"), - attribute.class("text-button"), - event.on_click(UserUndidRest), - ], - [html.text("Undo last rest")], - ) - Error(Nil) -> html.text("") - }, - ]), - ]), - html.div([attribute.class("play-resource-grid")], [ - html.article([attribute.class("play-resource-group")], [ - html.h4([], [ - html.text(case model.class_name { - "Warlock" -> "Pact Magic" - _ -> "Spell slots" - }), - ]), - case spell_slots { - [] -> - html.p([attribute.class("muted-copy")], [ - html.text("No spell slots at this level."), - ]) - _ -> - html.div( - [attribute.class("counter-grid")], - list.map(spell_slots, fn(level) { - view_counter( - "slot:" <> int.to_string(level), - "Level " <> int.to_string(level), - spell_slot_maximum(model, level), - spell_slot_spent(model, level), - UserSpentSpellSlot(level), - UserRestoredSpellSlot(level), - "", - False, - ) - }), - ) - }, - ]), - html.article([attribute.class("play-resource-group")], [ - html.h4([], [html.text("Class resources")]), - case resources, model.custom_resources { - [], [] -> - html.p([attribute.class("muted-copy")], [ - html.text("Add a custom counter for a limited-use feature."), - ]) - _, _ -> - html.div( - [attribute.class("counter-grid")], - list.append( - list.map(resources, fn(resource) { - view_counter( - resource.id, - resource.name, - resource.maximum, - resource_spent(model, resource.id), - UserSpentResource(resource.id), - UserRestoredResource(resource.id), - resource.source - <> case resource.die_sides { - 0 -> "" - sides -> " · d" <> int.to_string(sides) - }, - False, - ) - }), - list.map(model.custom_resources, fn(resource) { - view_counter( - resource.id, - resource.name, - resource.maximum, - resource.spent, - UserSpentResource(resource.id), - UserRestoredResource(resource.id), - "Custom · " <> recovery_label(resource.recovery), - True, - ) - }), - ), - ) - }, - html.div([attribute.class("custom-resource-form")], [ - html.input([ - attribute.type_("text"), - attribute.placeholder("Custom resource"), - attribute.value(model.custom_resource_name), - event.on_input(UserChangedCustomResourceName), - ]), - html.input([ - attribute.type_("number"), - attribute.min("1"), - attribute.max("999"), - attribute.value(model.custom_resource_maximum), - attribute.aria_label("Custom resource maximum"), - event.on_input(UserChangedCustomResourceMaximum), - ]), - html.select([event.on_change(UserChangedCustomResourceRecovery)], [ - html.option([attribute.value("long")], "Long rest"), - html.option([attribute.value("short-all")], "Short rest"), - html.option([attribute.value("short-1")], "+1 on short rest"), - html.option([attribute.value("manual")], "Manual"), - ]), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - event.on_click(UserAddedCustomResource), - ], - [html.text("Add counter")], - ), - ]), - ]), - html.article([attribute.class("play-resource-group play-state-group")], [ - html.h4([], [html.text("Status")]), - html.div([attribute.class("status-actions")], [ - html.button( - [ - attribute.type_("button"), - attribute.classes([ - #("status-toggle", True), - #("active", model.inspiration), - ]), - attribute.aria_pressed(case model.inspiration { - True -> "true" - False -> "false" - }), - event.on_click(UserToggledInspiration), - ], - [ - html.text(case model.inspiration { - True -> "Inspiration available" - False -> "Inspiration spent" - }), - ], - ), - case model.concentration { - "" -> - html.span([attribute.class("status-chip")], [ - html.text("No concentration"), - ]) - spell -> - html.span([attribute.class("status-chip active")], [ - html.text("Concentrating: " <> spell), - html.button( - [ - attribute.type_("button"), - attribute.aria_label("Clear concentration"), - event.on_click(UserClearedConcentration), - ], - [html.text("×")], + attribute.class("roll-modifier"), + attribute.aria_label( + "Roll " <> display_name <> " " <> skill.name, ), - ]) - }, - ]), - view_death_saves(model), - view_character_conditions(model), - ]), - view_prepared_spells_dashboard(model), - ]), - ]) -} - -fn view_prepared_spells_dashboard(model: Model) -> Element(Message) { - let prepared_spells = - model.spells - |> list.filter(fn(spell) { spell.prepared }) - |> list.sort(fn(left, right) { - case int.compare(left.level, right.level) { - order.Eq -> string.compare(left.name, right.name) - ordering -> ordering - } - }) - html.article([attribute.class("play-resource-group prepared-spells-group")], [ - html.h4([], [html.text("Prepared spells")]), - case prepared_spells { - [] -> - html.p([attribute.class("muted-copy")], [ - html.text("Prepare spells from the Character page to cast them here."), - ]) - _ -> - html.div( - [attribute.class("session-spell-list")], - list.map(prepared_spells, fn(spell) { - let catalog_spell = - list.find(model.spell_catalog, fn(available) { - available.name == spell.name - }) - let summary_details = case catalog_spell { - Ok(available) -> - available.school - <> case string.contains(available.duration, "Concentration") { - True -> " · Concentration" - False -> "" - } - Error(Nil) -> "" - } - html.div([attribute.class("session-spell-row")], [ - html.details([attribute.class("session-spell-details")], [ - html.summary([], [ - html.span([attribute.class("session-spell-disclosure")], [ - html.text("›"), - ]), - html.span([], [ - html.strong([], [html.text(spell.name)]), - html.small([], [ - html.text( - case spell.level { - 0 -> "Cantrip" - level -> "Level " <> int.to_string(level) - } - <> case summary_details { - "" -> "" - details -> " · " <> details - }, - ), - ]), - ]), - ]), - case catalog_spell { - Ok(available) -> - html.div([attribute.class("session-spell-description")], [ - html.div([attribute.class("spell-metadata")], [ - spell_metadata("Casting", available.casting_time), - spell_metadata("Range", available.range), - spell_metadata("Components", available.components), - spell_metadata("Duration", available.duration), - ]), - html.p([], [html.text(available.description)]), - ]) - Error(Nil) -> - html.p( - [attribute.class("session-spell-description muted-copy")], - [ - html.text( - "Description unavailable until rules data is loaded.", - ), - ], - ) - }, - ]), - html.button( - [ - attribute.type_("button"), - attribute.class("spell-cast-button"), - attribute.aria_label("Cast " <> spell.name), - event.on_click(UserOpenedSpellCast(spell.name, spell.level)), - ], - [html.text("Cast")], - ), - ]) - }), + event.on_click(UserOpenedDiceRoll( + display_name <> " " <> skill.name, + d20_test_modifier( + skill.score, + snapshot_exhaustion_level(snapshot), + ), + )), + ], + [html.text(signed(skill.score))], + ), + ], ) - }, - ]) -} - -fn view_counter( - id: String, - name: String, - maximum: Int, - spent: Int, - spend: Message, - restore: Message, - source: String, - removable: Bool, -) -> Element(Message) { - html.div([attribute.class("resource-counter")], [ - html.div([attribute.class("resource-counter-label")], [ - html.strong([], [html.text(name)]), - html.small([], [html.text(source)]), - ]), - html.div([attribute.class("resource-counter-controls")], [ - html.button( - [ - attribute.type_("button"), - attribute.aria_label("Spend " <> name), - attribute.disabled(spent >= maximum), - event.on_click(spend), - ], - [html.text("−")], - ), - html.span([], [ - html.strong([], [html.text(int.to_string(maximum - spent))]), - html.text(" / " <> int.to_string(maximum)), - ]), - html.button( - [ - attribute.type_("button"), - attribute.aria_label("Restore " <> name), - attribute.disabled(spent == 0), - event.on_click(restore), - ], - [html.text("+")], - ), - case removable { - True -> - html.button( - [ - attribute.type_("button"), - attribute.class("counter-delete"), - attribute.aria_label("Delete " <> name), - event.on_click(UserRemovedCustomResource(id)), - ], - [html.text("×")], - ) - False -> html.text("") - }, - ]), + }), + ), ]) } -fn recovery_label(recovery: RecoveryRule) -> String { - case recovery { - ShortRestAll -> "Short rest" - ShortRestAmount(amount) -> "+" <> int.to_string(amount) <> " on short rest" - LongRestAll -> "Long rest" - ManualRecovery -> "Manual" - } +fn view_snapshot_choices(snapshot: Snapshot) -> Element(Message) { + snapshot_section( + "CHARACTER CHOICES", + list.length(snapshot.choices), + list.map(snapshot.choices, fn(choice) { + html.div([attribute.class("inspector-choice")], [ + html.span([], [html.text(choice.label)]), + html.strong([], [html.text(choice.value)]), + ]) + }), + ) } -fn view_death_saves(model: Model) -> Element(Message) { - case effective_current_hit_points(model) { - 0 -> - html.div([attribute.class("death-saves")], [ +fn view_snapshot_features( + title: String, + features: List(SnapshotFeature), +) -> Element(Message) { + snapshot_section( + title, + list.length(features), + list.map(features, fn(feature) { + html.article([attribute.class("inspector-feature")], [ html.div([], [ - html.strong([], [html.text("Death saves")]), + html.h4([], [html.text(feature.name)]), html.span([], [ html.text( - "Successes " - <> int.to_string(model.death_save_successes) - <> "/3 · Failures " - <> int.to_string(model.death_save_failures) - <> "/3", + feature.category + <> " · " + <> feature.source + <> case feature.level > 1 { + True -> " · Level " <> int.to_string(feature.level) + False -> "" + }, ), ]), ]), - html.div([attribute.class("death-save-actions")], [ - html.button( - [ - attribute.type_("button"), - event.on_click(UserRecordedDeathSaveSuccess), - ], - [html.text("Success")], - ), - html.button( - [ - attribute.type_("button"), - event.on_click(UserRecordedDeathSaveFailure(1)), - ], - [html.text("Failure")], - ), - html.button( - [ - attribute.type_("button"), - event.on_click(UserRecordedDeathSaveFailure(2)), - ], - [html.text("Natural 1")], - ), - html.button( - [ - attribute.type_("button"), - event.on_click(UserRecordedDeathSaveNaturalTwenty), - ], - [html.text("Natural 20")], + case feature.description { + "" -> html.text("") + description -> html.p([], [html.text(description)]) + }, + ]) + }), + ) +} + +fn view_snapshot_equipment(snapshot: Snapshot) -> Element(Message) { + let equipment = + snapshot.equipment + |> list.filter(fn(item) { item.quantity > 0 || item.equipped }) + snapshot_section( + "EQUIPMENT", + list.length(equipment), + list.map(equipment, fn(item) { + html.div([attribute.class("inspector-equipment")], [ + html.div([], [ + html.strong([], [html.text(item.name)]), + html.small([], [html.text(item.category)]), + ]), + html.span([], [ + html.text( + case item.equipped { + True -> "EQUIPPED · " + False -> "" + } + <> "×" + <> int.to_string(item.quantity), ), - html.button( - [attribute.type_("button"), event.on_click(UserResetDeathSaves)], - [html.text("Reset")], + ]), + ]) + }), + ) +} + +fn view_snapshot_feats(snapshot: Snapshot) -> Element(Message) { + snapshot_section( + "FEATS", + list.length(snapshot.feats), + list.map(snapshot.feats, fn(feat) { + html.article([attribute.class("inspector-feature")], [ + html.div([], [ + html.h4([], [html.text(feat.name)]), + html.span([], [ + html.text( + "Level " + <> int.to_string(feat.level) + <> case feat.ability { + "" -> "" + ability -> " · +" <> ability + }, + ), + ]), + ]), + case feat.description { + "" -> html.text("") + description -> html.p([], [html.text(description)]) + }, + ]) + }), + ) +} + +fn view_snapshot_spells(snapshot: Snapshot) -> Element(Message) { + let spell_note = case snapshot.spellcasting_ability { + "" -> "" + ability -> ability <> " · Save DC " <> int.to_string(snapshot.spell_save_dc) + } + snapshot_section( + "SPELLS", + list.length(snapshot.spells), + list.append( + case spell_note { + "" -> [] + note -> [ + html.p([attribute.class("inspector-spellcasting")], [html.text(note)]), + ] + }, + list.map(snapshot.spells, fn(spell) { + html.article([attribute.class("inspector-spell")], [ + html.div([], [ + html.div([], [ + html.h4([], [html.text(spell.name)]), + html.span([], [ + html.text(case spell.level { + 0 -> "Cantrip" + level -> "Level " <> int.to_string(level) + }), + ]), + ]), + html.span([attribute.class("spell-status")], [ + html.text(case spell.prepared, spell.ritual { + True, True -> "PREPARED · RITUAL" + True, False -> "PREPARED" + False, True -> "RITUAL" + False, False -> spell.school + }), + ]), + ]), + case spell.description { + "" -> html.text("") + description -> html.p([], [html.text(description)]) + }, + ]) + }), + ), + ) +} + +fn snapshot_display_name(snapshot: Snapshot) -> String { + case string.trim(snapshot.character_name) { + "" -> "Unnamed Adventurer" + name -> name + } +} + +fn background_description(model: Model) -> String { + case model.origin_details { + Ok(details) -> { + let description = + details.background_features + |> list.map(fn(feature) { feature.name <> " " <> feature.description }) + |> string.join(" · ") + case string.length(description) > 180 { + True -> string.slice(description, at_index: 0, length: 177) <> "…" + False -> description + } + } + Error(_) -> "Origin feat and proficiencies" + } +} + +fn view_identity(model: Model) -> Element(Message) { + html.section([attribute.class("foundation-form")], [ + section_heading("01", "Character foundation", "Choose your core identity"), + html.div([attribute.class("identity-grid")], [ + required_field( + "Character name", + string.trim(model.character_name) == "", + html.input([ + attribute.class("text-input name-input"), + attribute.placeholder("Name your hero"), + attribute.value(model.character_name), + event.on_input(UserChangedName), + ]), + ), + field( + "Level", + html.select( + [attribute.class("select-input"), event.on_change(UserChangedLevel)], + list.map(levels(), fn(level) { + html.option( + [ + attribute.value(int.to_string(level)), + attribute.selected(level == model.level), + ], + "Level " <> int.to_string(level), + ) + }), + ), + ), + ]), + html.div([attribute.class("choice-grid")], [ + choice_select( + model, + "Class", + "Your training and core features", + model.class_name, + list.map(class_choices(), fn(item) { #(item.name, item.source) }), + UserChangedClass, + ), + choice_select( + model, + "Species", + "Innate traits and movement", + model.species_name, + list.map(model.species, fn(item) { #(item.name, item.source) }), + UserChangedSpecies, + ), + choice_select( + model, + "Background", + background_description(model), + model.background_name, + list.map(model.backgrounds, fn(item) { #(item.name, item.source) }), + UserChangedBackground, + ), + ]), + case model.origin_details { + Error(_) -> html.text("") + Ok(details) -> + html.div([attribute.class("progression-choices origin-choices")], [ + html.h4([], [html.text("Origin choices")]), + html.div( + [attribute.class("progression-choice-grid")], + list.map(details.choices, fn(choice) { + view_feature_choice(choice, model) + }), ), - ]), - ]) - _ -> html.text("") + ]) + }, + ]) +} + +fn recovery_label(recovery: RecoveryRule) -> String { + case recovery { + ShortRestAll -> "Short rest" + ShortRestAmount(amount) -> "+" <> int.to_string(amount) <> " on short rest" + LongRestAll -> "Long rest" + ManualRecovery -> "Manual" } } @@ -10007,164 +11167,6 @@ fn standard_condition_names() -> List(String) { ] } -fn view_character_conditions(model: Model) -> Element(Message) { - let conditions = standard_condition_names() - html.div([attribute.class("character-conditions")], [ - html.div( - [attribute.class("condition-list")], - list.map(model.conditions, fn(condition) { - case condition.name { - "Exhaustion" -> html.text("") - name -> - html.span([attribute.class("condition-chip")], [ - html.text(name), - html.button( - [ - attribute.type_("button"), - attribute.aria_label("Remove " <> name), - event.on_click(UserRemovedCondition(name)), - ], - [html.text("×")], - ), - ]) - } - }), - ), - html.div([attribute.class("condition-controls")], [ - html.select( - [ - attribute.value(model.condition_choice), - event.on_change(UserChangedConditionChoice), - ], - [ - html.option([attribute.value("")], "Add condition…"), - ..list.map(conditions, fn(name) { - html.option( - [ - attribute.value(name), - attribute.disabled( - list.any(model.conditions, fn(condition) { - condition.name == name - }), - ), - ], - name, - ) - }) - ], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.disabled(model.condition_choice == ""), - event.on_click(UserAddedCondition), - ], - [html.text("Apply")], - ), - html.label([attribute.class("exhaustion-control")], [ - html.span([], [html.text("Exhaustion")]), - html.input([ - attribute.type_("number"), - attribute.min("0"), - attribute.max("6"), - attribute.value(int.to_string(exhaustion_level(model))), - event.on_input(UserChangedExhaustion), - ]), - ]), - ]), - case exhaustion_level(model) { - 0 -> html.text("") - level -> - html.small([attribute.class("effect-note")], [ - html.text( - "Exhaustion " - <> int.to_string(level) - <> ": −" - <> int.to_string(level * 2) - <> " to D20 Tests; speed reduced by " - <> int.to_string(level * 5) - <> " ft.", - ), - ]) - }, - ]) -} - -fn view_character_resources(model: Model) -> Element(Message) { - html.section([attribute.class("panel progression-panel")], [ - section_heading( - "04", - "Equipment & companions", - "Inventory, summons, spell preparation, and campaign notes", - ), - html.div( - [ - attribute.class("progression-tabs"), - attribute.role("tablist"), - attribute.aria_label("Equipment, companions, magic, and notes"), - ], - [ - progression_tab( - "Equipment", - model.active_resources_tab == ResourcesEquipment, - EquipmentTab, - tab_incomplete_count(model, EquipmentTab), - ), - progression_tab( - "Spells", - model.active_resources_tab == ResourcesSpells, - SpellsTab, - tab_incomplete_count(model, SpellsTab), - ), - progression_tab( - "Companions", - model.active_resources_tab == ResourcesCompanions, - CompanionsTab, - tab_incomplete_count(model, CompanionsTab), - ), - progression_tab( - "Notes", - model.active_resources_tab == ResourcesNotes, - NotesTab, - tab_incomplete_count(model, NotesTab), - ), - ], - ), - case model.active_resources_tab { - ResourcesEquipment -> view_equipment(model) - ResourcesSpells -> view_spells(model) - ResourcesCompanions -> view_companions(model) - ResourcesNotes -> view_notes(model) - }, - ]) -} - -fn progression_tab( - label: String, - selected: Bool, - tab: AbilitiesTab, - incomplete_count: Int, -) -> Element(Message) { - html.button( - [ - attribute.type_("button"), - attribute.role("tab"), - attribute.classes([ - #("progression-tab", True), - #("active", selected), - #("incomplete", incomplete_count > 0), - ]), - attribute.aria_selected(selected), - event.on_click(UserSelectedAbilitiesTab(tab)), - ], - [ - html.text(label), - incomplete_indicator(incomplete_count, label), - ], - ) -} - fn incomplete_indicator(count: Int, label: String) -> Element(Message) { case count { 0 -> html.text("") @@ -10522,13 +11524,18 @@ fn feature_choice_option_is_selected( selections: List(FeatureSelection), value: String, ) -> Bool { - list.any(selections, fn(selection) { - selection.value == value - && case string.contains(choice.id, ":expertise:") { - True -> string.contains(selection.id, ":expertise:") - False -> True - } - }) + case choice.id { + "class:starting-equipment" -> False + "origin:background:equipment" -> False + _ -> + list.any(selections, fn(selection) { + selection.value == value + && case string.contains(choice.id, ":expertise:") { + True -> string.contains(selection.id, ":expertise:") + False -> True + } + }) + } } fn origin_skill_is_selected( @@ -10747,78 +11754,6 @@ fn view_skill_proficiencies(model: Model) -> Element(Message) { ]) } -fn view_origins(model: Model) -> Element(Message) { - case model.origin_details { - Error(reason) -> - html.div([attribute.class("progression-state")], [ - html.p([], [html.text(reason)]), - ]) - Ok(details) -> - html.div([attribute.class("origin-view")], [ - html.div([attribute.class("origin-summary")], [ - stat_block( - "SPEED", - int.to_string(details.speed) <> " ft", - details.species_name, - ), - stat_block( - "SIZE", - case - feature_selection_value( - model.feature_selections, - "origin:species:size", - ) - { - "" -> details.size - selected -> selected - }, - details.species_name, - ), - stat_block("BACKGROUND", details.background_name, "2024 origin"), - ]), - case details.choices { - [] -> html.text("") - choices -> - html.div([attribute.class("progression-choices")], [ - html.h4([], [html.text("Origin choices")]), - html.div( - [attribute.class("progression-choice-grid")], - list.map(choices, fn(choice) { - view_feature_choice(choice, model) - }), - ), - ]) - }, - html.div([attribute.class("origin-columns")], [ - html.div([], [ - html.h3([], [html.text(details.species_name <> " traits")]), - html.div( - [attribute.class("feature-list")], - list.map(details.species_features, view_rule_feature), - ), - ]), - html.div([], [ - html.h3([], [html.text(details.background_name <> " features")]), - html.div( - [attribute.class("feature-list")], - list.map(details.background_features, view_rule_feature), - ), - ]), - ]), - ]) - } -} - -fn view_rule_feature(feature: RuleFeature) -> Element(Message) { - html.article([attribute.class("rule-feature")], [ - html.div([attribute.class("feature-heading")], [ - html.h4([], [html.text(feature.name)]), - html.span([], [html.text(feature.source)]), - ]), - html.p([], [html.text(feature.description)]), - ]) -} - fn coin_control(label: String, value: Int, coin: Coin) -> Element(Message) { html.label([attribute.class("coin-control")], [ html.span([], [html.text(label)]), @@ -10891,6 +11826,7 @@ fn pack_content_catalog_item( armor_base: 0, dexterity_cap: 99, description: "Included in " <> pack.name <> ".", + value: 0, contents: [], consumable: False, ) @@ -10913,6 +11849,98 @@ fn selected_inventory_additions( } } +fn add_inventory_selection( + model: Model, + selected: InventoryItem, +) -> List(EquipmentItem) { + selected_inventory_additions(selected, model.equipment_catalog) + |> list.reverse + |> list.fold(model.equipment, fn(equipment, addition) { + let #(item, quantity) = addition + add_equipment_quantity(equipment, item, quantity) + }) +} + +fn currency_value_in_copper(model: Model) -> Int { + model.platinum + * 1000 + + model.gold + * 100 + + model.electrum + * 50 + + model.silver + * 10 + + model.copper +} + +fn spend_currency(model: Model, cost: Int) -> Result(Model, Nil) { + let remaining = currency_value_in_copper(model) - cost + case cost <= 0 || remaining < 0 { + True -> Error(Nil) + False -> { + let platinum = + int.min( + model.platinum, + int.floor_divide(remaining, 1000) |> result.unwrap(0), + ) + let after_platinum = remaining - platinum * 1000 + let gold = + int.min( + model.gold, + int.floor_divide(after_platinum, 100) |> result.unwrap(0), + ) + let after_gold = after_platinum - gold * 100 + let electrum = + int.min( + model.electrum, + int.floor_divide(after_gold, 50) |> result.unwrap(0), + ) + let after_electrum = after_gold - electrum * 50 + let silver = + int.min( + model.silver, + int.floor_divide(after_electrum, 10) |> result.unwrap(0), + ) + let after_silver = after_electrum - silver * 10 + let copper = int.min(model.copper, after_silver) + let change = after_silver - copper + let change_platinum = + int.floor_divide(change, 1000) + |> result.unwrap(0) + let after_change_platinum = change - change_platinum * 1000 + let change_gold = + int.floor_divide(after_change_platinum, 100) + |> result.unwrap(0) + let after_change_gold = after_change_platinum - change_gold * 100 + let change_electrum = + int.floor_divide(after_change_gold, 50) + |> result.unwrap(0) + let after_change_electrum = after_change_gold - change_electrum * 50 + let change_silver = + int.floor_divide(after_change_electrum, 10) + |> result.unwrap(0) + let change_copper = after_change_electrum - change_silver * 10 + Ok( + Model( + ..model, + platinum: platinum + change_platinum, + gold: gold + change_gold, + electrum: electrum + change_electrum, + silver: silver + change_silver, + copper: copper + change_copper, + ), + ) + } + } +} + +fn can_buy_selected_equipment(model: Model) -> Bool { + case selected_equipment_catalog_item(model) { + Ok(item) -> item.value > 0 && currency_value_in_copper(model) >= item.value + Error(Nil) -> False + } +} + fn add_equipment_quantity( equipment: List(EquipmentItem), selected: InventoryItem, @@ -10953,6 +11981,27 @@ fn equipment_add_button_label(model: Model) -> String { } } +fn equipment_buy_button_label(model: Model) -> String { + case selected_equipment_catalog_item(model) { + Error(Nil) -> "Buy item" + Ok(item) -> "Buy · " <> equipment_price_label(item.value) + } +} + +fn equipment_price_label(copper: Int) -> String { + let gold = + int.floor_divide(copper, 100) + |> result.unwrap(0) + let remaining_copper = copper - gold * 100 + case gold, remaining_copper { + 0, 0 -> "No price" + 0, copper -> int.to_string(copper) <> " CP" + gold, 0 -> int.to_string(gold) <> " GP" + gold, copper -> + int.to_string(gold) <> " GP " <> int.to_string(copper) <> " CP" + } +} + fn merge_equipment_catalog( existing: List(InventoryItem), incoming: List(InventoryItem), @@ -11136,15 +12185,26 @@ fn view_equipment_catalog_picker(model: Model) -> Element(Message) { ], ), ]), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.disabled(model.equipment_choice == ""), - event.on_click(UserAddedEquipment), - ], - [html.text(equipment_add_button_label(model))], - ), + html.div([attribute.class("inventory-catalog-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(model.equipment_choice == ""), + event.on_click(UserAddedEquipment), + ], + [html.text(equipment_add_button_label(model))], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + attribute.disabled(!can_buy_selected_equipment(model)), + event.on_click(UserBoughtEquipment), + ], + [html.text(equipment_buy_button_label(model))], + ), + ]), ]), ]) } @@ -11164,17 +12224,79 @@ fn view_equipment(model: Model) -> Element(Message) { "Attack", ), stat_block( - "ITEMS", + "CARRYING", int.to_string(inventory_quantity(model.equipment)), - "Carried quantity", + "items recorded", ), stat_block( - "ATTUNED", + "ATTUNEMENT", int.to_string(attuned_item_count(model.item_resources)) <> " / 3", - "Magic items", + "magic items", ), ]), - html.div([attribute.class("inventory-toolbar")], [ + html.div([attribute.class("inventory-workbench")], [ + html.section([attribute.class("inventory-browser")], [ + html.div([attribute.class("inventory-browser-heading")], [ + html.div([], [ + html.span([attribute.class("sheet-overline")], [ + html.text("EQUIPMENT CATALOG"), + ]), + html.h2([], [html.text("Carried items")]), + ]), + html.span([attribute.class("inventory-count")], [ + html.text(int.to_string(list.length(model.equipment)) <> " ITEMS"), + ]), + ]), + html.div([attribute.class("inventory-adders")], [ + view_equipment_catalog_picker(model), + html.div([attribute.class("inventory-picker custom-item-picker")], [ + html.label([], [ + html.span([], [html.text("CUSTOM ITEM")]), + html.input([ + attribute.class("text-input"), + attribute.placeholder("Item name…"), + attribute.value(model.custom_equipment_name), + event.on_input(UserChangedCustomEquipment), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled( + string.trim(model.custom_equipment_name) == "", + ), + event.on_click(UserAddedCustomEquipment), + ], + [html.text("Add custom")], + ), + ]), + ]), + case model.equipment { + [] -> + html.div([attribute.class("inventory-empty")], [ + html.span([], [html.text("PACK")]), + html.h3([], [html.text("Your pack is empty")]), + html.p([], [ + html.text("Choose an item from the catalog above to add it."), + ]), + ]) + equipment -> + html.div( + [attribute.class("equipment-list")], + list.map(equipment, fn(item) { + view_inventory_item_row(model, item) + }), + ) + }, + ]), + view_inventory_inspector(model), + ]), + html.section([attribute.class("inventory-currency")], [ + html.div([], [ + html.span([attribute.class("sheet-overline")], [html.text("CURRENCY")]), + html.h3([], [html.text("Coin purse")]), + ]), html.div([attribute.class("currency-grid")], [ coin_control("PP", model.platinum, Platinum), coin_control("GP", model.gold, Gold), @@ -11182,239 +12304,245 @@ fn view_equipment(model: Model) -> Element(Message) { coin_control("SP", model.silver, Silver), coin_control("CP", model.copper, Copper), ]), - html.div([attribute.class("inventory-adders")], [ - view_equipment_catalog_picker(model), - html.div([attribute.class("inventory-picker custom-item-picker")], [ - html.label([], [ - html.span([], [html.text("CUSTOM ITEM")]), - html.input([ - attribute.class("text-input"), - attribute.placeholder("Item name…"), - attribute.value(model.custom_equipment_name), - event.on_input(UserChangedCustomEquipment), + ]), + html.div([attribute.class("inventory-auxiliary")], [ + html.div([attribute.class("proficiency-manager")], [ + html.div([attribute.class("proficiency-heading")], [ + html.div([], [ + html.h3([], [html.text("Tool & other proficiencies")]), + html.p([], [ + html.text("Record proficiencies not covered by skills."), ]), ]), + ]), + html.div([attribute.class("proficiency-picker")], [ + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedOtherProficiency), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.proficiency_choice == ""), + ], + "Choose a 2024 tool…", + ), + ..model.equipment_catalog + |> list.filter(fn(item) { item.category == "Tool" }) + |> list.map(fn(item) { + html.option( + [ + attribute.value(item.name), + attribute.selected(model.proficiency_choice == item.name), + attribute.disabled(source_is_blocked(model, item.source)), + ], + item.name, + ) + }) + ], + ), + html.input([ + attribute.class("text-input"), + attribute.placeholder("Or type another proficiency"), + attribute.value(model.proficiency_choice), + event.on_input(UserChangedOtherProficiency), + ]), html.button( [ attribute.type_("button"), attribute.class("secondary-button"), - attribute.disabled(string.trim(model.custom_equipment_name) == ""), - event.on_click(UserAddedCustomEquipment), + attribute.disabled(string.trim(model.proficiency_choice) == ""), + event.on_click(UserAddedOtherProficiency), ], - [html.text("Add custom")], + [html.text("Add")], ), ]), + html.div( + [attribute.class("proficiency-list")], + list.map(model.other_proficiencies, fn(proficiency) { + html.span([attribute.class("proficiency-chip")], [ + html.text(proficiency), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Roll " <> proficiency <> " check"), + event.on_click(UserOpenedDiceRoll( + proficiency <> " check", + d20_test_modifier( + proficiency_bonus(model.level), + exhaustion_level(model), + ), + )), + ], + [html.text("Roll")], + ), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Remove " <> proficiency), + event.on_click(UserRemovedOtherProficiency(proficiency)), + ], + [html.text("×")], + ), + ]) + }), + ), ]), - ]), - html.div([attribute.class("proficiency-manager")], [ - html.div([attribute.class("proficiency-heading")], [ + html.div([attribute.class("resource-manager armor-override-manager")], [ html.div([], [ - html.h3([], [html.text("Tool & other proficiencies")]), - html.p([], [ + html.h3([], [html.text("Armor Class override")]), + html.p([], [html.text("Use 0 for the calculated value.")]), + ]), + resource_control( + "AC OVERRIDE", + model.armor_class_override, + UserChangedArmorClass, + ), + ]), + ]), + ]) +} + +fn view_inventory_item_row( + model: Model, + item: EquipmentItem, +) -> Element(Message) { + let selected = model.inspected_equipment_name == item.name + html.article( + [ + attribute.classes([ + #("equipment-row", True), + #("equipped", item.equipped), + #("selected", selected), + ]), + ], + [ + html.label([attribute.class("equipment-toggle")], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(item.equipped), + attribute.disabled(item.quantity == 0), + attribute.aria_label("Equip " <> item.name), + event.on_check(fn(_) { UserToggledEquipment(item.name) }), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-description-button"), + event.on_click(UserInspectedEquipment(item.name)), + ], + [ + html.strong([], [html.text(item.name)]), + html.small([], [ html.text( - "Record proficiencies not covered by skills, such as Cook's Utensils.", + item.category + <> case item.equipped { + True -> " · EQUIPPED" + False -> "" + }, ), ]), + ], + ), + html.label([attribute.class("quantity-control")], [ + html.span([], [html.text("QTY")]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.max("99"), + attribute.value(int.to_string(item.quantity)), + event.on_input(fn(value) { + UserChangedEquipmentQuantity(item.name, value) + }), ]), ]), - html.div([attribute.class("proficiency-picker")], [ - html.select( - [ - attribute.class("select-input"), - event.on_change(UserChangedOtherProficiency), - ], - [ - html.option( + html.div([attribute.class("equipment-actions")], [ + case equipment_can_be_used(model, item) { + True -> + html.button( [ - attribute.value(""), - attribute.selected(model.proficiency_choice == ""), + attribute.type_("button"), + attribute.class("equipment-use-button"), + attribute.aria_label(equipment_use_button_aria_label( + model, + item.name, + )), + attribute.disabled(item.quantity == 0), + event.on_click(UserUsedEquipment(item.name)), ], - "Choose a 2024 tool…", - ), - ..model.equipment_catalog - |> list.filter(fn(item) { item.category == "Tool" }) - |> list.map(fn(item) { - html.option( - [ - attribute.value(item.name), - attribute.selected(model.proficiency_choice == item.name), - attribute.disabled(source_is_blocked(model, item.source)), - ], - item.name, - ) - }) - ], - ), - html.input([ - attribute.class("text-input"), - attribute.placeholder("Or type another proficiency"), - attribute.value(model.proficiency_choice), - event.on_input(UserChangedOtherProficiency), - ]), + [html.text(equipment_use_button_label(model, item.name))], + ) + False -> html.text("") + }, html.button( [ attribute.type_("button"), - attribute.class("secondary-button"), - attribute.disabled(string.trim(model.proficiency_choice) == ""), - event.on_click(UserAddedOtherProficiency), + attribute.class("equipment-delete-button"), + attribute.aria_label("Delete " <> item.name), + event.on_click(UserDeletedEquipment(item.name)), ], - [html.text("Add proficiency")], + [html.text("×")], ), ]), - html.div( - [attribute.class("proficiency-list")], - list.map(model.other_proficiencies, fn(proficiency) { - html.span([attribute.class("proficiency-chip")], [ - html.text(proficiency), - html.button( - [ - attribute.type_("button"), - attribute.class("proficiency-roll-button"), - attribute.aria_label("Roll " <> proficiency <> " check"), - event.on_click(UserOpenedDiceRoll( - proficiency <> " check", - d20_test_modifier( - proficiency_bonus(model.level), - exhaustion_level(model), - ), - )), - ], - [html.text("Roll")], - ), - html.button( - [ - attribute.type_("button"), - attribute.aria_label("Remove " <> proficiency), - event.on_click(UserRemovedOtherProficiency(proficiency)), - ], - [html.text("×")], - ), - ]) - }), - ), - ]), - html.div([attribute.class("resource-manager armor-override-manager")], [ - html.div([], [ - html.h3([], [html.text("Armor Class override")]), + ], + ) +} + +fn view_inventory_inspector(model: Model) -> Element(Message) { + case + list.find(model.equipment, fn(item) { + item.name == model.inspected_equipment_name + }) + { + Error(Nil) -> + html.aside([attribute.class("inventory-inspector empty-inspector")], [ + html.span([attribute.class("sheet-overline")], [html.text("INSPECT")]), + html.div([attribute.class("inventory-inspector-empty-mark")], [ + html.text("↗"), + ]), + html.h3([], [html.text("Select an item")]), html.p([], [ - html.text("Leave at 0 to calculate AC from Dexterity and equipment."), + html.text("Choose an item from your pack to inspect its rules."), ]), - ]), - html.div([attribute.class("resource-grid")], [ - resource_control( - "AC OVERRIDE", - model.armor_class_override, - UserChangedArmorClass, - ), - ]), - ]), - html.div( - [attribute.class("equipment-list")], - list.map(model.equipment, fn(item) { - html.article( - [ - attribute.classes([ - #("equipment-row", True), - #("equipped", item.equipped), - ]), - ], - [ - html.div([attribute.class("equipment-identity")], [ - html.label([attribute.class("equipment-toggle")], [ - html.input([ - attribute.type_("checkbox"), - attribute.checked(item.equipped), - attribute.disabled(item.quantity == 0), - attribute.aria_label("Equip " <> item.name), - event.on_check(fn(_) { UserToggledEquipment(item.name) }), - ]), - ]), - html.button( - [ - attribute.type_("button"), - attribute.class("equipment-description-button"), - event.on_click(UserInspectedEquipment(item.name)), - ], - [ - html.strong([], [html.text(item.name)]), - html.small([], [html.text(item.category)]), - ], - ), - ]), - html.label([attribute.class("quantity-control")], [ - html.span([], [html.text("QTY")]), - html.input([ - attribute.type_("number"), - attribute.min("0"), - attribute.max("99"), - attribute.value(int.to_string(item.quantity)), - event.on_input(fn(value) { - UserChangedEquipmentQuantity(item.name, value) - }), - ]), - ]), - view_item_resource_controls(model, item.name), - html.div([attribute.class("equipment-actions")], [ - case item.category { - "Tool" -> - html.button( - [ - attribute.type_("button"), - attribute.class("equipment-roll-button"), - attribute.aria_label("Roll " <> item.name <> " check"), - event.on_click(UserOpenedDiceRoll( - item.name <> " check", - d20_test_modifier( - case - list.contains(model.other_proficiencies, item.name) - { - True -> proficiency_bonus(model.level) - False -> 0 - }, - exhaustion_level(model), - ), - )), - ], - [html.text("Roll")], - ) - _ -> html.text("") - }, - case equipment_can_be_used(model, item) { - True -> - html.button( - [ - attribute.type_("button"), - attribute.class("equipment-use-button"), - attribute.aria_label(equipment_use_button_aria_label( - model, - item.name, - )), - attribute.disabled(item.quantity == 0), - event.on_click(UserUsedEquipment(item.name)), - ], - [html.text(equipment_use_button_label(model, item.name))], - ) - False -> html.text("") - }, - html.button( - [ - attribute.type_("button"), - attribute.class("equipment-delete-button"), - attribute.aria_label("Delete " <> item.name), - event.on_click(UserDeletedEquipment(item.name)), - ], - [html.text("×")], - ), + ]) + Ok(item) -> + html.aside([attribute.class("inventory-inspector")], [ + html.span([attribute.class("sheet-overline")], [ + html.text("ITEM DETAILS"), + ]), + html.div([attribute.class("inventory-inspector-mark")], [ + html.text(case item.category { + "Weapon" -> "⚔" + "Armor" | "Shield" -> "AC" + _ -> "◇" + }), + ]), + html.h2([], [html.text(item.name)]), + html.p([attribute.class("inventory-inspector-type")], [ + html.text(item.category), + ]), + html.div([attribute.class("inventory-inspector-stats")], [ + html.div([], [ + html.span([], [html.text("QUANTITY")]), + html.strong([], [html.text(int.to_string(item.quantity))]), + ]), + html.div([], [ + html.span([], [html.text("STATUS")]), + html.strong([], [ + html.text(case item.equipped { + True -> "Equipped" + False -> "Carried" + }), ]), - case model.inspected_equipment_name == item.name { - True -> view_equipment_description(model, item.name) - False -> html.text("") - }, - ], - ) - }), - ), - ]) + ]), + ]), + view_equipment_description(model, item.name), + view_item_resource_controls(model, item.name), + ]) + } } fn equipment_can_be_used(model: Model, item: EquipmentItem) -> Bool { @@ -11670,157 +12798,230 @@ fn companion_is_in_initiative( fn view_companions(model: Model) -> Element(Message) { let available = filtered_companion_catalog(model) html.div([attribute.class("companions-view")], [ + html.div([attribute.class("companion-summary")], [ + stat_block( + "ROSTER", + int.to_string(list.length(model.companions)), + "saved companions", + ), + stat_block( + "IN ENCOUNTER", + int.to_string( + model.companions + |> list.filter(fn(companion) { + companion_is_in_initiative(model, companion) + }) + |> list.length, + ), + "initiative ready", + ), + stat_block( + "CATALOG", + int.to_string(list.length(full_companion_catalog(model))), + "available creatures", + ), + ]), html.div([attribute.class("companion-catalog-manager")], [ - html.div([attribute.class("companion-section-heading")], [ - html.div([], [ - html.h3([], [html.text("Summons & companions")]), - html.p([], [ - html.text( - "Add a 5e.tools familiar, summoned creature, or object such as an Eldritch Cannon.", - ), - ]), - ]), - html.small([], [ - html.text( - int.to_string(list.length(available)) - <> " of " - <> int.to_string(list.length(full_companion_catalog(model))) - <> " companions", - ), - ]), + html.div([attribute.class("companion-type-tabs")], [ + companion_type_tab("All", "", model.companion_type_filter), + companion_type_tab("Familiars", "familiar", model.companion_type_filter), + companion_type_tab("Summoned", "summoned", model.companion_type_filter), + companion_type_tab("Objects", "object", model.companion_type_filter), ]), - html.div([attribute.class("companion-catalog-controls")], [ - html.label([], [ - html.span([], [html.text("COMPANION TYPE")]), - html.select( - [ - attribute.class("select-input"), - event.on_change(UserChangedCompanionTypeFilter), - ], - [ - html.option( - [ - attribute.value(""), - attribute.selected(model.companion_type_filter == ""), - ], - "All types", - ), - ..list.map( - [ - #("familiar", "Familiars"), - #("summoned", "Summoned creatures"), - #("object", "Objects"), - ], - fn(option) { - html.option( - [ - attribute.value(option.0), - attribute.selected( - model.companion_type_filter == option.0, - ), - ], - option.1, - ) - }, - ) - ], - ), - ]), - html.label([], [ - html.span([], [html.text("SEARCH COMPANIONS")]), - html.input([ - attribute.class("text-input"), - attribute.type_("search"), - attribute.placeholder("Owl, Fey Spirit, Eldritch Cannon…"), - attribute.value(model.companion_search), - event.on_input(UserChangedCompanionSearch), - ]), + html.label([attribute.class("companion-search")], [ + html.span([], [html.text("SEARCH")]), + html.input([ + attribute.class("text-input"), + attribute.type_("search"), + attribute.placeholder("Search companions…"), + attribute.value(model.companion_search), + event.on_input(UserChangedCompanionSearch), ]), - html.label([], [ - html.span([], [html.text("5E.TOOLS COMPANION")]), - html.select( - [ - attribute.class("select-input"), - event.on_change(UserSelectedCompanionCatalog), - ], - [ + ]), + html.label([attribute.class("companion-catalog-select")], [ + html.span([], [html.text("ADD FROM 5E.TOOLS")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(UserSelectedCompanionCatalog), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.companion_catalog_selection == ""), + ], + case model.rules_data_url, available { + "", _ -> "Configure rules data to load companions" + _, [] -> "No companions match" + _, _ -> "Choose a companion…" + }, + ), + ..list.map(available, fn(companion) { + let identifier = companion_catalog_identifier(companion) html.option( [ - attribute.value(""), - attribute.selected(model.companion_catalog_selection == ""), + attribute.value(identifier), + attribute.selected( + model.companion_catalog_selection == identifier, + ), + attribute.disabled(source_is_blocked(model, companion.source)), ], - case model.rules_data_url, available { - "", _ -> "Configure rules data to load companions" - _, [] -> "No companions match these filters" - _, _ -> "Choose a companion…" - }, - ), - ..list.map(available, fn(companion) { - let identifier = companion_catalog_identifier(companion) - html.option( - [ - attribute.value(identifier), - attribute.selected( - model.companion_catalog_selection == identifier, - ), - attribute.disabled(source_is_blocked( - model, - companion.source, - )), - ], - companion.name - <> " · " - <> companion_category_label(companion.category) - <> " · " - <> companion.source, - ) - }) - ], - ), - ]), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button companion-add-button"), - attribute.disabled(model.companion_catalog_selection == ""), - event.on_click(UserAddedCatalogCompanion), + companion.name + <> " · " + <> companion_category_label(companion.category), + ) + }) ], - [html.text("Add companion")], ), ]), - case selected_companion_catalog_entry(model) { - Error(Nil) -> html.text("") - Ok(companion) -> view_companion_catalog_preview(model, companion) - }, + html.button( + [ + attribute.type_("button"), + attribute.class("sheet-primary-button companion-add-button"), + attribute.disabled(model.companion_catalog_selection == ""), + event.on_click(UserAddedCatalogCompanion), + ], + [html.text("Add to roster")], + ), ]), - view_custom_companion_form(model), - html.div([attribute.class("companion-list")], [ - keyed.div( - [attribute.class("companion-card-list")], - case model.companions { - [] -> [ - #( - "empty", - html.div([attribute.class("companion-empty-state")], [ - html.strong([], [html.text("No companions yet")]), - html.p([], [ - html.text( - "Saved companions stay with this character and can be added to the live initiative table.", - ), + case selected_companion_catalog_entry(model) { + Error(Nil) -> html.text("") + Ok(companion) -> + html.details([attribute.class("companion-catalog-preview-wrap")], [ + html.summary([], [html.text("Preview catalog companion")]), + view_companion_catalog_preview(model, companion), + ]) + }, + html.div([attribute.class("companion-workbench")], [ + html.section([attribute.class("companion-roster")], [ + html.div([attribute.class("companion-section-heading")], [ + html.div([], [ + html.span([attribute.class("sheet-overline")], [ + html.text("ACTIVE ROSTER"), + ]), + html.h2([], [html.text("Companions")]), + ]), + html.small([], [ + html.text(int.to_string(list.length(model.companions)) <> " SAVED"), + ]), + ]), + keyed.div( + [attribute.class("companion-card-list")], + case model.companions { + [] -> [ + #( + "empty", + html.div([attribute.class("companion-empty-state")], [ + html.strong([], [html.text("No companions yet")]), + html.p([], [ + html.text( + "Add a familiar, summon, construct, or campaign ally.", + ), + ]), ]), - ]), - ), - ] - companions -> - list.map(companions, fn(companion) { - #(companion.id, view_character_companion(model, companion)) - }) - }, - ), + ), + ] + companions -> + list.map(companions, fn(companion) { + #(companion.id, view_character_companion(model, companion)) + }) + }, + ), + ]), + view_companion_inspector(model), + ]), + html.details([attribute.class("custom-companion-disclosure")], [ + html.summary([], [html.text("+ Add a custom companion")]), + view_custom_companion_form(model), ]), ]) } +fn companion_type_tab( + label: String, + value: String, + selected: String, +) -> Element(Message) { + html.button( + [ + attribute.type_("button"), + attribute.class(case value == selected { + True -> "active" + False -> "" + }), + attribute.aria_pressed(case value == selected { + True -> "true" + False -> "false" + }), + event.on_click(UserChangedCompanionTypeFilter(value)), + ], + [html.text(label)], + ) +} + +fn view_companion_inspector(model: Model) -> Element(Message) { + case + list.find(model.companions, fn(companion) { + companion.id == model.inspected_companion_id + }) + { + Error(Nil) -> + html.aside([attribute.class("companion-inspector empty-inspector")], [ + html.span([attribute.class("sheet-overline")], [html.text("DETAILS")]), + html.div([attribute.class("companion-inspector-mark")], [html.text("✦")]), + html.h3([], [html.text("Select a companion")]), + html.p([], [ + html.text("Choose a companion to view its profile and actions."), + ]), + ]) + Ok(companion) -> + html.aside([attribute.class("companion-inspector")], [ + html.span([attribute.class("sheet-overline")], [html.text("PROFILE")]), + html.div([attribute.class("companion-inspector-mark")], [ + html.text(companion_initials(companion.name)), + ]), + html.h2([], [html.text(companion.name)]), + html.p([], [html.text(companion.source)]), + html.div([attribute.class("companion-preview-stats")], [ + html.span([], [ + html.strong([], [ + html.text(int.to_string(companion.maximum_hit_points)), + ]), + html.text(" HP"), + ]), + html.span([], [ + html.strong([], [html.text(int.to_string(companion.armor_class))]), + html.text(" AC"), + ]), + html.span([], [ + html.strong([], [ + html.text(signed(companion.initiative_bonus)), + ]), + html.text(" INIT"), + ]), + ]), + case saved_companion_catalog_entry(model, companion) { + Ok(catalog_entry) -> + view_companion_catalog_preview(model, catalog_entry) + Error(Nil) -> + html.p([], [ + html.text("Custom companion. Record details in character notes."), + ]) + }, + ]) + } +} + +fn companion_initials(name: String) -> String { + name + |> string.split(" ") + |> list.take(2) + |> list.map(fn(part) { string.slice(part, 0, 1) }) + |> string.concat + |> string.uppercase +} + fn view_companion_catalog_preview( model: Model, companion: CompanionCatalogEntry, @@ -11934,116 +13135,361 @@ fn view_custom_companion_form(model: Model) -> Element(Message) { ), html.button( [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.disabled(string.trim(model.custom_companion_name) == ""), - event.on_click(UserAddedCustomCompanion), + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(string.trim(model.custom_companion_name) == ""), + event.on_click(UserAddedCustomCompanion), + ], + [html.text("Add custom companion")], + ), + ]), + ]) +} + +fn view_character_companion( + model: Model, + companion: CharacterCompanion, +) -> Element(Message) { + let in_initiative = companion_is_in_initiative(model, companion) + html.article( + [ + attribute.classes([ + #("companion-card", True), + #("selected", model.inspected_companion_id == companion.id), + ]), + ], + [ + html.button( + [ + attribute.type_("button"), + attribute.class("companion-card-heading"), + event.on_click(UserInspectedCompanion(companion.id)), + ], + [ + html.div([], [ + html.span([attribute.class("companion-avatar")], [ + html.text(companion_initials(companion.name)), + ]), + html.div([], [ + html.h4([], [html.text(companion.name)]), + html.small([], [html.text(companion.source)]), + ]), + ]), + html.span([], [ + html.text("AC " <> int.to_string(companion.armor_class)), + ]), + ], + ), + html.div([attribute.class("companion-card-controls")], [ + field( + "Max HP", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("1"), + attribute.value(int.to_string(companion.maximum_hit_points)), + event.on_input(fn(value) { + UserChangedCompanionMaximumHitPoints(companion.id, value) + }), + ]), + ), + field( + "Initiative bonus", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("-99"), + attribute.max("99"), + attribute.value(int.to_string(companion.initiative_bonus)), + event.on_input(fn(value) { + UserChangedCompanionInitiativeBonus(companion.id, value) + }), + ]), + ), + ]), + html.div([attribute.class("companion-card-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + attribute.disabled(in_initiative), + event.on_click(UserAddedCompanionToInitiative(companion.id)), + ], + [ + html.text(case in_initiative { + True -> "In initiative" + False -> "Add to initiative" + }), + ], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-delete-button"), + event.on_click(UserRemovedCompanion(companion.id)), + ], + [html.text("Remove")], + ), + ]), + ], + ) +} + +fn update_active_note( + model: Model, + update_note: fn(CharacterNote) -> CharacterNote, +) -> Model { + Model( + ..model, + notes: list.map(model.notes, fn(note) { + case note.id == model.active_note_id { + True -> update_note(note) + False -> note + } + }), + ) +} + +fn note_category_value(category: NoteCategory) -> String { + case category { + SessionNote -> "session" + PeopleNote -> "people" + QuestNote -> "quest" + LocationNote -> "location" + } +} + +fn note_category_from_value(value: String) -> Result(NoteCategory, Nil) { + case value { + "session" -> Ok(SessionNote) + "people" -> Ok(PeopleNote) + "quest" -> Ok(QuestNote) + "location" -> Ok(LocationNote) + _ -> Error(Nil) + } +} + +fn note_category_label(category: NoteCategory) -> String { + case category { + SessionNote -> "Session log" + PeopleNote -> "NPCs & factions" + QuestNote -> "Quest" + LocationNote -> "Location" + } +} + +fn note_filter_default_category(filter: NoteFilter) -> NoteCategory { + case filter { + AllNotes | SessionNotes -> SessionNote + PeopleNotes -> PeopleNote + QuestNotes -> QuestNote + LocationNotes -> LocationNote + } +} + +fn note_filter_matches(filter: NoteFilter, category: NoteCategory) -> Bool { + case filter { + AllNotes -> True + SessionNotes -> category == SessionNote + PeopleNotes -> category == PeopleNote + QuestNotes -> category == QuestNote + LocationNotes -> category == LocationNote + } +} + +fn filtered_notes( + notes: List(CharacterNote), + filter: NoteFilter, +) -> List(CharacterNote) { + list.filter(notes, fn(note) { note_filter_matches(filter, note.category) }) +} + +fn active_note(model: Model) -> Result(CharacterNote, Nil) { + list.find(model.notes, fn(note) { note.id == model.active_note_id }) +} + +fn note_filter_button( + model: Model, + filter: NoteFilter, + label: String, +) -> Element(Message) { + let count = model.notes |> filtered_notes(filter) |> list.length + html.button( + [ + attribute.type_("button"), + attribute.classes([#("active", model.active_note_filter == filter)]), + attribute.aria_pressed(case model.active_note_filter == filter { + True -> "true" + False -> "false" + }), + event.on_click(UserSelectedNoteFilter(filter)), + ], + [ + html.span([], [html.text(label)]), + html.strong([], [html.text(int.to_string(count))]), + ], + ) +} + +fn note_display_title(note: CharacterNote) -> String { + case string.trim(note.title) { + "" -> "Untitled note" + title -> title + } +} + +fn view_note_editor(note: CharacterNote) -> Element(Message) { + html.div([attribute.class("note-editor")], [ + html.div([attribute.class("note-editor-toolbar")], [ + html.label([], [ + html.span([], [html.text("CATEGORY")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedNoteCategory), + ], + list.map( + [ + #(SessionNote, "Session log"), + #(PeopleNote, "NPCs & factions"), + #(QuestNote, "Quest"), + #(LocationNote, "Location"), + ], + fn(option) { + let #(category, label) = option + html.option( + [ + attribute.value(note_category_value(category)), + attribute.selected(note.category == category), + ], + label, + ) + }, + ), + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("note-delete-button"), + event.on_click(UserDeletedNote(note.id)), + ], + [html.text("Delete note")], + ), + ]), + html.label([attribute.class("notes-title-field")], [ + html.span([], [html.text("TITLE")]), + html.input([ + attribute.class("text-input"), + attribute.placeholder("Give this note a clear title"), + attribute.value(note.title), + event.on_input(UserChangedNoteTitle), + ]), + ]), + html.label([attribute.class("notes-field")], [ + html.span([], [html.text("NOTE")]), + html.textarea( + [ + attribute.placeholder( + "Record discoveries, allies, promises, clues, and open questions…", + ), + attribute.value(note.body), + event.on_input(UserChangedNoteBody), ], - [html.text("Add custom companion")], + note.body, ), ]), ]) } -fn view_character_companion( - model: Model, - companion: CharacterCompanion, +fn view_note_cards( + notes: List(CharacterNote), + active_note_id: String, ) -> Element(Message) { - let in_initiative = companion_is_in_initiative(model, companion) - html.article([attribute.class("companion-card")], [ - html.div([attribute.class("companion-card-heading")], [ - html.div([], [ - html.h4([], [html.text(companion.name)]), - html.small([], [html.text(companion.source)]), - ]), - html.span([], [ - html.text("AC " <> int.to_string(companion.armor_class)), - ]), - ]), - html.div([attribute.class("companion-card-controls")], [ - field( - "Max HP", - html.input([ - attribute.class("text-input"), - attribute.type_("number"), - attribute.min("1"), - attribute.value(int.to_string(companion.maximum_hit_points)), - event.on_input(fn(value) { - UserChangedCompanionMaximumHitPoints(companion.id, value) - }), - ]), - ), - field( - "Initiative bonus", - html.input([ - attribute.class("text-input"), - attribute.type_("number"), - attribute.min("-99"), - attribute.max("99"), - attribute.value(int.to_string(companion.initiative_bonus)), - event.on_input(fn(value) { - UserChangedCompanionInitiativeBonus(companion.id, value) - }), - ]), - ), - ]), - case saved_companion_catalog_entry(model, companion) { - Ok(catalog_entry) -> - html.details([attribute.class("companion-details")], [ - html.summary([], [html.text("View stat block")]), - view_companion_catalog_preview(model, catalog_entry), - ]) - Error(Nil) -> html.text("") - }, - html.div([attribute.class("companion-card-actions")], [ + html.div( + [attribute.class("notes-card-grid")], + list.map(notes, fn(note) { html.button( [ attribute.type_("button"), - attribute.class("primary-button"), - attribute.disabled(in_initiative), - event.on_click(UserAddedCompanionToInitiative(companion.id)), - ], - [ - html.text(case in_initiative { - True -> "In initiative" - False -> "Add to initiative" - }), + attribute.classes([ + #("note-card", True), + #("selected", note.id == active_note_id), + ]), + event.on_click(UserSelectedNote(note.id)), ], - ), - html.button( [ - attribute.type_("button"), - attribute.class("equipment-delete-button"), - event.on_click(UserRemovedCompanion(companion.id)), + html.div([], [ + html.span([], [html.text(note_category_label(note.category))]), + html.small([], [html.text("LOCAL NOTE")]), + ]), + html.h3([], [html.text(note_display_title(note))]), + html.p([], [ + html.text(case string.trim(note.body) { + "" -> "No details yet." + body -> body + }), + ]), ], - [html.text("Remove")], - ), - ]), - ]) + ) + }), + ) } fn view_notes(model: Model) -> Element(Message) { + let notes = filtered_notes(model.notes, model.active_note_filter) html.div([attribute.class("notes-view")], [ - html.div([attribute.class("notes-heading")], [ - html.h3([], [html.text("Character notes")]), - html.p([], [ - html.text( - "Keep campaign details, allies, factions, conditions, and magic item effects with this character.", - ), + html.aside([attribute.class("notes-category-panel")], [ + html.span([attribute.class("sheet-overline")], [html.text("NOTEBOOK")]), + html.h2([], [html.text("Categories")]), + note_filter_button(model, AllNotes, "All notes"), + note_filter_button(model, SessionNotes, "Session log"), + note_filter_button(model, PeopleNotes, "NPCs & factions"), + note_filter_button(model, QuestNotes, "Quests"), + note_filter_button(model, LocationNotes, "Locations"), + html.div([attribute.class("notes-prompt")], [ + html.span([], [html.text("WRITING PROMPT")]), + html.p([], [ + html.text("What changed for your character in the last session?"), + ]), ]), ]), - html.label([attribute.class("notes-field")], [ - html.span([], [html.text("NOTES")]), - html.textarea( - [ - attribute.placeholder( - "Allies, factions, magic item effects, conditions, campaign notes…", - ), - attribute.value(model.notes), - event.on_input(UserChangedNotes), - ], - model.notes, - ), + html.section([attribute.class("notes-workspace")], [ + html.div([attribute.class("notes-heading")], [ + html.div([], [ + html.span([attribute.class("sheet-overline")], [ + html.text("CAMPAIGN RECORD"), + ]), + html.h2([], [html.text("Character notes")]), + html.p([], [ + html.text( + "Create separate notes for sessions, people, quests, and locations.", + ), + ]), + ]), + html.div([attribute.class("notes-heading-actions")], [ + html.span([attribute.class("sheet-saved-indicator")], [ + html.span([attribute.class("sheet-status-light")], []), + html.text("Saved locally"), + ]), + ui.primary_button("+ New note", UserCreatedNote), + ]), + ]), + case active_note(model) { + Ok(note) -> view_note_editor(note) + Error(Nil) -> html.text("") + }, + case notes { + [] -> + html.div([attribute.class("notes-empty-state")], [ + html.span([], [html.text("✎")]), + html.h3([], [html.text("No notes in this category")]), + html.p([], [ + html.text("Create a note to start a separate campaign thread."), + ]), + ]) + notes -> view_note_cards(notes, model.active_note_id) + }, ]), ]) } @@ -12285,159 +13731,311 @@ fn view_selected_feat_description( } } -fn view_spells(model: Model) -> Element(Message) { - let maximum_level = - maximum_spell_level(model.class_name, model.subclass_name, model.level) - let available = - model.spell_catalog - |> list.filter(fn(spell) { - spell_matches_filters(model, spell, maximum_level) - }) - |> list.sort(fn(left, right) { compare_spells(model, left, right) }) - let prepared = - model.spells - |> list.filter(fn(spell) { spell.level > 0 }) - |> list.length - let cantrips = - model.spells - |> list.filter(fn(spell) { spell.level == 0 }) - |> list.length - let capacity = prepared_spell_capacity(model) - let cantrip_capacity = cantrip_capacity(model) - html.div([attribute.class("spell-view")], [ - html.div([attribute.class("spell-summary")], [ - stat_block( - "SPELL LEVEL", - int.to_string(maximum_level), - case model.subclass_name { - "" -> model.class_name - subclass_name -> subclass_name - }, - ), - stat_block( - "PREPARED", - int.to_string(prepared), - "of " <> int.to_string(capacity), - ), - stat_block( - "SAVE DC", - int.to_string(spell_save_dc(model)), - spellcasting_ability_name(model.class_name), - ), - ]), - case spell_selection_incomplete(model) { - True -> - html.div([attribute.class("incomplete-callout")], [ - incomplete_indicator(1, "Spell selection"), - html.span([], [html.text("Choose at least one spell")]), - ]) - False -> html.text("") - }, - view_spell_controls(model, maximum_level, list.length(available)), - case maximum_level { - 0 -> - html.div([attribute.class("progression-state")], [ - html.p([], [ - html.text( - "This class does not have a spell list at the current level.", - ), - ]), - ]) - _ -> - case available { - [] -> - html.div([attribute.class("progression-state spell-empty")], [ - html.p([], [ - html.text("No spells match the current search and filters."), - ]), - ]) - _ -> - html.div( - [attribute.class("spell-list")], - list.map(available, fn(spell) { - let selected = spell_is_selected(model, spell.name) - let selection_full = case spell.level { - 0 -> cantrips >= cantrip_capacity - _ -> prepared >= capacity - } - html.article( - [ - attribute.classes([ - #("spell-row", True), - #("selected", selected), - ]), - ], - [ - html.input([ - attribute.type_("checkbox"), - attribute.checked(selected), - attribute.aria_label(case selected { - True -> "Remove " <> spell.name - False -> "Prepare " <> spell.name - }), - attribute.disabled( - !selected - && { - selection_full - || source_is_blocked(model, spell.source) - }, - ), - event.on_check(fn(_) { - UserToggledSpell(spell.name, spell.level) - }), - ]), - html.span([attribute.class("spell-level")], [ - html.text(case spell.level { - 0 -> "C" - level -> int.to_string(level) - }), - ]), - html.div([attribute.class("spell-main")], [ - html.strong([], [html.text(spell.name)]), - html.small([], [ - html.text( - spell.school - <> case spell.ritual { - True -> " · Ritual" - False -> "" - }, - ), - ]), - html.div([attribute.class("spell-metadata")], [ - spell_metadata("Casting", spell.casting_time), - spell_metadata("Range", spell.range), - spell_metadata("Components", spell.components), - spell_metadata("Duration", spell.duration), - ]), - ]), - html.p([attribute.class("spell-description")], [ - html.text(spell.description), - ]), - case selected { - True -> - html.button( - [ - attribute.type_("button"), - attribute.class("spell-cast-button"), - attribute.aria_label("Cast " <> spell.name), - event.on_click(UserOpenedSpellCast( - spell.name, - spell.level, - )), - ], - [html.text("Cast")], - ) - False -> html.text("") - }, - ], - ) - }), - ) - } - }, - ]) -} - +fn view_spells(model: Model) -> Element(Message) { + let maximum_level = + maximum_spell_level(model.class_name, model.subclass_name, model.level) + let available = + model.spell_catalog + |> list.filter(fn(spell) { + spell_matches_filters(model, spell, maximum_level) + }) + |> list.sort(fn(left, right) { compare_spells(model, left, right) }) + let prepared = + model.spells + |> list.filter(fn(spell) { spell.level > 0 }) + |> list.length + let capacity = prepared_spell_capacity(model) + let spell_levels = + [1, 2, 3, 4, 5, 6, 7, 8, 9] + |> list.filter(fn(level) { spell_slot_maximum(model, level) > 0 }) + + html.div([attribute.class("spell-view")], [ + html.div([attribute.class("spell-summary")], [ + stat_block( + "SPELLCASTING", + spellcasting_ability_name(model.class_name), + case model.subclass_name { + "" -> model.class_name + subclass_name -> subclass_name + }, + ), + stat_block( + "PREPARED", + int.to_string(prepared) <> " / " <> int.to_string(capacity), + "spells ready", + ), + stat_block("SAVE DC", int.to_string(spell_save_dc(model)), "spell save"), + stat_block("ATTACK", signed(spell_save_dc(model) - 8), "spell attack"), + ]), + html.div([attribute.class("spellbook-workbench")], [ + html.aside([attribute.class("spell-slot-panel")], [ + html.span([attribute.class("sheet-overline")], [ + html.text("SPELL SLOTS"), + ]), + html.h2([], [html.text("Daily magic")]), + case spell_levels { + [] -> + html.p([attribute.class("spell-slot-empty")], [ + html.text("No spell slots at this level."), + ]) + levels -> + html.div( + [attribute.class("spell-slot-list")], + list.map(levels, fn(level) { view_spell_slot_row(model, level) }), + ) + }, + html.div([attribute.class("spell-slot-actions")], [ + html.button( + [ + attribute.type_("button"), + event.on_click(UserOpenedRest(ShortRest)), + ], + [html.text("Short rest")], + ), + html.button( + [ + attribute.type_("button"), + event.on_click(UserOpenedRest(LongRest)), + ], + [html.text("Long rest")], + ), + ]), + ]), + html.section([attribute.class("spell-catalog-panel")], [ + view_spell_controls(model, maximum_level, list.length(available)), + case spell_selection_incomplete(model) { + True -> + html.div([attribute.class("incomplete-callout")], [ + incomplete_indicator(1, "Spell selection"), + html.span([], [html.text("Choose at least one spell")]), + ]) + False -> html.text("") + }, + case maximum_level { + 0 -> + html.div([attribute.class("progression-state")], [ + html.p([], [ + html.text( + "This class does not have a spell list at the current level.", + ), + ]), + ]) + _ -> + case available { + [] -> + html.div([attribute.class("progression-state spell-empty")], [ + html.p([], [ + html.text("No spells match the current search and filters."), + ]), + ]) + spells -> + html.div( + [attribute.class("spell-list")], + list.map(spells, fn(spell) { + view_spell_catalog_row(model, spell) + }), + ) + } + }, + ]), + view_spell_inspector(model, available), + ]), + ]) +} + +fn view_spell_slot_row(model: Model, level: Int) -> Element(Message) { + let maximum = spell_slot_maximum(model, level) + let spent = spell_slot_spent(model, level) + let ready = maximum - spent + html.div([attribute.class("spell-slot-row")], [ + html.div([], [ + html.span([], [html.text("LEVEL " <> int.to_string(level))]), + html.strong([], [ + html.text(int.to_string(ready) <> " / " <> int.to_string(maximum)), + ]), + ]), + html.div( + [attribute.class("spell-slot-pips")], + levels() + |> list.take(maximum) + |> list.map(fn(index) { + html.span( + [ + attribute.class(case index <= ready { + True -> "ready" + False -> "spent" + }), + ], + [], + ) + }), + ), + html.div([attribute.class("spell-slot-stepper")], [ + html.button( + [ + attribute.type_("button"), + attribute.aria_label( + "Spend level " <> int.to_string(level) <> " slot", + ), + attribute.disabled(spent >= maximum), + event.on_click(UserSpentSpellSlot(level)), + ], + [html.text("−")], + ), + html.button( + [ + attribute.type_("button"), + attribute.aria_label( + "Restore level " <> int.to_string(level) <> " slot", + ), + attribute.disabled(spent == 0), + event.on_click(UserRestoredSpellSlot(level)), + ], + [html.text("+")], + ), + ]), + ]) +} + +fn view_spell_catalog_row(model: Model, spell: Spell) -> Element(Message) { + let selected = spell_is_selected(model, spell.name) + let inspected = model.inspected_spell_name == spell.name + let prepared_count = + model.spells + |> list.filter(fn(selected_spell) { selected_spell.level > 0 }) + |> list.length + let cantrip_count = + model.spells + |> list.filter(fn(selected_spell) { selected_spell.level == 0 }) + |> list.length + let selection_full = case spell.level { + 0 -> cantrip_count >= cantrip_capacity(model) + _ -> prepared_count >= prepared_spell_capacity(model) + } + html.article( + [ + attribute.classes([ + #("spell-row", True), + #("selected", selected), + #("inspected", inspected), + ]), + ], + [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(selected), + attribute.aria_label(case selected { + True -> "Remove " <> spell.name + False -> "Prepare " <> spell.name + }), + attribute.disabled( + !selected + && { selection_full || source_is_blocked(model, spell.source) }, + ), + event.on_check(fn(_) { UserToggledSpell(spell.name, spell.level) }), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("spell-row-selector"), + event.on_click(UserInspectedSpell(spell.name)), + ], + [ + html.span([attribute.class("spell-level")], [ + html.text(case spell.level { + 0 -> "C" + level -> int.to_string(level) + }), + ]), + html.div([attribute.class("spell-main")], [ + html.strong([], [html.text(spell.name)]), + html.small([], [ + html.text( + spell.school + <> case spell.ritual { + True -> " · Ritual" + False -> "" + }, + ), + ]), + ]), + ], + ), + case selected { + True -> + html.button( + [ + attribute.type_("button"), + attribute.class("spell-cast-button"), + attribute.aria_label("Cast " <> spell.name), + event.on_click(UserOpenedSpellCast(spell.name, spell.level)), + ], + [html.text("Cast")], + ) + False -> html.text("") + }, + ], + ) +} + +fn view_spell_inspector( + model: Model, + available: List(Spell), +) -> Element(Message) { + let selected = case + list.find(available, fn(spell) { spell.name == model.inspected_spell_name }) + { + Ok(spell) -> Ok(spell) + Error(Nil) -> list.first(available) + } + case selected { + Error(Nil) -> + html.aside([attribute.class("spell-inspector empty-inspector")], [ + html.span([attribute.class("sheet-overline")], [html.text("SPELL")]), + html.h3([], [html.text("Select a spell")]), + html.p([], [html.text("Spell rules and casting details appear here.")]), + ]) + Ok(spell) -> + html.aside([attribute.class("spell-inspector")], [ + html.span([attribute.class("sheet-overline")], [ + html.text(case spell.level { + 0 -> "CANTRIP" + level -> "LEVEL " <> int.to_string(level) <> " SPELL" + }), + ]), + html.h2([], [html.text(spell.name)]), + html.p([attribute.class("spell-inspector-school")], [ + html.text(spell.school), + ]), + html.div([attribute.class("spell-metadata")], [ + spell_metadata("Casting", spell.casting_time), + spell_metadata("Range", spell.range), + spell_metadata("Components", spell.components), + spell_metadata("Duration", spell.duration), + ]), + html.p([attribute.class("spell-description")], [ + html.text(spell.description), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("sheet-primary-button"), + event.on_click(case spell_is_selected(model, spell.name) { + True -> UserOpenedSpellCast(spell.name, spell.level) + False -> UserToggledSpell(spell.name, spell.level) + }), + ], + [ + html.text(case spell_is_selected(model, spell.name) { + True -> "Cast spell" + False -> "Prepare spell" + }), + ], + ), + ]) + } +} + fn view_spell_controls( model: Model, maximum_level: Int, @@ -12671,295 +14269,6 @@ fn view_level_up(model: Model) -> Element(Message) { } } -fn view_sheet(model: Model) -> Element(Message) { - let hit_die = class_hit_die(model.class_name) - let hit_points = character_hit_points(model) - let armor_class = character_armor_class(model) - let initiative = modifier(effective_ability_score(model, Dexterity)) - let passive_perception = - 10 - + modifier(effective_ability_score(model, Wisdom)) - + case list.contains(model.skills, "Perception") { - True -> proficiency_bonus(model.level) - False -> 0 - } - let display_name = case string.trim(model.character_name) { - "" -> "Unnamed Adventurer" - name -> name - } - - html.aside([attribute.class("sheet")], [ - html.div([attribute.class("sheet-accent")], []), - view_character_portrait(model, display_name), - html.div([attribute.class("sheet-title")], [ - html.span([attribute.class("eyebrow")], [html.text("Character preview")]), - html.h2([], [html.text(display_name)]), - html.p([], [ - html.text( - model.species_name - <> " · " - <> model.background_name - <> " " - <> model.class_name, - ), - ]), - ]), - html.div([attribute.class("combat-grid")], [ - stat_block( - "ARMOR CLASS", - int.to_string(armor_class), - case model.armor_class_override > 0 { - True -> "Manual override" - False -> equipped_armor_name(model.equipment) - }, - ), - health_stat_block(model, hit_points), - rollable_stat_block( - "INITIATIVE", - d20_test_modifier(initiative, exhaustion_level(model)), - "Dexterity", - "Initiative", - ), - stat_block( - "PROF. BONUS", - signed(proficiency_bonus(model.level)), - "By level", - ), - ]), - view_final_ability_scores(model), - view_health_history(model), - html.div([attribute.class("sheet-section")], [ - html.div([attribute.class("sheet-section-title")], [ - html.h3([], [html.text("Saving throws")]), - html.span([], [html.text("Class proficiencies")]), - ]), - html.div( - [attribute.class("saving-throws")], - list.map(ability_list(), fn(ability) { - let proficient = class_save_proficient(model.class_name, ability) - let value = - modifier(effective_ability_score(model, ability)) - + case proficient { - True -> proficiency_bonus(model.level) - False -> 0 - } - html.div([], [ - html.span( - [attribute.classes([#("proficiency", True), #("on", proficient)])], - [], - ), - html.span([], [html.text(ability_name(ability))]), - html.button( - [ - attribute.type_("button"), - attribute.class("roll-modifier"), - attribute.aria_label( - "Roll " <> ability_name(ability) <> " saving throw", - ), - event.on_click(UserOpenedDiceRoll( - ability_name(ability) <> " saving throw", - d20_test_modifier(value, exhaustion_level(model)), - )), - ], - [html.text(signed(value))], - ), - ]) - }), - ), - ]), - html.div([attribute.class("sheet-section quick-stats")], [ - html.div([], [ - html.span([], [html.text("PASSIVE PERCEPTION")]), - html.strong([], [html.text(int.to_string(passive_perception))]), - ]), - html.div([], [ - html.span([], [html.text("HIT DIE")]), - html.strong([], [html.text("d" <> int.to_string(hit_die))]), - ]), - html.div([], [ - html.span([], [html.text("SPEED")]), - html.strong([], [ - html.text(int.to_string(character_speed(model)) <> " ft"), - ]), - ]), - ]), - html.div([attribute.class("source-card")], [ - html.span([attribute.class("source-icon")], [html.text("24")]), - html.div([], [ - html.strong([], [html.text("2024 rules data")]), - html.p([], [html.text("Species · backgrounds · classes · spells")]), - ]), - ]), - ]) -} - -fn view_final_ability_scores(model: Model) -> Element(Message) { - html.div([attribute.class("sheet-section final-ability-scores")], [ - html.div([attribute.class("sheet-section-title")], [ - html.h3([], [html.text("Final ability scores")]), - html.span([], [html.text("All modifiers applied")]), - ]), - html.div( - [attribute.class("final-ability-grid")], - list.map(ability_list(), fn(ability) { - let base = ability_score(model.abilities, ability) - let final = effective_ability_score(model, ability) - let adjustment = final - base - html.div([attribute.class("final-ability-stat")], [ - html.span([], [ - html.text( - ability_abbreviation(ability) <> " · " <> ability_name(ability), - ), - ]), - html.strong([], [html.text(int.to_string(final))]), - html.small([], [html.text(signed(modifier(final)) <> " modifier")]), - html.p([], [ - html.text( - int.to_string(base) - <> " base" - <> case adjustment { - 0 -> "" - adjustment -> " " <> signed(adjustment) <> " bonuses" - }, - ), - ]), - ]) - }), - ), - ]) -} - -fn view_health_history(model: Model) -> Element(Message) { - let constitution = modifier(effective_ability_score(model, Constitution)) - let tough_bonus = case has_feat(model, "Tough") { - True -> 2 - False -> 0 - } - let increases = - model.health_increases - |> list.filter(fn(increase) { increase.level <= model.level }) - |> list.sort(fn(left, right) { int.compare(left.level, right.level) }) - - html.div([attribute.class("sheet-section health-history")], [ - html.div([attribute.class("sheet-section-title")], [ - html.h3([], [html.text("Health gains")]), - html.span([], [ - html.text("Hit Die d" <> int.to_string(class_hit_die(model.class_name))), - ]), - ]), - html.div([attribute.class("health-history-list")], [ - health_history_row( - level: 1, - method: "Maximum", - die_value: class_hit_die(model.class_name), - constitution:, - tough_bonus:, - action: html.span([], []), - ), - ..list.map(increases, fn(increase) { - let HealthIncrease(level:, die_value:, rolled:) = increase - health_history_row( - level:, - method: case rolled { - True -> "Rolled" - False -> "Average" - }, - die_value:, - constitution:, - tough_bonus:, - action: html.button( - [ - attribute.type_("button"), - attribute.class("health-history-remove"), - attribute.aria_label( - "Remove level " <> int.to_string(level) <> " health gain", - ), - event.on_click(UserRemovedHealthIncrease(level)), - ], - [html.text("×")], - ), - ) - }) - ]), - ]) -} - -fn health_history_row( - level level: Int, - method method: String, - die_value die_value: Int, - constitution constitution: Int, - tough_bonus tough_bonus: Int, - action action: Element(Message), -) -> Element(Message) { - let total = health_gain(die_value, constitution) + tough_bonus - let tough_text = case tough_bonus { - 0 -> "" - bonus -> " + " <> int.to_string(bonus) <> " Tough" - } - html.div([attribute.class("health-history-row")], [ - html.span([], [html.text("LVL " <> int.to_string(level))]), - html.strong([], [html.text(method)]), - html.p([], [ - html.text( - int.to_string(die_value) - <> " " - <> string.lowercase(method) - <> " " - <> signed(constitution) - <> " CON" - <> tough_text - <> " = " - <> int.to_string(total) - <> " HP", - ), - ]), - action, - ]) -} - -fn view_footer() -> Element(Message) { - html.footer([], [ - html.span([], [html.text("Sheets · Unofficial character builder")]), - html.span([], [html.text("D&D is a trademark of Wizards of the Coast")]), - ]) -} - -fn live_table_panel_collapsed(model: Model, panel: String) -> Bool { - list.contains(model.collapsed_live_table_panels, panel) -} - -fn collapsible_section_heading( - model: Model, - panel: String, - number: String, - title: String, - description: String, -) -> Element(Message) { - let collapsed = live_table_panel_collapsed(model, panel) - html.button( - [ - attribute.type_("button"), - attribute.class("collapsible-panel-toggle"), - attribute.aria_expanded(!collapsed), - attribute.aria_label(case collapsed { - True -> "Expand " <> title - False -> "Collapse " <> title - }), - event.on_click(UserToggledLiveTablePanel(panel)), - ], - [ - section_heading(number, title, description), - html.span([attribute.class("collapsible-panel-icon")], [ - html.text(case collapsed { - True -> "+" - False -> "−" - }), - ]), - ], - ) -} - fn section_heading( number: String, title: String, @@ -13144,21 +14453,6 @@ fn epic_feat_ability_maximum(model: Model, ability: String) -> Int { }) } -fn feat_selection_requires_ability( - catalog: List(Feat), - selected: FeatSelection, -) -> Bool { - case selected.name { - "Ability Score Improvement" -> selected.ability == "" - name -> - case list.find(catalog, fn(feat) { feat.name == name }) { - Ok(feat) -> - list.length(feat.ability_options) > 1 && selected.ability == "" - Error(Nil) -> False - } - } -} - fn selected_feat_ability( catalog: List(Feat), selected: FeatSelection, @@ -13762,33 +15056,6 @@ fn health_gain(die_value: Int, constitution: Int) -> Int { } } -fn health_stat_block(model: Model, maximum: Int) -> Element(Message) { - html.button( - [ - attribute.type_("button"), - attribute.class("stat-block health-stat-button"), - attribute.aria_label("Configure hit points"), - event.on_click(UserOpenedHealthConfiguration), - ], - [ - html.span([], [html.text("HIT POINTS")]), - html.strong([], [ - html.text( - int.to_string(effective_current_hit_points(model)) - <> "/" - <> int.to_string(maximum), - ), - ]), - html.small([], [ - html.text(case model.temporary_hit_points { - 0 -> "Current / maximum" - temporary -> "+" <> int.to_string(temporary) <> " temporary HP" - }), - ]), - ], - ) -} - fn stat_block(label: String, value: String, note: String) -> Element(Message) { html.div([attribute.class("stat-block")], [ html.span([], [html.text(label)]), diff --git a/src/sheets/browser.gleam b/src/sheets/browser.gleam index 8d81512..f098177 100644 --- a/src/sheets/browser.gleam +++ b/src/sheets/browser.gleam @@ -62,6 +62,13 @@ pub fn print_page() -> Effect(message) { effect.from(fn(_dispatch) { print() }) } +pub fn copy_text(value: String) -> Effect(message) { + effect.from(fn(_dispatch) { copy_text_to_clipboard(value) }) +} + +@external(javascript, "./browser_ffi.mjs", "copyText") +fn copy_text_to_clipboard(value: String) -> Nil + @external(javascript, "./browser_ffi.mjs", "downloadFile") fn download_file(filename: String, contents: String) -> Nil diff --git a/src/sheets/browser_ffi.mjs b/src/sheets/browser_ffi.mjs index 250f073..2096859 100644 --- a/src/sheets/browser_ffi.mjs +++ b/src/sheets/browser_ffi.mjs @@ -17,6 +17,25 @@ export function invitedTable() { return new URL(globalThis.location.href).searchParams.get("table") ?? ""; } +export function copyText(value) { + const fallback = () => { + const input = document.createElement("textarea"); + input.value = value; + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.append(input); + input.select(); + document.execCommand("copy"); + input.remove(); + }; + + if (globalThis.navigator?.clipboard?.writeText) { + globalThis.navigator.clipboard.writeText(value).catch(fallback); + } else { + fallback(); + } +} + export function downloadFile(filename, contents) { diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index fa52fa4..8dc96e8 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -24,6 +24,7 @@ pub type InventoryItem { armor_base: Int, dexterity_cap: Int, description: String, + value: Int, contents: List(StartingItem), consumable: Bool, ) @@ -103,6 +104,8 @@ pub type ClassProgression { weapon_proficiencies: List(String), starting_equipment: List(String), starting_items: List(StartingItem), + starting_gold: Int, + gold_alternative: Int, features: List(ClassFeature), choices: List(FeatureChoice), ) @@ -122,6 +125,8 @@ pub type OriginDetails { background_features: List(RuleFeature), granted_feat: String, starting_items: List(StartingItem), + starting_gold: Int, + gold_alternative: Int, granted_skills: List(String), granted_tools: List(String), choices: List(FeatureChoice), @@ -263,6 +268,8 @@ type RawBackground { edition: String, entries: List(Entry), starting_items: List(StartingItem), + starting_gold: Int, + gold_alternative: Int, ) } @@ -272,6 +279,8 @@ type RawClass { source: String, edition: String, starting_items: List(StartingItem), + starting_gold: Int, + gold_alternative: Int, subclass_title: String, feature_references: List(ClassFeatureReference), skill_options: List(String), @@ -675,6 +684,7 @@ fn equipment_catalog_decoder( armor_base: inventory_armor_class(item.name), dexterity_cap: inventory_dexterity_cap(item.item_type), description: inventory_description(item, category), + value: item.value, consumable: inventory_is_consumable(item), contents: item.pack_contents, ) @@ -1424,6 +1434,16 @@ fn raw_class_decoder() -> decode.Decoder(RawClass) { [], class_starting_items_decoder(), ) + use starting_gold <- decode.optional_field( + "startingEquipment", + 0, + class_starting_gold_decoder(), + ) + use gold_alternative <- decode.optional_field( + "startingEquipment", + 0, + class_gold_alternative_decoder(), + ) decode.success(RawClass( name:, source:, @@ -1436,6 +1456,8 @@ fn raw_class_decoder() -> decode.Decoder(RawClass) { weapon_proficiencies:, starting_equipment:, starting_items:, + starting_gold:, + gold_alternative:, )) } @@ -1546,11 +1568,99 @@ fn class_starting_items_decoder() -> decode.Decoder(List(StartingItem)) { decode.success(list.flatten(groups)) } +fn class_starting_gold_decoder() -> decode.Decoder(Int) { + use groups <- decode.optional_field( + "defaultData", + [], + decode.list(starting_package_gold_decoder()), + ) + decode.success(int.sum(groups)) +} + +fn class_gold_alternative_decoder() -> decode.Decoder(Int) { + use groups <- decode.optional_field( + "defaultData", + [], + decode.list(starting_gold_alternative_decoder()), + ) + decode.success(int.sum(groups)) +} + fn background_starting_items_decoder() -> decode.Decoder(List(StartingItem)) { decode.list(starting_item_group_decoder()) |> decode.map(list.flatten) } +fn background_starting_gold_decoder() -> decode.Decoder(Int) { + decode.list(starting_package_gold_decoder()) + |> decode.map(int.sum) +} + +fn background_gold_alternative_decoder() -> decode.Decoder(Int) { + decode.list(starting_gold_alternative_decoder()) + |> decode.map(int.sum) +} + +fn starting_package_gold_decoder() -> decode.Decoder(Int) { + use lowercase <- decode.optional_field( + "a", + 0, + decode.list(starting_item_gold_decoder()) |> decode.map(int.sum), + ) + use uppercase <- decode.optional_field( + "A", + 0, + decode.list(starting_item_gold_decoder()) |> decode.map(int.sum), + ) + decode.success(case lowercase { + 0 -> uppercase + gold -> gold + }) +} + +fn starting_gold_alternative_decoder() -> decode.Decoder(Int) { + use lowercase <- decode.optional_field( + "b", + 0, + decode.list(starting_item_gold_decoder()) |> decode.map(int.sum), + ) + use uppercase <- decode.optional_field( + "B", + 0, + decode.list(starting_item_gold_decoder()) |> decode.map(int.sum), + ) + use lowercase_last <- decode.optional_field( + "c", + 0, + decode.list(starting_item_gold_decoder()) |> decode.map(int.sum), + ) + use uppercase_last <- decode.optional_field( + "C", + 0, + decode.list(starting_item_gold_decoder()) |> decode.map(int.sum), + ) + decode.success(case lowercase_last, uppercase_last, lowercase, uppercase { + gold, _, _, _ if gold > 0 -> gold + _, gold, _, _ if gold > 0 -> gold + _, _, gold, _ if gold > 0 -> gold + _, _, _, gold -> gold + }) +} + +fn starting_item_gold_decoder() -> decode.Decoder(Int) { + decode.one_of(decode.string |> decode.map(fn(_) { 0 }), or: [ + starting_item_gold_object_decoder(), + ]) +} + +fn starting_item_gold_object_decoder() -> decode.Decoder(Int) { + use copper <- decode.optional_field("value", 0, decode.int) + decode.success( + int.floor_divide(copper, 100) + |> result.unwrap(0), + ) +} + fn starting_item_group_decoder() -> decode.Decoder(List(StartingItem)) { use lowercase <- decode.optional_field( "a", @@ -1689,21 +1799,21 @@ fn normalize_class_progression( |> list.find(fn(item) { item.source == "EFA" }) |> result.lazy_or(fn() { list.first(candidates) }) }) - |> result.unwrap( - RawClass( - name: class_name, - source: "", - edition: "one", - subclass_title: "Subclass", - feature_references: [], - skill_options: [], - skill_count: 0, - armor_training: [], - weapon_proficiencies: [], - starting_equipment: [], - starting_items: [], - ), - ) + |> result.unwrap(RawClass( + name: class_name, + source: "", + edition: "one", + subclass_title: "Subclass", + feature_references: [], + skill_options: [], + skill_count: 0, + armor_training: [], + weapon_proficiencies: [], + starting_equipment: [], + starting_items: [], + starting_gold: 0, + gold_alternative: 0, + )) let current_subclasses = subclasses |> list.filter(fn(subclass) { @@ -1794,6 +1904,8 @@ fn normalize_class_progression( weapon_proficiencies: selected.weapon_proficiencies, starting_equipment: selected.starting_equipment, starting_items: selected.starting_items, + starting_gold: selected.starting_gold, + gold_alternative: selected.gold_alternative, features: list.append(class_features, subclass_features), choices: class_choices |> list.append(subclass_choice) @@ -2210,15 +2322,15 @@ fn origin_background_decoder( item.name == background_name && { item.edition == "one" || item.source == "XPHB" } }) - |> result.unwrap( - RawBackground( - name: background_name, - source: "", - edition: "one", - entries: [], - starting_items: [], - ), - ) + |> result.unwrap(RawBackground( + name: background_name, + source: "", + edition: "one", + entries: [], + starting_items: [], + starting_gold: 0, + gold_alternative: 0, + )) OriginDetails( species_name: species.species_name, background_name: background.name, @@ -2227,6 +2339,8 @@ fn origin_background_decoder( species_features: rule_features(species.entries, species.source), background_features: rule_features(background.entries, background.source), starting_items: background.starting_items, + starting_gold: background.starting_gold, + gold_alternative: background.gold_alternative, granted_feat: background_granted_feat(background.entries), granted_skills: background_granted_skills(background.entries), granted_tools: background_granted_tools(background.entries), @@ -2254,12 +2368,24 @@ fn raw_background_decoder() -> decode.Decoder(RawBackground) { [], background_starting_items_decoder(), ) + use starting_gold <- decode.optional_field( + "startingEquipment", + 0, + background_starting_gold_decoder(), + ) + use gold_alternative <- decode.optional_field( + "startingEquipment", + 0, + background_gold_alternative_decoder(), + ) decode.success(RawBackground( name:, source:, edition:, entries:, starting_items:, + starting_gold:, + gold_alternative:, )) } diff --git a/src/sheets/ui.gleam b/src/sheets/ui.gleam new file mode 100644 index 0000000..9f21537 --- /dev/null +++ b/src/sheets/ui.gleam @@ -0,0 +1,307 @@ +import gleam/list +import gleam/string +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html +import lustre/event + +pub type NavigationItem(message) { + NavigationItem( + label: String, + marker: String, + selected: Bool, + message: message, + ) +} + +pub type Metric { + Metric(label: String, value: String, detail: String, marker: String) +} + +pub type ChipTone { + AccentChip + NotificationChip + WarningChip + DangerChip + NeutralChip +} + +pub fn application( + sidebar: Element(message), + topbar: Element(message), + page: Element(message), + mobile_navigation: Element(message), +) -> Element(message) { + html.div([attribute.class("sheet-application")], [ + sidebar, + html.div([attribute.class("sheet-main")], [topbar, page]), + mobile_navigation, + ]) +} + +pub fn brand() -> Element(message) { + html.div([attribute.class("sheet-brand")], [ + html.div([attribute.class("sheet-brand-mark")], [html.text("S")]), + html.div([attribute.class("sheet-brand-copy")], [ + html.span([], [html.text("D&D 2024")]), + html.strong([], [html.text("SHEETS")]), + ]), + ]) +} + +pub fn sidebar( + identity_name: String, + identity_detail: String, + identity_label: String, + identity_message: message, + character_navigation: List(NavigationItem(message)), + session_navigation: List(NavigationItem(message)), + footer: String, +) -> Element(message) { + html.aside([attribute.class("sheet-sidebar")], [ + brand(), + html.button( + [ + attribute.type_("button"), + attribute.class("sheet-active-character"), + event.on_click(identity_message), + ], + [ + html.div([attribute.class("sheet-character-identity")], [ + html.div([attribute.class("sheet-character-avatar")], [ + html.text(initials(identity_name)), + ]), + html.div([attribute.class("sheet-character-copy")], [ + html.strong([], [html.text(identity_name)]), + html.span([], [html.text(identity_detail)]), + ]), + ]), + html.span([attribute.class("sheet-character-switcher")], [ + html.text(identity_label <> " ↓"), + ]), + ], + ), + navigation_group("CHARACTER", character_navigation), + navigation_group("SESSION", session_navigation), + html.div([attribute.class("sheet-sidebar-spacer")], []), + html.div([attribute.class("sheet-sync-state")], [ + html.span([attribute.class("sheet-status-light")], []), + html.span([], [html.text(footer)]), + ]), + ]) +} + +pub fn topbar( + current: String, + search_hint: String, + settings_message: message, +) -> Element(message) { + html.header([attribute.class("sheet-desktop-topbar")], [ + html.div([attribute.class("sheet-breadcrumb")], [ + html.span([], [html.text("CHARACTERS")]), + html.span([attribute.class("sheet-breadcrumb-divider")], [html.text("/")]), + html.strong([], [html.text(string.uppercase(current))]), + ]), + html.div([attribute.class("sheet-topbar-actions")], [ + html.div([attribute.class("sheet-search-placeholder")], [ + html.span([], [html.text("⌕")]), + html.span([], [html.text(search_hint)]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("sheet-settings-marker"), + attribute.aria_label("Configure rules data"), + event.on_click(settings_message), + ], + [html.text("⌘")], + ), + ]), + ]) +} + +pub fn mobile_topbar( + status: String, + menu_message: message, +) -> Element(message) { + html.header([attribute.class("sheet-mobile-topbar")], [ + brand(), + html.div([attribute.class("sheet-mobile-topbar-actions")], [ + html.span([attribute.class("sheet-mobile-status")], [ + html.span([attribute.class("sheet-status-light")], []), + html.text(status), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("sheet-mobile-menu"), + attribute.aria_label("Open character library"), + event.on_click(menu_message), + ], + [html.text("☰")], + ), + ]), + ]) +} + +pub fn mobile_navigation( + items: List(NavigationItem(message)), +) -> Element(message) { + html.nav( + [ + attribute.class("sheet-mobile-navigation"), + attribute.aria_label("Application pages"), + ], + list.map(items, fn(item) { + let NavigationItem(label:, marker:, selected:, message:) = item + html.button( + [ + attribute.type_("button"), + attribute.classes([ + #("sheet-mobile-navigation-item", True), + #("selected", selected), + ]), + attribute.aria_pressed(bool_string(selected)), + event.on_click(message), + ], + [ + html.span([attribute.class("sheet-mobile-navigation-marker")], [ + html.text(marker), + ]), + html.span([], [html.text(label)]), + ], + ) + }), + ) +} + +pub fn page_heading( + eyebrow_text: String, + title: String, + description: String, + actions: List(Element(message)), +) -> Element(message) { + html.header([attribute.class("sheet-page-heading")], [ + html.div([attribute.class("sheet-page-heading-copy")], [ + eyebrow(eyebrow_text), + html.h1([], [html.text(title)]), + case description { + "" -> html.text("") + _ -> html.p([], [html.text(description)]) + }, + ]), + html.div([attribute.class("sheet-page-actions")], actions), + ]) +} + +pub fn eyebrow(label: String) -> Element(message) { + html.span([attribute.class("sheet-eyebrow")], [html.text("+ " <> label)]) +} + +pub fn primary_button(label: String, message: message) -> Element(message) { + html.button( + [ + attribute.type_("button"), + attribute.class("sheet-primary-button"), + event.on_click(message), + ], + [html.span([], [html.text(label)]), html.span([], [html.text("→")])], + ) +} + +pub fn secondary_button(label: String, message: message) -> Element(message) { + html.button( + [ + attribute.type_("button"), + attribute.class("sheet-secondary-button"), + event.on_click(message), + ], + [html.text(label)], + ) +} + +pub fn metric(metric: Metric) -> Element(message) { + let Metric(label:, value:, detail:, marker:) = metric + html.article([attribute.class("sheet-metric")], [ + html.div([attribute.class("sheet-metric-heading")], [ + html.span([], [html.text(label)]), + html.span([attribute.class("sheet-metric-marker")], [html.text(marker)]), + ]), + html.div([attribute.class("sheet-metric-value")], [ + html.strong([], [html.text(value)]), + html.span([], [html.text(detail)]), + ]), + ]) +} + +pub fn status_chip(label: String, tone: ChipTone) -> Element(message) { + html.span( + [ + attribute.classes([ + #("sheet-status-chip", True), + #("accent", tone == AccentChip), + #("notification", tone == NotificationChip), + #("warning", tone == WarningChip), + #("danger", tone == DangerChip), + #("neutral", tone == NeutralChip), + ]), + ], + [ + html.span([attribute.class("sheet-status-chip-dot")], []), + html.span([], [html.text(label)]), + ], + ) +} + +fn navigation_group( + label: String, + items: List(NavigationItem(message)), +) -> Element(message) { + html.nav([attribute.class("sheet-navigation-group")], [ + html.span([attribute.class("sheet-navigation-label")], [html.text(label)]), + ..list.map(items, navigation_item) + ]) +} + +fn navigation_item(item: NavigationItem(message)) -> Element(message) { + let NavigationItem(label:, marker:, selected:, message:) = item + html.button( + [ + attribute.type_("button"), + attribute.classes([ + #("sheet-navigation-item", True), + #("selected", selected), + ]), + attribute.aria_pressed(bool_string(selected)), + event.on_click(message), + ], + [ + html.span([attribute.class("sheet-navigation-marker")], [ + html.text(marker), + ]), + html.span([], [html.text(label)]), + ], + ) +} + +fn initials(name: String) -> String { + case name |> string.split(" ") |> list.filter(fn(word) { word != "" }) { + [first, second, ..] -> first_letter(first) <> first_letter(second) + [first] -> first_letter(first) + [] -> "S" + } +} + +fn first_letter(value: String) -> String { + case string.to_graphemes(value) { + [grapheme, ..] -> string.uppercase(grapheme) + [] -> "S" + } +} + +fn bool_string(value: Bool) -> String { + case value { + True -> "true" + False -> "false" + } +} diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index de4a955..4b0b489 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -1,5 +1,6 @@ import gleam/list import gleeunit +import sheets.{SavedPlayerCharacter} import sheets/data import sheets/peer.{ CompanionInitiative, DiceRoll, Encounter, InitiativeEntry, PlayerInitiative, @@ -199,3 +200,111 @@ pub fn background_ability_allocation_bonus_test() { assert data.ability_allocation_bonus(allocation, "Wisdom") == 1 assert data.ability_allocation_bonus(allocation, "Charisma") == 0 } + +pub fn reconnecting_character_updates_saved_copy_across_table_tickets_test() { + let original = + saved_player_snapshot( + owner: "Maribel", + character_id: "character-pepper", + character_name: "Pepper", + current_hit_points: 19, + ) + let incoming = + saved_player_snapshot( + owner: "Maribel renamed", + character_id: "character-pepper", + character_name: "Pepper", + current_hit_points: 27, + ) + let existing = + SavedPlayerCharacter( + id: "saved-pepper", + table_ticket: "old-table", + last_synced_at: "2026-07-19T14:30:00.000Z", + snapshot: original, + ) + + assert sheets.save_player_snapshot( + [existing], + "new-table", + incoming, + "2026-07-20T14:30:00.000Z", + ) + == [ + SavedPlayerCharacter( + id: "saved-pepper", + table_ticket: "new-table", + last_synced_at: "2026-07-20T14:30:00.000Z", + snapshot: incoming, + ), + ] +} + +pub fn snapshot_identity_does_not_fall_back_to_owner_test() { + let first = + saved_player_snapshot( + owner: "Maribel", + character_id: "character-pepper", + character_name: "Pepper", + current_hit_points: 19, + ) + let second = + saved_player_snapshot( + owner: "Maribel", + character_id: "character-salt", + character_name: "Salt", + current_hit_points: 19, + ) + + assert !sheets.snapshots_match(first, second) +} + +fn saved_player_snapshot( + owner owner: String, + character_id character_id: String, + character_name character_name: String, + current_hit_points current_hit_points: Int, +) -> peer.Snapshot { + Snapshot( + owner:, + character_id:, + character_name:, + portrait_url: "", + level: 4, + class_name: "Fighter", + subclass_name: "Champion", + species_name: "Human", + background_name: "Soldier", + abilities: SnapshotAbilities( + strength: 16, + dexterity: 14, + constitution: 13, + intelligence: 10, + wisdom: 12, + charisma: 8, + ), + skills: [], + choices: [], + class_features: [], + origin_features: [], + equipment: [], + feats: [], + spells: [], + gold: 0, + other_proficiencies: [], + armor_class: 18, + hit_points: 32, + current_hit_points:, + initiative: 2, + proficiency_bonus: 2, + passive_perception: 11, + speed: 30, + hit_die: 10, + spell_save_dc: 0, + spellcasting_ability: "", + resources: [], + concentration: "", + inspiration: False, + conditions: [], + ) +}