@jjlmoya/utils-tabletop 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +2 -0
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +1 -1
  6. package/src/tool/encounter-difficulty-calculator/bibliography.astro +6 -0
  7. package/src/tool/encounter-difficulty-calculator/bibliography.ts +12 -0
  8. package/src/tool/encounter-difficulty-calculator/component.astro +53 -0
  9. package/src/tool/encounter-difficulty-calculator/controller.ts +133 -0
  10. package/src/tool/encounter-difficulty-calculator/dnd-5e-encounter-difficulty-calculator.css +389 -0
  11. package/src/tool/encounter-difficulty-calculator/dom-views.ts +54 -0
  12. package/src/tool/encounter-difficulty-calculator/entry.ts +27 -0
  13. package/src/tool/encounter-difficulty-calculator/evaluator.ts +12 -0
  14. package/src/tool/encounter-difficulty-calculator/i18n/de.ts +155 -0
  15. package/src/tool/encounter-difficulty-calculator/i18n/en.ts +155 -0
  16. package/src/tool/encounter-difficulty-calculator/i18n/es.ts +155 -0
  17. package/src/tool/encounter-difficulty-calculator/i18n/fr.ts +155 -0
  18. package/src/tool/encounter-difficulty-calculator/i18n/id.ts +155 -0
  19. package/src/tool/encounter-difficulty-calculator/i18n/it.ts +155 -0
  20. package/src/tool/encounter-difficulty-calculator/i18n/ja.ts +155 -0
  21. package/src/tool/encounter-difficulty-calculator/i18n/ko.ts +155 -0
  22. package/src/tool/encounter-difficulty-calculator/i18n/nl.ts +155 -0
  23. package/src/tool/encounter-difficulty-calculator/i18n/pl.ts +155 -0
  24. package/src/tool/encounter-difficulty-calculator/i18n/pt.ts +155 -0
  25. package/src/tool/encounter-difficulty-calculator/i18n/ru.ts +155 -0
  26. package/src/tool/encounter-difficulty-calculator/i18n/sv.ts +155 -0
  27. package/src/tool/encounter-difficulty-calculator/i18n/tr.ts +155 -0
  28. package/src/tool/encounter-difficulty-calculator/i18n/zh.ts +155 -0
  29. package/src/tool/encounter-difficulty-calculator/index.ts +11 -0
  30. package/src/tool/encounter-difficulty-calculator/logic.test.ts +37 -0
  31. package/src/tool/encounter-difficulty-calculator/logic.ts +174 -0
  32. package/src/tool/encounter-difficulty-calculator/seo.astro +16 -0
  33. package/src/tool/encounter-difficulty-calculator/storage.ts +26 -0
  34. package/src/tool/encounter-difficulty-calculator/ui.ts +45 -0
  35. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-tabletop",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,6 +1,7 @@
1
1
  import { diceRollerSimulator } from '../tool/dice-roller-simulator/entry';
2
2
  import { boardGameTimer } from '../tool/board-game-timer/entry';
3
3
  import { dungeonMapGenerator } from '../tool/dungeon-map-generator/entry';
4
+ import { encounterDifficultyCalculator } from '../tool/encounter-difficulty-calculator/entry';
4
5
  import type { CategoryLocaleContent, KnownLocale } from '../types';
5
6
 
6
7
  export const tabletopCategory = {
@@ -9,6 +10,7 @@ export const tabletopCategory = {
9
10
  diceRollerSimulator,
10
11
  boardGameTimer,
11
12
  dungeonMapGenerator,
13
+ encounterDifficultyCalculator,
12
14
  ],
13
15
  i18n: {
14
16
  de: () => import('./i18n/de').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -34,6 +34,7 @@ import { lunarTideTracker } from './tool/lunar-tide-tracker/entry';
34
34
  import { hiddenRoleDealer } from './tool/hidden-role-dealer/entry';
35
35
  import { scatterDirectionSelector } from './tool/scatter-direction-selector/entry';
36
36
  import { dungeonMapGenerator } from './tool/dungeon-map-generator/entry';
37
+ import { encounterDifficultyCalculator } from './tool/encounter-difficulty-calculator/entry';
37
38
 
38
39
  export const ALL_ENTRIES = [
39
40
  diceRollerSimulator,
@@ -48,5 +49,6 @@ export const ALL_ENTRIES = [
48
49
  hiddenRoleDealer,
49
50
  scatterDirectionSelector,
50
51
  dungeonMapGenerator,
52
+ encounterDifficultyCalculator,
51
53
  ];
52
54
 
@@ -22,7 +22,7 @@ describe('Locale Completeness Validation', () => {
22
22
  });
23
23
 
24
24
  it('all tools registered', () => {
25
- expect(ALL_TOOLS.length).toBe(12);
25
+ expect(ALL_TOOLS.length).toBe(13);
26
26
  });
27
27
 
28
28
 
@@ -5,7 +5,7 @@ import { tabletopCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(12);
8
+ expect(ALL_TOOLS.length).toBe(13);
9
9
  });
10
10
 
11
11
 
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <Bibliography links={bibliography} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'D&D Beyond, Basic Rules 2014, Building Combat Encounters',
6
+ url: 'https://www.dndbeyond.com/sources/dnd/basic-rules-2014/building-combat-encounters',
7
+ },
8
+ {
9
+ name: 'D&D Beyond, Basic Rules 2014, Monsters and Experience Points',
10
+ url: 'https://www.dndbeyond.com/sources/dnd/basic-rules-2014/monsters',
11
+ },
12
+ ];
@@ -0,0 +1,53 @@
1
+ ---
2
+ import type { EncounterDifficultyUI } from './ui';
3
+
4
+ interface Props {
5
+ ui: EncounterDifficultyUI;
6
+ }
7
+
8
+ const { ui } = Astro.props as Props;
9
+ ---
10
+
11
+ <div class="encounter-calculator" data-encounter-calculator data-ui={JSON.stringify(ui)}>
12
+ <div class="encounter-intro">{ui.intro}</div>
13
+ <div class="encounter-presets">
14
+ <span>{ui.presets}</span>
15
+ <button type="button" data-preset="classic">{ui.presetClassic}</button>
16
+ <button type="button" data-preset="boss">{ui.presetBoss}</button>
17
+ <button type="button" data-preset="swarm">{ui.presetSwarm}</button>
18
+ </div>
19
+ <div class="encounter-layout">
20
+ <section class="encounter-controls" aria-label={ui.partySection}>
21
+ <div class="control-group">
22
+ <div class="group-heading"><span>{ui.partySection}</span><span class="eyebrow">D&D 5E 2014</span></div>
23
+ <label for="party-level">{ui.partyLevel}<output data-output="partyLevel">5</output></label>
24
+ <input id="party-level" data-input="partyLevel" type="range" min="1" max="20" value="5" />
25
+ <small>{ui.partyLevelHint}</small>
26
+ <label for="party-size">{ui.partySize}<output data-output="partySize">4</output></label>
27
+ <input id="party-size" data-input="partySize" type="range" min="1" max="8" value="4" />
28
+ <small>{ui.partySizeHint}</small>
29
+ </div>
30
+ <div class="control-group">
31
+ <div class="group-heading"><span>{ui.threatSection}</span><span class="eyebrow">CR + XP</span></div>
32
+ <label>{ui.monsterCr}<output data-cr-output>2</output></label>
33
+ <div class="cr-list" data-cr-list aria-label={ui.monsterCr}></div>
34
+ <button type="button" class="cr-more-button" data-cr-more>{ui.moreCr}</button>
35
+ <small>{ui.monsterCrHint}</small>
36
+ <label for="monster-count">{ui.monsterCount}<output data-output="monsterCount">2</output></label>
37
+ <input id="monster-count" data-input="monsterCount" type="range" min="1" max="15" value="2" />
38
+ <small>{ui.monsterCountHint}</small>
39
+ </div>
40
+ <button type="button" class="reset-button" data-reset>{ui.reset}</button>
41
+ </section>
42
+ <section class="encounter-result" aria-live="polite" aria-label={ui.sceneLabel}>
43
+ <div data-result></div>
44
+ </section>
45
+ </div>
46
+ <p class="rules-note">{ui.rulesNote} <a href="https://www.dndbeyond.com/sources/dnd/basic-rules-2014/building-combat-encounters" target="_blank" rel="noreferrer">{ui.rulesLinkLabel}</a></p>
47
+ </div>
48
+
49
+ <script>
50
+ import { mountEncounterCalculator } from './controller';
51
+ const root = document.querySelector('[data-encounter-calculator]');
52
+ if (root) mountEncounterCalculator(root);
53
+ </script>
@@ -0,0 +1,133 @@
1
+ import { calculateEncounter, CR_OPTIONS, DEFAULT_SETTINGS, QUICK_CR_VALUES, type EncounterSettings } from './logic';
2
+ import { readEncounterSettings, writeEncounterSettings } from './storage';
3
+ import { renderResult } from './dom-views';
4
+
5
+ type Root = HTMLElement & { dataset: DOMStringMap };
6
+ type Labels = Record<string, string>;
7
+
8
+ function getUi(root: Root): Labels {
9
+ try {
10
+ return JSON.parse(root.dataset.ui ?? '{}') as Labels;
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
15
+
16
+ function query<T extends HTMLElement>(root: Root, selector: string): T {
17
+ return root.querySelector(selector) as T;
18
+ }
19
+
20
+ function getSettings(root: Root): EncounterSettings {
21
+ return {
22
+ partyLevel: Number(query<HTMLInputElement>(root, '[data-input="partyLevel"]').value),
23
+ partySize: Number(query<HTMLInputElement>(root, '[data-input="partySize"]').value),
24
+ monsterCr: Number(root.querySelector<HTMLElement>('[data-cr].is-active')?.dataset.cr ?? 2),
25
+ monsterCount: Number(query<HTMLInputElement>(root, '[data-input="monsterCount"]').value),
26
+ };
27
+ }
28
+
29
+ function setRangeValue(root: Root, key: string, value: number): void {
30
+ const input = query<HTMLInputElement>(root, '[data-input="' + key + '"]');
31
+ const output = query<HTMLElement>(root, '[data-output="' + key + '"]');
32
+ input.value = String(value);
33
+ output.textContent = String(value);
34
+ }
35
+
36
+ function setCr(root: Root, cr: number): void {
37
+ const list = query<HTMLElement>(root, '[data-cr-list]');
38
+ if (!QUICK_CR_VALUES.includes(cr as typeof QUICK_CR_VALUES[number])) list.classList.add('is-expanded');
39
+ root.querySelectorAll<HTMLElement>('[data-cr]').forEach((chip) => {
40
+ const active = Number(chip.dataset.cr) === cr;
41
+ chip.classList.toggle('is-active', active);
42
+ chip.setAttribute('aria-pressed', String(active));
43
+ });
44
+ const output = root.querySelector<HTMLElement>('[data-cr-output]');
45
+ if (output) output.textContent = formatCr(cr);
46
+ }
47
+
48
+ function formatCr(cr: number): string {
49
+ if (cr === 0.125) return '1/8';
50
+ if (cr === 0.25) return '1/4';
51
+ if (cr === 0.5) return '1/2';
52
+ return String(cr);
53
+ }
54
+
55
+ function applySettings(root: Root, settings: EncounterSettings): void {
56
+ setRangeValue(root, 'partyLevel', settings.partyLevel);
57
+ setRangeValue(root, 'partySize', settings.partySize);
58
+ setRangeValue(root, 'monsterCount', settings.monsterCount);
59
+ setCr(root, settings.monsterCr);
60
+ }
61
+
62
+ function render(root: Root): void {
63
+ const result = calculateEncounter(getSettings(root));
64
+ const labels = getUi(root);
65
+ query<HTMLElement>(root, '[data-result]').innerHTML = renderResult(result, labels);
66
+ writeEncounterSettings(result.settings);
67
+ root.dataset.difficulty = result.difficulty;
68
+ }
69
+
70
+ function bindRanges(root: Root): void {
71
+ root.querySelectorAll<HTMLInputElement>('[data-input]').forEach((input) => {
72
+ input.addEventListener('input', () => {
73
+ query<HTMLElement>(root, '[data-output="' + input.dataset.input + '"]').textContent = input.value;
74
+ render(root);
75
+ });
76
+ });
77
+ }
78
+
79
+ function bindCr(root: Root): void {
80
+ root.querySelectorAll<HTMLElement>('[data-cr]').forEach((chip) => chip.addEventListener('click', () => {
81
+ setCr(root, Number(chip.dataset.cr));
82
+ render(root);
83
+ }));
84
+ }
85
+
86
+ function bindCrMore(root: Root): void {
87
+ const button = query<HTMLButtonElement>(root, '[data-cr-more]');
88
+ button.addEventListener('click', () => {
89
+ const list = query<HTMLElement>(root, '[data-cr-list]');
90
+ const expanded = list.classList.toggle('is-expanded');
91
+ const labels = getUi(root);
92
+ button.textContent = expanded ? labels.lessCr : labels.moreCr;
93
+ });
94
+ }
95
+
96
+ function bindPresets(root: Root): void {
97
+ const presets: Record<string, EncounterSettings> = {
98
+ classic: { partyLevel: 5, partySize: 4, monsterCr: 2, monsterCount: 2 },
99
+ boss: { partyLevel: 8, partySize: 4, monsterCr: 10, monsterCount: 1 },
100
+ swarm: { partyLevel: 3, partySize: 4, monsterCr: 0.25, monsterCount: 8 },
101
+ };
102
+ root.querySelectorAll<HTMLElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => {
103
+ applySettings(root, presets[button.dataset.preset ?? ''] ?? DEFAULT_SETTINGS);
104
+ render(root);
105
+ }));
106
+ }
107
+
108
+ function bindReset(root: Root): void {
109
+ query<HTMLButtonElement>(root, '[data-reset]').addEventListener('click', () => {
110
+ applySettings(root, DEFAULT_SETTINGS);
111
+ render(root);
112
+ });
113
+ }
114
+
115
+ function setupCrOptions(root: Root): void {
116
+ const list = query<HTMLElement>(root, '[data-cr-list]');
117
+ list.innerHTML = CR_OPTIONS.map((entry) => {
118
+ const advanced = QUICK_CR_VALUES.includes(entry.value as typeof QUICK_CR_VALUES[number]);
119
+ const attribute = advanced ? 'false' : 'true';
120
+ return '<button type="button" class="cr-chip" data-cr="' + entry.value + '" data-advanced="' + attribute + '" aria-pressed="false">' + entry.label + '</button>';
121
+ }).join('');
122
+ }
123
+
124
+ export function mountEncounterCalculator(root: Root): void {
125
+ setupCrOptions(root);
126
+ applySettings(root, readEncounterSettings() ?? DEFAULT_SETTINGS);
127
+ bindRanges(root);
128
+ bindCr(root);
129
+ bindCrMore(root);
130
+ bindPresets(root);
131
+ bindReset(root);
132
+ render(root);
133
+ }
@@ -0,0 +1,389 @@
1
+ .encounter-calculator {
2
+ --n-ink: #18212b;
3
+ --n-muted: #5b6875;
4
+ --n-paper: #f7f4ed;
5
+ --n-card: #fff;
6
+ --n-line: #d9d5cb;
7
+ --n-accent: #bf542f;
8
+ --n-accent-soft: #f2d1c5;
9
+ --n-safe: #5e8b68;
10
+ --n-medium: #c18b3c;
11
+ --n-hard: #b8643d;
12
+ --n-deadly: #853e42;
13
+ --n-shadow: #d5cec2;
14
+
15
+ color: var(--n-ink);
16
+ background: var(--n-paper);
17
+ border: 1px solid var(--n-line);
18
+ border-radius: 1.75rem;
19
+ padding: clamp(1rem, 3vw, 2rem);
20
+ box-shadow: 0 1rem 0 var(--n-shadow);
21
+ }
22
+
23
+ .theme-dark .encounter-calculator {
24
+ --n-ink: #f7f4ed;
25
+ --n-muted: #b5bdc4;
26
+ --n-paper: #1d252d;
27
+ --n-card: #26313b;
28
+ --n-line: #44515b;
29
+ --n-accent: #ee8661;
30
+ --n-accent-soft: #633d36;
31
+ --n-safe: #8cb596;
32
+ --n-medium: #dfb265;
33
+ --n-hard: #e18b63;
34
+ --n-deadly: #dc7a7d;
35
+ --n-shadow: #11171c;
36
+ }
37
+
38
+ .encounter-intro,
39
+ .rules-note {
40
+ color: var(--n-muted);
41
+ line-height: 1.6;
42
+ }
43
+
44
+ .encounter-presets {
45
+ display: flex;
46
+ flex-wrap: wrap;
47
+ align-items: center;
48
+ gap: .5rem;
49
+ margin: 1.25rem 0;
50
+ font-size: .8rem;
51
+ font-weight: 700;
52
+ }
53
+
54
+ .encounter-presets span {
55
+ color: var(--n-muted);
56
+ margin-right: .25rem;
57
+ }
58
+
59
+ .encounter-presets button,
60
+ .cr-chip,
61
+ .reset-button {
62
+ color: var(--n-ink);
63
+ background: var(--n-card);
64
+ border: 1px solid var(--n-line);
65
+ border-radius: 999px;
66
+ padding: .55rem .75rem;
67
+ cursor: pointer;
68
+ font: inherit;
69
+ }
70
+
71
+ .encounter-presets button:hover,
72
+ .cr-chip:hover,
73
+ .reset-button:hover,
74
+ .cr-chip.is-active {
75
+ color: var(--n-card);
76
+ background: var(--n-accent);
77
+ border-color: var(--n-accent);
78
+ }
79
+
80
+ .encounter-layout {
81
+ display: grid;
82
+ grid-template-columns: minmax(16rem, .75fr) minmax(20rem, 1.25fr);
83
+ gap: 1rem;
84
+ }
85
+
86
+ .encounter-controls,
87
+ .encounter-result {
88
+ background: var(--n-card);
89
+ border: 1px solid var(--n-line);
90
+ border-radius: 1.25rem;
91
+ padding: 1.25rem;
92
+ }
93
+
94
+ .encounter-result {
95
+ align-self: start;
96
+ }
97
+
98
+ .control-group + .control-group {
99
+ border-top: 1px solid var(--n-line);
100
+ margin-top: 1.25rem;
101
+ padding-top: 1.25rem;
102
+ }
103
+
104
+ .group-heading,
105
+ .orbit-labels,
106
+ .result-head,
107
+ .result-stats,
108
+ .encounter-controls label {
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: space-between;
112
+ gap: .75rem;
113
+ }
114
+
115
+ .group-heading {
116
+ font-size: .75rem;
117
+ letter-spacing: .12em;
118
+ text-transform: uppercase;
119
+ font-weight: 800;
120
+ margin-bottom: 1rem;
121
+ }
122
+
123
+ .eyebrow,
124
+ .result-kicker {
125
+ color: var(--n-accent);
126
+ font-size: .65rem;
127
+ letter-spacing: .1em;
128
+ text-transform: uppercase;
129
+ }
130
+
131
+ .encounter-controls label {
132
+ margin-top: .95rem;
133
+ font-size: .9rem;
134
+ font-weight: 750;
135
+ }
136
+
137
+ .encounter-controls output {
138
+ min-width: 2.5rem;
139
+ color: var(--n-accent);
140
+ font-size: 1.15rem;
141
+ text-align: right;
142
+ }
143
+
144
+ .encounter-controls input[type='range'] {
145
+ accent-color: var(--n-accent);
146
+ width: 100%;
147
+ margin: .7rem 0 .25rem;
148
+ }
149
+
150
+ .encounter-controls small {
151
+ display: block;
152
+ color: var(--n-muted);
153
+ font-size: .75rem;
154
+ line-height: 1.45;
155
+ }
156
+
157
+ .cr-list {
158
+ display: flex;
159
+ flex-wrap: wrap;
160
+ gap: .4rem;
161
+ margin: .75rem 0 .35rem;
162
+ }
163
+
164
+ .cr-chip {
165
+ padding: .4rem .55rem;
166
+ font-size: .78rem;
167
+ }
168
+
169
+ .cr-chip[data-advanced='true'] {
170
+ display: none;
171
+ }
172
+
173
+ .cr-list.is-expanded .cr-chip[data-advanced='true'] {
174
+ display: inline-block;
175
+ }
176
+
177
+ .cr-more-button {
178
+ border: 0;
179
+ padding: .25rem 0;
180
+ color: var(--n-accent);
181
+ background: transparent;
182
+ cursor: pointer;
183
+ font: inherit;
184
+ font-size: .72rem;
185
+ font-weight: 800;
186
+ }
187
+
188
+ .reset-button {
189
+ margin-top: 1.25rem;
190
+ color: var(--n-muted);
191
+ font-size: .8rem;
192
+ }
193
+
194
+ .result-orbit {
195
+ display: flex;
196
+ flex-direction: column;
197
+ gap: 1rem;
198
+ }
199
+
200
+ .orbit-labels {
201
+ color: var(--n-muted);
202
+ font-size: .72rem;
203
+ font-weight: 800;
204
+ letter-spacing: .1em;
205
+ text-transform: uppercase;
206
+ }
207
+
208
+ .orbit-scene {
209
+ position: relative;
210
+ height: 11rem;
211
+ overflow: hidden;
212
+ border: 1px solid var(--n-line);
213
+ border-radius: 1rem;
214
+ background: var(--n-paper);
215
+ }
216
+
217
+ .orbit-line {
218
+ position: absolute;
219
+ top: 50%;
220
+ left: 12%;
221
+ right: 12%;
222
+ border-top: 2px solid var(--n-line);
223
+ transform: scaleX(var(--threat-scale));
224
+ transform-origin: center;
225
+ }
226
+
227
+ .party-orbit,
228
+ .threat-orbit {
229
+ position: absolute;
230
+ top: 50%;
231
+ display: flex;
232
+ gap: .4rem;
233
+ }
234
+
235
+ .party-orbit {
236
+ left: 10%;
237
+ transform: translateY(-50%);
238
+ }
239
+
240
+ .threat-orbit {
241
+ right: 10%;
242
+ opacity: var(--threat-opacity);
243
+ transform: translateY(-50%) scale(var(--threat-scale));
244
+ transform-origin: right center;
245
+ }
246
+
247
+ .party-orbit i,
248
+ .threat-orbit b {
249
+ display: block;
250
+ width: 1rem;
251
+ height: 1rem;
252
+ border-radius: 50%;
253
+ background: var(--n-safe);
254
+ box-shadow: 0 0 0 .35rem var(--n-card), 0 0 0 .45rem var(--n-safe);
255
+ }
256
+
257
+ .party-marker,
258
+ .threat-marker {
259
+ position: relative;
260
+ }
261
+
262
+ .threat-orbit b {
263
+ background: var(--n-deadly);
264
+ box-shadow: 0 0 0 .35rem var(--n-card), 0 0 0 .45rem var(--n-deadly);
265
+ }
266
+
267
+ .result-head {
268
+ align-items: flex-end;
269
+ }
270
+
271
+ .result-head strong {
272
+ color: var(--n-accent);
273
+ font-size: clamp(1.5rem, 4vw, 2.4rem);
274
+ }
275
+
276
+ .result-head small {
277
+ color: var(--n-muted);
278
+ font-size: .8rem;
279
+ font-weight: 700;
280
+ white-space: nowrap;
281
+ }
282
+
283
+ .result-hint {
284
+ margin: -.5rem 0 0;
285
+ color: var(--n-muted);
286
+ font-size: .78rem;
287
+ line-height: 1.45;
288
+ }
289
+
290
+ .threshold-track {
291
+ display: grid;
292
+ grid-template-columns: repeat(5, 1fr);
293
+ gap: .35rem;
294
+ }
295
+
296
+ .threshold-band {
297
+ min-height: 4.1rem;
298
+ display: flex;
299
+ flex-direction: column;
300
+ justify-content: space-between;
301
+ padding: .55rem;
302
+ border-top: .25rem solid var(--n-line);
303
+ color: var(--n-muted);
304
+ font-size: .68rem;
305
+ }
306
+
307
+ .threshold-band strong {
308
+ color: var(--n-ink);
309
+ font-size: .72rem;
310
+ }
311
+
312
+ .threshold-band.easy {
313
+ border-color: var(--n-safe);
314
+ }
315
+
316
+ .threshold-band.medium {
317
+ border-color: var(--n-medium);
318
+ }
319
+
320
+ .threshold-band.hard {
321
+ border-color: var(--n-hard);
322
+ }
323
+
324
+ .threshold-band.deadly {
325
+ border-color: var(--n-deadly);
326
+ }
327
+
328
+ .threshold-band.is-active {
329
+ background: var(--n-accent-soft);
330
+ color: var(--n-ink);
331
+ }
332
+
333
+ .result-stats {
334
+ border-top: 1px solid var(--n-line);
335
+ padding-top: 1rem;
336
+ }
337
+
338
+ .result-stats span {
339
+ display: flex;
340
+ flex-direction: column;
341
+ gap: .25rem;
342
+ }
343
+
344
+ .result-stats small {
345
+ color: var(--n-muted);
346
+ font-size: .7rem;
347
+ }
348
+
349
+ .result-stats strong {
350
+ font-size: .9rem;
351
+ }
352
+
353
+ .encounter-warning {
354
+ display: grid;
355
+ grid-template-columns: auto 1fr;
356
+ gap: .7rem;
357
+ border-left: .25rem solid var(--n-medium);
358
+ padding: .7rem .8rem;
359
+ color: var(--n-ink);
360
+ background: var(--n-paper);
361
+ }
362
+
363
+ .encounter-warning span {
364
+ color: var(--n-medium);
365
+ font-weight: 800;
366
+ font-size: .75rem;
367
+ }
368
+
369
+ .encounter-warning p {
370
+ margin: 0;
371
+ color: var(--n-muted);
372
+ font-size: .78rem;
373
+ line-height: 1.45;
374
+ }
375
+
376
+ .rules-note {
377
+ margin: 1.25rem 0 0;
378
+ font-size: .75rem;
379
+ }
380
+
381
+ .rules-note a {
382
+ color: var(--n-accent);
383
+ }
384
+
385
+ @media (max-width: 760px) {
386
+ .encounter-layout { grid-template-columns: 1fr; }
387
+ .threshold-track { grid-template-columns: repeat(2, 1fr); }
388
+ .threshold-band:last-child { grid-column: span 2; }
389
+ }