dndkit 0.4.0__py3-none-any.whl

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 (118) hide show
  1. dndkit/__init__.py +10 -0
  2. dndkit/__main__.py +6 -0
  3. dndkit/activity.py +68 -0
  4. dndkit/app.py +105 -0
  5. dndkit/config.py +104 -0
  6. dndkit/errors.py +48 -0
  7. dndkit/game/__init__.py +1 -0
  8. dndkit/game/areas.py +181 -0
  9. dndkit/game/battlefield.py +154 -0
  10. dndkit/game/bestiary.py +155 -0
  11. dndkit/game/characters.py +227 -0
  12. dndkit/game/combat.py +209 -0
  13. dndkit/game/combatants.py +188 -0
  14. dndkit/game/concentration.py +43 -0
  15. dndkit/game/dice.py +186 -0
  16. dndkit/game/effects.py +183 -0
  17. dndkit/game/grid.py +88 -0
  18. dndkit/game/journal.py +47 -0
  19. dndkit/game/movement.py +264 -0
  20. dndkit/game/pathing.py +153 -0
  21. dndkit/game/proficiencies.py +104 -0
  22. dndkit/game/progression.py +77 -0
  23. dndkit/game/readiness.py +214 -0
  24. dndkit/game/reference.py +123 -0
  25. dndkit/game/registry.py +172 -0
  26. dndkit/game/resources.py +96 -0
  27. dndkit/game/rests.py +98 -0
  28. dndkit/game/rules.py +196 -0
  29. dndkit/game/seating.py +71 -0
  30. dndkit/game/sight.py +142 -0
  31. dndkit/game/spellbook.py +97 -0
  32. dndkit/game/tables.py +216 -0
  33. dndkit/game/world.py +125 -0
  34. dndkit/models/__init__.py +69 -0
  35. dndkit/models/base.py +26 -0
  36. dndkit/models/battle.py +40 -0
  37. dndkit/models/bestiary.py +56 -0
  38. dndkit/models/campaign.py +209 -0
  39. dndkit/models/character.py +149 -0
  40. dndkit/models/world.py +62 -0
  41. dndkit/skills/__init__.py +14 -0
  42. dndkit/skills/catalog.py +113 -0
  43. dndkit/skills/dnd-battle-map/SKILL.md +78 -0
  44. dndkit/skills/dnd-game-master/SKILL.md +125 -0
  45. dndkit/skills/dnd-monsters/SKILL.md +85 -0
  46. dndkit/skills/extension.py +93 -0
  47. dndkit/storage/__init__.py +6 -0
  48. dndkit/storage/catalog.py +72 -0
  49. dndkit/storage/confirmations.py +54 -0
  50. dndkit/storage/files.py +55 -0
  51. dndkit/storage/sharing.py +67 -0
  52. dndkit/storage/store.py +278 -0
  53. dndkit/text.py +13 -0
  54. dndkit/tools/__init__.py +47 -0
  55. dndkit/tools/apps.py +47 -0
  56. dndkit/tools/battle.py +117 -0
  57. dndkit/tools/bestiary.py +121 -0
  58. dndkit/tools/campaign.py +208 -0
  59. dndkit/tools/characters.py +189 -0
  60. dndkit/tools/combat.py +169 -0
  61. dndkit/tools/deps.py +42 -0
  62. dndkit/tools/journal.py +102 -0
  63. dndkit/tools/locations.py +98 -0
  64. dndkit/tools/npcs.py +101 -0
  65. dndkit/tools/play.py +118 -0
  66. dndkit/tools/players.py +44 -0
  67. dndkit/tools/progression.py +41 -0
  68. dndkit/tools/quests.py +95 -0
  69. dndkit/tools/recorder.py +63 -0
  70. dndkit/tools/render.py +281 -0
  71. dndkit/tools/render_combat.py +121 -0
  72. dndkit/tools/render_map.py +141 -0
  73. dndkit/tools/resources.py +104 -0
  74. dndkit/tools/schema.py +47 -0
  75. dndkit/tools/screen.py +47 -0
  76. dndkit/tools/spells.py +67 -0
  77. dndkit/tools/utility.py +42 -0
  78. dndkit/ui/__init__.py +52 -0
  79. dndkit/ui/backstage.js +41 -0
  80. dndkit/ui/battlemap.js +70 -0
  81. dndkit/ui/character.html +23 -0
  82. dndkit/ui/combat.html +18 -0
  83. dndkit/ui/components.css +283 -0
  84. dndkit/ui/components.js +261 -0
  85. dndkit/ui/desk.js +91 -0
  86. dndkit/ui/dice.html +42 -0
  87. dndkit/ui/dicetray.js +99 -0
  88. dndkit/ui/gm.html +59 -0
  89. dndkit/ui/party.html +18 -0
  90. dndkit/ui/player.html +61 -0
  91. dndkit/ui/seating.html +18 -0
  92. dndkit/ui/shared.css +195 -0
  93. dndkit/ui/shared.js +242 -0
  94. dndkit/ui/sheet.js +75 -0
  95. dndkit/ui/table.html +41 -0
  96. dndkit/ui/words.js +120 -0
  97. dndkit/ui/world.js +56 -0
  98. dndkit/views/__init__.py +7 -0
  99. dndkit/views/backstage.py +49 -0
  100. dndkit/views/battle.py +92 -0
  101. dndkit/views/desk.py +76 -0
  102. dndkit/views/everyone.py +194 -0
  103. dndkit/views/public.py +89 -0
  104. dndkit/views/widgets.py +58 -0
  105. dndkit/views/world.py +70 -0
  106. dndkit/web/__init__.py +10 -0
  107. dndkit/web/access.py +136 -0
  108. dndkit/web/audiences.py +111 -0
  109. dndkit/web/changes.py +84 -0
  110. dndkit/web/feed.py +112 -0
  111. dndkit/web/routes.py +134 -0
  112. dndkit/web/server.py +131 -0
  113. dndkit-0.4.0.dist-info/METADATA +567 -0
  114. dndkit-0.4.0.dist-info/RECORD +118 -0
  115. dndkit-0.4.0.dist-info/WHEEL +5 -0
  116. dndkit-0.4.0.dist-info/entry_points.txt +2 -0
  117. dndkit-0.4.0.dist-info/licenses/LICENSE +22 -0
  118. dndkit-0.4.0.dist-info/top_level.txt +1 -0
dndkit/ui/dice.html ADDED
@@ -0,0 +1,42 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Dice tray</title>
7
+ <!--gm:shared-->
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <div id="status"></div>
12
+ <script>
13
+ const tray = Views.dice();
14
+ document.getElementById("app").append(tray.el);
15
+ const normalize = (text) => String(text || "").toLowerCase().replace(/\s+/g, "");
16
+ const loadedAt = Date.now() / 1000;
17
+
18
+ GM.onResult((result) => {
19
+ if (result.isError) GM.status(((result.content || [])[0] || {}).text || "error");
20
+ });
21
+
22
+ // This widget shows the roll its own call made, not merely the latest one.
23
+ GM.start(async () => {
24
+ const args = await GM.toolInput();
25
+ if (args.secret) return GM.status(`🔒 ${GM.t("secretRoll")}`); // not for the table's eyes
26
+ const wanted = normalize(args.notation);
27
+ let rolls = [];
28
+ let roll = null;
29
+ // The roll may land a moment after the page opens: look for it a few times.
30
+ for (let attempt = 0; attempt < 16 && !roll; attempt++) {
31
+ rolls = (await GM.data("dice")).rolls;
32
+ roll = rolls.slice().reverse().find((r) =>
33
+ r.t >= loadedAt - 5 && (!wanted || normalize(r.notation) === wanted)) || null;
34
+ if (!roll) await GM.sleep(250);
35
+ }
36
+ roll = roll || rolls[rolls.length - 1];
37
+ if (!roll) return GM.status(GM.t("waiting"));
38
+ tray.show(roll, rolls);
39
+ });
40
+ </script>
41
+ </body>
42
+ </html>
dndkit/ui/dicetray.js ADDED
@@ -0,0 +1,99 @@
1
+ /* The dice tray: polyhedral dice tumble and settle on the faces rolled; a
2
+ * natural 20 glows, a natural 1 shakes, the die advantage drops fades. */
3
+ "use strict";
4
+
5
+ import { GM } from "./shared.js";
6
+
7
+ export function DiceTray() {
8
+ const { h, fill, t } = GM;
9
+ const MAX_SHOWN = 24;
10
+ const COLORS = { 4: ["#d9674c", "#8f2f1d"], 6: ["#4f8fdc", "#1f4f8f"], 8: ["#5fb57a", "#226b3c"],
11
+ 10: ["#9a73dc", "#55308f"], 12: ["#dc9a4f", "#8f5520"], 20: ["#d8b45c", "#7d5a14"] };
12
+ const SHAPES = {
13
+ 4: '<polygon points="50,6 95,90 5,90"/>',
14
+ 6: '<rect x="10" y="10" width="80" height="80" rx="14"/>',
15
+ 8: '<polygon points="50,3 96,50 50,97 4,50"/><polyline points="4,50 96,50" class="edge"/>',
16
+ 10: '<polygon points="50,3 94,40 78,92 22,92 6,40"/><path d="M6,40 L50,58 L94,40 M50,58 L50,92" class="edge"/>',
17
+ 12: '<polygon points="50,3 95,35 78,92 22,92 5,35"/><polygon points="50,24 72,40 64,66 36,66 28,40" class="edge"/>',
18
+ 20: '<polygon points="50,3 92,27 92,73 50,97 8,73 8,27"/><polygon points="50,22 76,66 24,66" class="edge"/>',
19
+ };
20
+
21
+ const notation = h("span", { class: "title" }, "🎲");
22
+ const mode = h("span", { class: "muted small" });
23
+ const tray = h("div", { class: "dice" });
24
+ const total = h("div", { class: "total" }, h("span", { class: "muted" }, "…"));
25
+ const history = h("div", { class: "history" });
26
+ const el = h("div", { class: "dicebox" },
27
+ h("div", { class: "card tray" }, h("div", { class: "head" }, notation, mode), tray, total), history);
28
+ let shown = null;
29
+
30
+ function svgDie(sides) {
31
+ const [light, dark] = COLORS[sides] || ["#8d8a96", "#4a4752"];
32
+ const shape = SHAPES[sides] || '<circle cx="50" cy="50" r="46"/>';
33
+ return `<svg viewBox="0 0 100 100"><defs><linearGradient id="g${sides}" x1="0" y1="0" x2="1" y2="1">` +
34
+ `<stop offset="0" stop-color="${light}"/><stop offset="1" stop-color="${dark}"/></linearGradient></defs>` +
35
+ `<g fill="url(#g${sides})" stroke="rgba(255,255,255,.35)" stroke-width="2">${shape}</g></svg>`;
36
+ }
37
+
38
+ function dieElement(sides, face, delay, classes) {
39
+ const die = h("div", { class: `die d${sides} ${classes}`, style: `animation-delay:${delay}ms` });
40
+ die.innerHTML = svgDie(Number(sides)); // static markup from SHAPES only, no outside text
41
+ die.querySelectorAll(".edge").forEach((edge) => edge.setAttribute("fill", "none"));
42
+ const number = h("span", { class: "face" }, "?");
43
+ die.append(number, h("span", { class: "kind" }, `d${sides}`));
44
+ const settleAt = performance.now() + delay + 750;
45
+ const spin = () => {
46
+ if (performance.now() >= settleAt) { number.textContent = face; return; }
47
+ number.textContent = 1 + Math.floor(Math.random() * sides);
48
+ setTimeout(spin, 70);
49
+ };
50
+ setTimeout(spin, delay);
51
+ return die;
52
+ }
53
+
54
+ /** Roll *roll* on screen; *rolls* (the latest ones) fill the history under it. */
55
+ function show(roll, rolls) {
56
+ shown = `${roll.n}@${roll.t}`;
57
+ tray.replaceChildren();
58
+ let delay = 0;
59
+ let count = 0;
60
+ let crit = false;
61
+ let fumble = false;
62
+ roll.terms.forEach((term, index) => {
63
+ if (index > 0 || term.sign < 0) tray.append(h("span", { class: "const" }, term.sign < 0 ? "−" : "+"));
64
+ if (!term.sides) { tray.append(h("span", { class: "const" }, term.faces[0])); return; }
65
+ term.faces.forEach((face, i) => {
66
+ if (count >= MAX_SHOWN) return;
67
+ const kept = term.kept === null || term.kept === undefined || (face === term.kept && i === term.faces.indexOf(term.kept));
68
+ const natural = term.sides === 20 && kept ? (face === 20 ? "crit" : face === 1 ? "fumble" : "") : "";
69
+ crit = crit || natural === "crit";
70
+ fumble = fumble || natural === "fumble";
71
+ tray.append(dieElement(term.sides, face, delay, (kept ? "" : "dropped ") + natural));
72
+ delay += 110;
73
+ count += 1;
74
+ });
75
+ });
76
+ const hidden = roll.terms.reduce((n, term) => n + (term.sides ? term.faces.length : 0), 0) - count;
77
+ if (hidden > 0) tray.append(h("span", { class: "chip" }, `+${hidden}`));
78
+ notation.textContent = (roll.secret ? "🔒 " : "") + roll.notation;
79
+ mode.textContent = roll.mode === "normal" ? "" : t(roll.mode);
80
+ total.className = "total";
81
+ fill(total, h("span", { class: "muted small" }, t("waiting")));
82
+ setTimeout(() => {
83
+ total.className = "total" + (crit ? " crit" : fumble ? " fumble" : "");
84
+ fill(total, h("span", { class: "muted small" }, t("total")), h("b", {}, roll.total));
85
+ GM.fit();
86
+ }, delay + 800);
87
+ const older = (rolls || []).filter((r) => r.n !== roll.n).slice(-5).reverse();
88
+ fill(history, older.map((r) => h("span", { class: "chip" }, `${r.secret ? "🔒 " : ""}${r.notation} → ${r.total}`)));
89
+ GM.fit();
90
+ }
91
+
92
+ /** Web pages: roll the newest roll when one arrives. */
93
+ function render(data) {
94
+ const latest = data.rolls[data.rolls.length - 1];
95
+ if (latest && `${latest.n}@${latest.t}` !== shown) show(latest, data.rolls);
96
+ }
97
+
98
+ return { el, show, render };
99
+ }
dndkit/ui/gm.html ADDED
@@ -0,0 +1,59 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Backstage</title>
7
+ <!--gm:shared-->
8
+ </head>
9
+ <body class="web">
10
+ <div id="heading"></div>
11
+ <div class="board three">
12
+ <div class="col" id="play"></div>
13
+ <div class="col" id="plans"></div>
14
+ <div class="col" id="side"></div>
15
+ </div>
16
+ <div id="world"></div>
17
+ <div id="sheets"></div>
18
+ <div id="status"></div>
19
+ <script>
20
+ // The game master's backstage: exact numbers, plans and secrets, the full
21
+ // sheets, and every call the model makes, to follow it and see why
22
+ // something did not happen. Opens only on the machine running the game.
23
+ const heading = Views.campaign();
24
+ const combat = Views.combat();
25
+ const party = Views.party({ campaign: false });
26
+ const seating = Views.seating();
27
+ const backstage = Views.backstage();
28
+ const notebook = Views.notebook();
29
+ const activity = Views.activity();
30
+ const dice = Views.dice();
31
+ const journal = Views.journal();
32
+ const join = Views.join();
33
+ const world = Views.world();
34
+ const sheets = Views.sheets();
35
+
36
+ document.getElementById("heading").append(heading.el);
37
+ document.getElementById("play").append(combat.el, party.el, seating.el);
38
+ document.getElementById("plans").append(backstage.el, notebook.el, journal.el);
39
+ document.getElementById("side").append(activity.el, dice.el, join.el);
40
+ document.getElementById("world").append(world.el);
41
+ document.getElementById("sheets").append(sheets.el);
42
+
43
+ GM.start(() => {
44
+ GM.live("party", null, heading.render);
45
+ GM.live("party", null, (data) => { document.title = `${GM.t("backstage")} — ${data.campaign}`; party.render(data); });
46
+ GM.live("combat", null, combat.render); // shown even between fights: the last one stays
47
+ GM.live("seating", null, seating.render);
48
+ GM.live("backstage", null, backstage.render);
49
+ GM.live("notebook", null, notebook.render);
50
+ GM.live("activity", null, activity.render);
51
+ GM.live("dice", null, dice.render);
52
+ GM.live("journal", null, journal.render);
53
+ GM.live("join", null, join.render);
54
+ GM.live("world", null, world.render);
55
+ GM.live("sheets", null, sheets.render);
56
+ });
57
+ </script>
58
+ </body>
59
+ </html>
dndkit/ui/party.html ADDED
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Party board</title>
7
+ <!--gm:shared-->
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <div id="status"></div>
12
+ <script>
13
+ const board = Views.party();
14
+ document.getElementById("app").append(board.el);
15
+ GM.start(() => GM.live("party", null, board.render, 2500));
16
+ </script>
17
+ </body>
18
+ </html>
dndkit/ui/player.html ADDED
@@ -0,0 +1,61 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="theme-color" content="#17151b" />
7
+ <title>Character</title>
8
+ <!--gm:shared-->
9
+ </head>
10
+ <body class="web">
11
+ <div id="heading"></div>
12
+ <div class="board">
13
+ <div class="col" id="main"></div>
14
+ <div class="col" id="side"></div>
15
+ </div>
16
+ <div id="status"></div>
17
+ <script>
18
+ // One player's phone: their sheet, the fight, the party and the rolls.
19
+ // It buzzes when their turn comes.
20
+ const { h, t } = GM;
21
+ const banner = h("div", { class: "yourturn", hidden: true }, t("yourTurn"));
22
+ const sheet = Views.character();
23
+ const combat = Views.combat({ me: () => me });
24
+ const dice = Views.dice();
25
+ const journal = Views.journal();
26
+ const heading = Views.campaign();
27
+ document.getElementById("heading").append(heading.el);
28
+ const party = Views.party({ quests: false, campaign: false });
29
+ let me = null;
30
+ let fight = null;
31
+ let myTurn = false;
32
+
33
+ function checkTurn() {
34
+ const now = Boolean(me && fight && fight.in_combat && fight.order.some((f) => f.current && f.name === me));
35
+ banner.hidden = !now;
36
+ // Browsers refuse to vibrate before the first tap on the page.
37
+ const tapped = !navigator.userActivation || navigator.userActivation.hasBeenActive;
38
+ if (now && !myTurn && navigator.vibrate && tapped) navigator.vibrate([200, 100, 200]);
39
+ document.title = (now ? "▶ " : "") + (me || t("character"));
40
+ myTurn = now;
41
+ }
42
+
43
+ document.getElementById("main").append(banner, combat.el, sheet.el);
44
+ document.getElementById("side").append(dice.el, journal.el, party.el);
45
+
46
+ GM.start(() => {
47
+ GM.live("party", null, heading.render);
48
+ GM.live("me", null, (data) => { me = data.name; sheet.render(data); checkTurn(); });
49
+ GM.live("combat", null, (data) => {
50
+ fight = data;
51
+ combat.el.hidden = !data.in_combat;
52
+ combat.render(data);
53
+ checkTurn();
54
+ });
55
+ GM.live("dice", null, dice.render);
56
+ GM.live("journal", null, journal.render);
57
+ GM.live("party", null, party.render);
58
+ });
59
+ </script>
60
+ </body>
61
+ </html>
dndkit/ui/seating.html ADDED
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>The table</title>
7
+ <!--gm:shared-->
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <div id="status"></div>
12
+ <script>
13
+ const table = Views.seating();
14
+ document.getElementById("app").append(table.el);
15
+ GM.start(() => GM.live("seating", null, table.render, 2500));
16
+ </script>
17
+ </body>
18
+ </html>
dndkit/ui/shared.css ADDED
@@ -0,0 +1,195 @@
1
+ /* dndkit pages: shared look. Dark by default (roomkit-ui says
2
+ "dark" whatever its theme); a widget's body stays transparent so the
3
+ host's own background shows around the cards, a web page uses --page. */
4
+ :root {
5
+ color-scheme: dark;
6
+ --page: #17151b;
7
+ --card: #25222b;
8
+ --card-2: #312d39;
9
+ --line: #3d3747;
10
+ --text: #efe8dc;
11
+ --muted: #a69d90;
12
+ --gold: #d9b45f;
13
+ --gold-hi: #f5d889;
14
+ --red: #e3584f;
15
+ --green: #5cbf7b;
16
+ --amber: #e2a93f;
17
+ --violet: #b69cff;
18
+ --radius: 12px;
19
+ --sans: -apple-system, "Segoe UI", system-ui, "Helvetica Neue", sans-serif;
20
+ --serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
21
+ }
22
+
23
+ @media (prefers-color-scheme: light) {
24
+ :root {
25
+ color-scheme: light;
26
+ --page: #efe7d8;
27
+ --card: #fbf6ec;
28
+ --card-2: #efe6d4;
29
+ --line: #dccfb6;
30
+ --text: #2b2520;
31
+ --muted: #7d7064;
32
+ --gold: #a77b1f;
33
+ --gold-hi: #c8962a;
34
+ --violet: #6b4fc9;
35
+ }
36
+ }
37
+
38
+ html,
39
+ body {
40
+ margin: 0;
41
+ background: transparent;
42
+ color: var(--text);
43
+ font: 13px/1.4 var(--sans);
44
+ }
45
+
46
+ body {
47
+ padding: 8px 10px;
48
+ }
49
+
50
+ .card {
51
+ background: var(--card);
52
+ border: 1px solid var(--line);
53
+ border-radius: var(--radius);
54
+ padding: 12px 14px;
55
+ }
56
+
57
+ .title {
58
+ font-family: var(--serif);
59
+ font-size: 17px;
60
+ color: var(--gold);
61
+ letter-spacing: 0.02em;
62
+ }
63
+
64
+ .muted {
65
+ color: var(--muted);
66
+ }
67
+
68
+ .small {
69
+ font-size: 11px;
70
+ }
71
+
72
+ .row {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 8px;
76
+ }
77
+
78
+ .hp {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 8px;
82
+ min-width: 0;
83
+ }
84
+
85
+ .hpbar {
86
+ flex: 1;
87
+ height: 8px;
88
+ min-width: 60px;
89
+ background: var(--card-2);
90
+ border-radius: 4px;
91
+ overflow: hidden;
92
+ }
93
+
94
+ .hpbar > i {
95
+ display: block;
96
+ height: 100%;
97
+ background: linear-gradient(90deg, #3f9e5f, var(--green));
98
+ transition: width 0.6s ease;
99
+ }
100
+
101
+ .hpbar.mid > i {
102
+ background: linear-gradient(90deg, #c08a26, var(--amber));
103
+ }
104
+
105
+ .hpbar.low > i {
106
+ background: linear-gradient(90deg, #b8322b, var(--red));
107
+ }
108
+
109
+ .hplabel {
110
+ font-variant-numeric: tabular-nums;
111
+ font-size: 12px;
112
+ white-space: nowrap;
113
+ }
114
+
115
+ .chips {
116
+ display: flex;
117
+ flex-wrap: wrap;
118
+ gap: 4px;
119
+ }
120
+
121
+ .chips:empty {
122
+ display: none;
123
+ }
124
+
125
+ .chip {
126
+ padding: 1px 8px;
127
+ border-radius: 999px;
128
+ font-size: 11px;
129
+ background: var(--card-2);
130
+ color: var(--muted);
131
+ }
132
+
133
+ .chip.cond {
134
+ background: color-mix(in srgb, var(--violet) 18%, transparent);
135
+ color: var(--violet);
136
+ }
137
+
138
+ .chip.warn {
139
+ background: color-mix(in srgb, var(--amber) 20%, transparent);
140
+ color: var(--amber);
141
+ }
142
+
143
+ .chip.conc {
144
+ background: color-mix(in srgb, var(--gold) 18%, transparent);
145
+ color: var(--gold-hi);
146
+ }
147
+
148
+ .pips {
149
+ display: inline-flex;
150
+ gap: 3px;
151
+ vertical-align: middle;
152
+ }
153
+
154
+ .pips i {
155
+ width: 9px;
156
+ height: 9px;
157
+ border-radius: 50%;
158
+ border: 1.5px solid var(--gold);
159
+ box-sizing: border-box;
160
+ }
161
+
162
+ .pips i.on {
163
+ background: var(--gold);
164
+ }
165
+
166
+ .pips.bad i {
167
+ border-color: var(--red);
168
+ }
169
+
170
+ .pips.bad i.on {
171
+ background: var(--red);
172
+ }
173
+
174
+ .pips.good i {
175
+ border-color: var(--green);
176
+ }
177
+
178
+ .pips.good i.on {
179
+ background: var(--green);
180
+ }
181
+
182
+ #status {
183
+ color: var(--muted);
184
+ font-size: 12px;
185
+ padding-top: 6px;
186
+ }
187
+
188
+ #status:empty {
189
+ display: none;
190
+ }
191
+
192
+ .empty {
193
+ color: var(--muted);
194
+ font-style: italic;
195
+ }