Compare commits
3 Commits
c2f74e64c8
...
01d64b682b
| Author | SHA1 | Date | |
|---|---|---|---|
| 01d64b682b | |||
| 8157e95066 | |||
| 86e51bb851 |
+16
-3
@@ -92,19 +92,32 @@ func (cs *ConfigStore) get(userID string) Config {
|
|||||||
// merge applies an incoming config for one user with last-write-wins by
|
// merge applies an incoming config for one user with last-write-wins by
|
||||||
// UpdatedAt and returns the resulting stored config (which the caller sends back).
|
// UpdatedAt and returns the resulting stored config (which the caller sends back).
|
||||||
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
|
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
|
||||||
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
|
// Name/birthday/updated are last-write-wins: the incoming row replaces the
|
||||||
// replaces the stored one when it is strictly newer.
|
// stored one only when strictly newer. The pedigree id is stickier — an empty
|
||||||
|
// incoming value never clears a stored one, so a clock race between devices
|
||||||
|
// can't drop it; when both are set, the newer profile's id wins with the rest.
|
||||||
_, err := cs.db.Exec(`
|
_, err := cs.db.Exec(`
|
||||||
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
|
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
name = excluded.name, birthday = excluded.birthday,
|
name = excluded.name, birthday = excluded.birthday,
|
||||||
pedigree_id = excluded.pedigree_id, updated = excluded.updated
|
pedigree_id = CASE WHEN excluded.pedigree_id != '' THEN excluded.pedigree_id ELSE config.pedigree_id END,
|
||||||
|
updated = excluded.updated
|
||||||
WHERE excluded.updated > config.updated`,
|
WHERE excluded.updated > config.updated`,
|
||||||
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
|
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
// Adopt a pedigree id the server is missing even from an older-stamped profile,
|
||||||
|
// so a device that set it isn't blocked by another device's newer name/birthday
|
||||||
|
// edit. (A set id is only ever changed by a newer profile that also sets one.)
|
||||||
|
if in.PedigreeID != "" {
|
||||||
|
if _, err := cs.db.Exec(
|
||||||
|
`UPDATE config SET pedigree_id = ? WHERE user_id = ? AND pedigree_id = ''`,
|
||||||
|
in.PedigreeID, userID); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return cs.get(userID), nil
|
return cs.get(userID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+104
-22
@@ -230,6 +230,28 @@
|
|||||||
localStorage.setItem(configKey(), JSON.stringify(cfg));
|
localStorage.setItem(configKey(), JSON.stringify(cfg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile a local and a server profile. Name/birthday/updatedAt are plain
|
||||||
|
// last-write-wins by timestamp. The pedigree id is sticky: a non-empty value
|
||||||
|
// never loses to an empty one — so it can't be dropped by a clock race between
|
||||||
|
// devices — and when both are set the newer profile's id wins with the rest.
|
||||||
|
// (The server merge mirrors this, so a set id is only changed, never cleared,
|
||||||
|
// by sync.)
|
||||||
|
function reconcileConfig(local, server) {
|
||||||
|
const base = server.updatedAt >= local.updatedAt ? server : local;
|
||||||
|
return {
|
||||||
|
name: base.name,
|
||||||
|
birthday: base.birthday,
|
||||||
|
pedigreeId: (local.pedigreeId && server.pedigreeId)
|
||||||
|
? base.pedigreeId
|
||||||
|
: (local.pedigreeId || server.pedigreeId),
|
||||||
|
updatedAt: base.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function sameConfig(a, b) {
|
||||||
|
return a.name === b.name && a.birthday === b.birthday
|
||||||
|
&& a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
|
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
|
||||||
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
|
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
|
||||||
// at that point). Returns null for a missing/invalid birthday or a date before it.
|
// at that point). Returns null for a missing/invalid birthday or a date before it.
|
||||||
@@ -2040,12 +2062,14 @@
|
|||||||
pedigreeId: body.pedigreeId || "",
|
pedigreeId: body.pedigreeId || "",
|
||||||
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
|
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
|
||||||
};
|
};
|
||||||
if (server.updatedAt > local.updatedAt) {
|
const merged = reconcileConfig(local, server);
|
||||||
saveConfig(server);
|
if (!sameConfig(merged, local)) {
|
||||||
|
saveConfig(merged);
|
||||||
renderHeader();
|
renderHeader();
|
||||||
refreshPedigreeButton();
|
refreshPedigreeButton();
|
||||||
} else if (local.updatedAt > server.updatedAt) {
|
}
|
||||||
await pushConfig(local);
|
if (!sameConfig(merged, server)) {
|
||||||
|
await pushConfig(merged);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("config sync failed:", err);
|
console.warn("config sync failed:", err);
|
||||||
@@ -2434,8 +2458,9 @@
|
|||||||
const pedTree = document.getElementById("pedigree-tree");
|
const pedTree = document.getElementById("pedigree-tree");
|
||||||
const pedBtn = document.getElementById("pedigree-btn");
|
const pedBtn = document.getElementById("pedigree-btn");
|
||||||
const pedRefresh = document.getElementById("pedigree-refresh");
|
const pedRefresh = document.getElementById("pedigree-refresh");
|
||||||
|
const pedRepeatNote = document.getElementById("pedigree-repeat-note");
|
||||||
|
|
||||||
const PED_OPEN_DEPTH = 4; // generations shown expanded by default; deeper collapse
|
const PED_OPEN_DEPTH = 3; // show 3 generations expanded by default; deeper collapses
|
||||||
let pedPollTimer = null;
|
let pedPollTimer = null;
|
||||||
let pedNodes = {}; // latest ancestry map, for the progress count
|
let pedNodes = {}; // latest ancestry map, for the progress count
|
||||||
|
|
||||||
@@ -2556,20 +2581,24 @@
|
|||||||
// fills many positions); "generations back" is the depth of the deepest
|
// fills many positions); "generations back" is the depth of the deepest
|
||||||
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
|
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
|
||||||
function pedCounts() {
|
function pedCounts() {
|
||||||
const seen = new Set();
|
const counts = new Map();
|
||||||
let maxPos = 1;
|
let maxPos = 1;
|
||||||
for (const k in pedNodes) {
|
for (const k in pedNodes) {
|
||||||
const n = pedNodes[k];
|
const n = pedNodes[k];
|
||||||
const key = n.reg || n.name;
|
const key = n.reg || n.name;
|
||||||
if (key) seen.add(key);
|
if (key) counts.set(key, (counts.get(key) || 0) + 1);
|
||||||
const p = Number(k);
|
const p = Number(k);
|
||||||
if (p > maxPos) maxPos = p;
|
if (p > maxPos) maxPos = p;
|
||||||
}
|
}
|
||||||
return { distinct: seen.size, gens: Math.floor(Math.log2(maxPos)) };
|
let repeated = 0;
|
||||||
|
counts.forEach((c) => { if (c > 1) repeated++; });
|
||||||
|
return { distinct: counts.size, repeated, gens: Math.floor(Math.log2(maxPos)) };
|
||||||
}
|
}
|
||||||
function pedCountText() {
|
function pedCountText() {
|
||||||
const { distinct: a, gens: g } = pedCounts();
|
const { distinct: a, repeated: r, gens: g } = pedCounts();
|
||||||
return `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
|
let s = `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
|
||||||
|
if (r > 0) s += `, ${r} appearing more than once`;
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
|
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
|
||||||
function setPedStatus(text, kind) {
|
function setPedStatus(text, kind) {
|
||||||
@@ -2622,12 +2651,49 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam
|
// The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam
|
||||||
// 2n+1. We build recursively (sire above dam) and collapse below PED_OPEN_DEPTH.
|
// 2n+1. We render it top-down like a family tree — the dog on top, parents
|
||||||
|
// branching below — as a nested <ul>/<li> so CSS can draw the connectors.
|
||||||
|
// Built recursively (sire left, dam right) and collapsed below PED_OPEN_DEPTH.
|
||||||
|
// A dog's identity for spotting pedigree collapse: its registration number,
|
||||||
|
// or its name when it has none. Ancestors sharing a key are the same dog.
|
||||||
|
function dogKey(n) { return n ? (n.reg || n.name || "") : ""; }
|
||||||
|
// Stable hue per repeated dog so its badge/highlight colour is consistent
|
||||||
|
// everywhere it appears.
|
||||||
|
function hueFor(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||||
|
return h % 360;
|
||||||
|
}
|
||||||
|
let pedRepeat = {}; // dogKey -> occurrence count, for the current tree
|
||||||
|
|
||||||
function renderTree(nodes) {
|
function renderTree(nodes) {
|
||||||
pedNodes = nodes;
|
pedNodes = nodes;
|
||||||
pedTree.textContent = "";
|
pedTree.textContent = "";
|
||||||
|
// Count how many positions each dog fills, to flag pedigree collapse.
|
||||||
|
pedRepeat = {};
|
||||||
|
for (const k in nodes) {
|
||||||
|
const id = dogKey(nodes[k]);
|
||||||
|
if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1;
|
||||||
|
}
|
||||||
|
const anyRepeat = Object.values(pedRepeat).some((c) => c > 1);
|
||||||
|
if (pedRepeatNote) pedRepeatNote.hidden = !anyRepeat;
|
||||||
const root = buildPedNode(nodes, 1, 0);
|
const root = buildPedNode(nodes, 1, 0);
|
||||||
if (root) pedTree.append(root);
|
if (!root) return;
|
||||||
|
const ul = document.createElement("ul");
|
||||||
|
ul.className = "ped-tree-h";
|
||||||
|
ul.append(root);
|
||||||
|
pedTree.append(ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight (or unhighlight) every card that is the same dog as `key`.
|
||||||
|
function togglePedHighlight(key) {
|
||||||
|
const cards = pedTree.querySelectorAll(".ped-card[data-dogkey]");
|
||||||
|
let lit = false;
|
||||||
|
cards.forEach((c) => {
|
||||||
|
if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true;
|
||||||
|
});
|
||||||
|
cards.forEach((c) => c.classList.remove("ped-lit"));
|
||||||
|
if (!lit) cards.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPedNode(nodes, pos, depth) {
|
function buildPedNode(nodes, pos, depth) {
|
||||||
@@ -2636,8 +2702,7 @@
|
|||||||
const hasDam = !!nodes[String(pos * 2 + 1)];
|
const hasDam = !!nodes[String(pos * 2 + 1)];
|
||||||
if (!n && !hasSire && !hasDam) return null;
|
if (!n && !hasSire && !hasDam) return null;
|
||||||
|
|
||||||
const wrap = document.createElement("div");
|
const li = document.createElement("li");
|
||||||
wrap.className = "ped-node";
|
|
||||||
|
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "ped-card";
|
card.className = "ped-card";
|
||||||
@@ -2659,31 +2724,48 @@
|
|||||||
card.append(r);
|
card.append(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark dogs that fill more than one position (pedigree collapse). A ×N badge
|
||||||
|
// shows how many times, a stable colour ties the copies together, and tapping
|
||||||
|
// the card lights up every place this dog appears.
|
||||||
|
const key = dogKey(n);
|
||||||
|
if (key && pedRepeat[key] > 1) {
|
||||||
|
card.classList.add("ped-repeat");
|
||||||
|
card.dataset.dogkey = key;
|
||||||
|
card.style.setProperty("--repeat-hue", hueFor(key));
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "ped-repeat-badge";
|
||||||
|
badge.textContent = "×" + pedRepeat[key];
|
||||||
|
badge.title = "Appears " + pedRepeat[key] + " times in this pedigree — tap to highlight them all";
|
||||||
|
card.append(badge);
|
||||||
|
card.addEventListener("click", (e) => {
|
||||||
|
if (e.target.closest(".ped-toggle")) return; // let the expander do its job
|
||||||
|
togglePedHighlight(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
li.append(card);
|
||||||
|
|
||||||
if (hasSire || hasDam) {
|
if (hasSire || hasDam) {
|
||||||
const kids = document.createElement("div");
|
const kids = document.createElement("ul");
|
||||||
kids.className = "ped-children";
|
|
||||||
const s = buildPedNode(nodes, pos * 2, depth + 1);
|
const s = buildPedNode(nodes, pos * 2, depth + 1);
|
||||||
const d = buildPedNode(nodes, pos * 2 + 1, depth + 1);
|
const d = buildPedNode(nodes, pos * 2 + 1, depth + 1);
|
||||||
if (s) { s.classList.add("ped-sire"); kids.append(s); }
|
if (s) { s.classList.add("ped-sire"); kids.append(s); }
|
||||||
if (d) { d.classList.add("ped-dam"); kids.append(d); }
|
if (d) { d.classList.add("ped-dam"); kids.append(d); }
|
||||||
|
|
||||||
const collapsed = depth >= PED_OPEN_DEPTH;
|
const collapsed = depth >= PED_OPEN_DEPTH;
|
||||||
if (collapsed) wrap.classList.add("collapsed");
|
if (collapsed) li.classList.add("collapsed");
|
||||||
const toggle = document.createElement("button");
|
const toggle = document.createElement("button");
|
||||||
toggle.type = "button";
|
toggle.type = "button";
|
||||||
toggle.className = "ped-toggle";
|
toggle.className = "ped-toggle";
|
||||||
toggle.setAttribute("aria-label", "Toggle ancestors");
|
toggle.setAttribute("aria-label", "Toggle ancestors");
|
||||||
toggle.textContent = collapsed ? "+" : "−";
|
toggle.textContent = collapsed ? "+" : "−";
|
||||||
toggle.addEventListener("click", () => {
|
toggle.addEventListener("click", () => {
|
||||||
const nowCollapsed = wrap.classList.toggle("collapsed");
|
const nowCollapsed = li.classList.toggle("collapsed");
|
||||||
toggle.textContent = nowCollapsed ? "+" : "−";
|
toggle.textContent = nowCollapsed ? "+" : "−";
|
||||||
});
|
});
|
||||||
card.prepend(toggle);
|
card.prepend(toggle);
|
||||||
wrap.append(card, kids);
|
li.append(kids);
|
||||||
} else {
|
|
||||||
wrap.append(card);
|
|
||||||
}
|
}
|
||||||
return wrap;
|
return li;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- changelog dialog ----------
|
// ---------- changelog dialog ----------
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
[
|
[
|
||||||
|
{ "date": "2026-07-26", "text": "In the pedigree, a dog that fills more than one spot (pedigree collapse, common in a breed's older lines) now carries a ×N badge — tap it to highlight every place that dog appears in the tree" },
|
||||||
|
{ "date": "2026-07-26", "text": "The pedigree now reads top-down like a family tree — your dog on top with its sire and dam branching below — showing three generations at a glance, with each dog expandable to trace the line further back" },
|
||||||
|
{ "date": "2026-07-26", "text": "The pedigree ID set in Settings now syncs reliably to your other devices — it's no longer dropped when two devices' clocks disagree" },
|
||||||
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: add your dog's SKK chip or registration number in Settings to unlock it, then explore its ancestry as a tree — the first generations show at once and the line fills in further back as it's traced from SKK Hunddata. It's cached, so it reopens instantly and works offline" },
|
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: add your dog's SKK chip or registration number in Settings to unlock it, then explore its ancestry as a tree — the first generations show at once and the line fills in further back as it's traced from SKK Hunddata. It's cached, so it reopens instantly and works offline" },
|
||||||
{ "date": "2026-07-24", "text": "The age counter reads \"16 weeks (3 months and 3 weeks) old\" so weeks and months line up; past 4 months it drops the weeks and shows just months (e.g. \"5 months and 2 weeks old\")" },
|
{ "date": "2026-07-24", "text": "The age counter reads \"16 weeks (3 months and 3 weeks) old\" so weeks and months line up; past 4 months it drops the weeks and shows just months (e.g. \"5 months and 2 weeks old\")" },
|
||||||
{ "date": "2026-07-17", "text": "The Sleep trend chart follows the selected day — pick a past day to see its full curve against the day before and the average leading up to it" },
|
{ "date": "2026-07-17", "text": "The Sleep trend chart follows the selected day — pick a past day to see its full curve against the day before and the average leading up to it" },
|
||||||
|
|||||||
@@ -291,6 +291,7 @@
|
|||||||
<p id="pedigree-status" class="pedigree-status" hidden></p>
|
<p id="pedigree-status" class="pedigree-status" hidden></p>
|
||||||
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
|
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
|
||||||
<div id="pedigree-subject" class="pedigree-subject" hidden></div>
|
<div id="pedigree-subject" class="pedigree-subject" hidden></div>
|
||||||
|
<p id="pedigree-repeat-note" class="muted-note pedigree-repeat-note" hidden>Some ancestors appear in more than one place further back (pedigree collapse). Expand the tree to reveal their ×N badges, then tap one to highlight every spot that dog appears.</p>
|
||||||
<div id="pedigree-tree" class="pedigree-tree"></div>
|
<div id="pedigree-tree" class="pedigree-tree"></div>
|
||||||
</main>
|
</main>
|
||||||
</div><!-- /#pedigree-screen -->
|
</div><!-- /#pedigree-screen -->
|
||||||
|
|||||||
+101
-28
@@ -1123,59 +1123,132 @@ section.collapsed > :not(h2) { display: none; }
|
|||||||
.ped-subject-name { font-size: 1.15rem; font-weight: 700; }
|
.ped-subject-name { font-size: 1.15rem; font-weight: 700; }
|
||||||
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
|
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
|
||||||
|
|
||||||
/* the tree */
|
/* Top-down family tree: the dog on top, parents branching below, connected by
|
||||||
|
lines drawn with each <li>'s ::before/::after (the classic CSS tree). Wider
|
||||||
|
than the screen once expanded, so the container scrolls horizontally. */
|
||||||
.pedigree-tree {
|
.pedigree-tree {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 24px;
|
padding: 8px 0 28px;
|
||||||
}
|
}
|
||||||
.ped-node { position: relative; }
|
.ped-tree-h, .ped-tree-h ul {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.ped-tree-h {
|
||||||
|
/* "safe" centers when the tree fits and falls back to start-aligned (no
|
||||||
|
clipped/unreachable left edge) once it's wider than the screen. */
|
||||||
|
justify-content: safe center;
|
||||||
|
min-width: max-content;
|
||||||
|
padding: 4px 16px 12px;
|
||||||
|
}
|
||||||
|
.ped-tree-h ul {
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 22px; /* room for the connector from the parent above */
|
||||||
|
}
|
||||||
|
.ped-tree-h li {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 22px 6px 0;
|
||||||
|
}
|
||||||
|
/* Elbow from each child up to the horizontal bar shared by its siblings. */
|
||||||
|
.ped-tree-h li::before,
|
||||||
|
.ped-tree-h li::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 50%;
|
||||||
|
height: 22px;
|
||||||
|
border-top: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.ped-tree-h li::before { right: 50%; }
|
||||||
|
.ped-tree-h li::after { left: 50%; border-left: 2px solid var(--border); }
|
||||||
|
/* Vertical drop from a parent card down to its children's bar. */
|
||||||
|
.ped-tree-h ul::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
height: 22px;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
/* A lone parent connects with a straight line, no elbow. */
|
||||||
|
.ped-tree-h li:only-child::before,
|
||||||
|
.ped-tree-h li:only-child::after { display: none; }
|
||||||
|
/* Trim the outer half-lines at the ends of a sibling row. */
|
||||||
|
.ped-tree-h li:first-child::before,
|
||||||
|
.ped-tree-h li:last-child::after { border: 0 none; }
|
||||||
|
.ped-tree-h li:last-child::before { border-right: 2px solid var(--border); }
|
||||||
|
/* The dog sits on top with no connector above it. */
|
||||||
|
.ped-tree-h > li { padding-top: 0; }
|
||||||
|
.ped-tree-h > li::before,
|
||||||
|
.ped-tree-h > li::after { display: none; }
|
||||||
|
/* Collapsed: hide the ancestry below this dog (and its connectors go with it). */
|
||||||
|
.ped-tree-h li.collapsed > ul { display: none; }
|
||||||
|
|
||||||
.ped-card {
|
.ped-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
width: 140px;
|
||||||
padding: 6px 24px 6px 28px;
|
box-sizing: border-box;
|
||||||
margin: 3px 0;
|
padding: 7px 18px 7px 20px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.ped-name { font-weight: 600; font-size: 0.95rem; }
|
.ped-name { font-weight: 600; font-size: 0.85rem; line-height: 1.2; }
|
||||||
.ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; }
|
.ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; }
|
||||||
.ped-titles { font-size: 0.72rem; color: var(--accent); margin-top: 1px; }
|
.ped-titles { font-size: 0.66rem; color: var(--accent); margin-top: 2px; line-height: 1.2; }
|
||||||
.ped-reg {
|
.ped-reg {
|
||||||
font-size: 0.72rem;
|
font-size: 0.66rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
/* sire ♂ (blue) / dam ♀ (purple) accents on the parent cards */
|
||||||
/* lineage spine: children indented under the card */
|
.ped-sire > .ped-card { border-top: 3px solid var(--sleep); }
|
||||||
.ped-children {
|
.ped-dam > .ped-card { border-top: 3px solid var(--training); }
|
||||||
margin-left: 16px;
|
|
||||||
padding-left: 14px;
|
|
||||||
border-left: 2px solid var(--border);
|
|
||||||
}
|
|
||||||
.ped-node.collapsed > .ped-children { display: none; }
|
|
||||||
|
|
||||||
/* sire ♂ (blue) above dam ♀ (purple) markers on the parent-role cards */
|
|
||||||
.ped-sire > .ped-card { border-left: 3px solid var(--sleep); }
|
|
||||||
.ped-dam > .ped-card { border-left: 3px solid var(--training); }
|
|
||||||
.ped-sire > .ped-card::after,
|
.ped-sire > .ped-card::after,
|
||||||
.ped-dam > .ped-card::after {
|
.ped-dam > .ped-card::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 7px;
|
right: 6px;
|
||||||
top: 6px;
|
top: 5px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
|
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
|
||||||
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
|
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
|
||||||
|
|
||||||
/* expand/collapse toggle */
|
/* pedigree collapse: a dog filling more than one position gets a ×N badge, a
|
||||||
|
stable hue, and lights up (with every copy) when tapped. */
|
||||||
|
.ped-card.ped-repeat { cursor: pointer; }
|
||||||
|
.ped-repeat-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
bottom: 5px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
background: hsl(var(--repeat-hue, 0), 58%, 48%);
|
||||||
|
}
|
||||||
|
.ped-card.ped-lit {
|
||||||
|
border-color: hsl(var(--repeat-hue, 0), 70%, 50%);
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--repeat-hue, 0), 70%, 50%), var(--shadow);
|
||||||
|
}
|
||||||
|
.pedigree-repeat-note { margin: 0 0 10px; }
|
||||||
|
|
||||||
|
/* expand/collapse toggle (top-left of the card) */
|
||||||
.ped-toggle {
|
.ped-toggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 6px;
|
left: 5px;
|
||||||
top: 50%;
|
top: 5px;
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 18px; height: 18px;
|
width: 18px; height: 18px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user