The day bar had run out of room: two timers and four day controls came to more than the bar's width on every phone, so it wrapped onto two rows whenever a walk was running. Shaving pixels off both groups was not enough — even the most compact form of it only fitted a 428px iPhone Plus. The way out was structural. The browser's date picker is a sheet that covers the screen, which is backwards here: the reason to change day is to see what the figures did on it, and a modal hides exactly the thing you opened it for. So the date button now opens a small panel under the bar instead — month name with arrows, locale-ordered weekday initials, six rows of days — with the overview still on screen and updating as you move through it. That also solved the width, because Today belongs inside the grid rather than beside it. The day controls drop from about 204px to 111px, which is enough for both timers on one row from 320px up; only a 280px foldable cover still wraps. The bar keeps its wrapping for that case and for large system font sizes. The hidden input stays as the value everything reads — selectedDay() and every caller are untouched, and only the input's own picker is no longer opened. Six rows always, so the panel does not change height from month to month. Future days are disabled, matching the bar's → being disabled on today. The week starts where Intl says it does for the reader's locale, falling back to Monday. Arrow keys walk the grid and pull the neighbouring month into view at the edges. The grid arithmetic is checked rather than eyeballed, both week starts across every month of a year, leap years and year boundaries — including February 2026, which begins on a Sunday and so needs six leading days from January under Monday weeks. That is the case a naive `1 - getDay()` gets wrong, and it would have been invisible until somebody happened to open that month.
1981 lines
55 KiB
CSS
1981 lines
55 KiB
CSS
:root {
|
||
--bg: #f7f5ff;
|
||
--surface: #ffffff;
|
||
--text: #1c1b22;
|
||
--muted: #6b6b78;
|
||
--accent: #7c5cff;
|
||
--accent-soft: #ebe6ff;
|
||
--sleep: #6c8cff;
|
||
--eat: #ff9b3d;
|
||
--pee: #ffd23f;
|
||
--poo: #8a5a3b;
|
||
--weight: #2bb3a3;
|
||
--training: #b04ecf;
|
||
--walk: #3f9e63;
|
||
/* Asleep and awake each come in two values: the bright one for stripes,
|
||
tints and fills, and an -ink for text on those pale grounds. Both inks
|
||
flip with the theme; the brights don't. --sleep itself was doing both
|
||
jobs and failing the second — 2.6:1 on the timer pill it painted. */
|
||
--sleep-ink: #4258cc;
|
||
--wake: #f2b705;
|
||
--wake-ink: #6f4d00;
|
||
/* The two timers, on the dark theme, take a near-white with a sixth of the
|
||
gold mixed into it (#fdf4da): warm enough to belong to the sunshine, far
|
||
enough from --wake-ink not to be yellow text on a yellow pill again. In
|
||
the light theme the same pill is near-white cream, so anything pale would
|
||
vanish and it keeps the dark ink. Separate from --wake-ink so the list's
|
||
AWAKE labels stay gold and go on matching their stripe. */
|
||
--wake-timer-ink: #6f4d00;
|
||
--note: #6f7a90;
|
||
--danger: #d64545;
|
||
--gain: #2e9e5b;
|
||
--border: #e9e6f5;
|
||
--radius: 12px;
|
||
--shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05);
|
||
}
|
||
|
||
/* Dark palette. Two triggers, same values (CSS has no way to share them):
|
||
system prefers dark and the user hasn't forced light, OR the user picked
|
||
dark via the Settings toggle. The [data-theme] attribute (set from JS) beats
|
||
the media query on specificity, so an explicit choice always wins. */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
--bg: #14131a;
|
||
--surface: #1f1e28;
|
||
--text: #ebeaf2;
|
||
--muted: #9c9bab;
|
||
--accent: #a690ff;
|
||
--accent-soft: #2a2640;
|
||
--sleep-ink: #8ba4ff;
|
||
--wake-ink: #f5c451;
|
||
--wake-timer-ink: #fdf4da;
|
||
--border: #2c2a3a;
|
||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||
}
|
||
}
|
||
:root[data-theme="dark"] {
|
||
--bg: #14131a;
|
||
--surface: #1f1e28;
|
||
--text: #ebeaf2;
|
||
--muted: #9c9bab;
|
||
--accent: #a690ff;
|
||
--accent-soft: #2a2640;
|
||
--sleep-ink: #8ba4ff;
|
||
--wake-ink: #f5c451;
|
||
--wake-timer-ink: #fdf4da;
|
||
--border: #2c2a3a;
|
||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
/* The user agent's [hidden] { display: none } loses to any author rule that
|
||
sets a display, so every element given one here went on rendering while
|
||
hidden. This had been patched one selector at a time as each case was
|
||
noticed — .auth-screen[hidden], .snackbar[hidden], dialog label[hidden] and
|
||
seven more — which left the ones nobody had hit yet still broken: the 🌳
|
||
pedigree button, the reminder rule rows, "send a test notification", the
|
||
pedigree disambiguation list, and Delete in both the exercise and event
|
||
dialogs. Stating it once replaces all ten of those rules and stops the next
|
||
hidden element joining the list.
|
||
|
||
!important, because the offenders aren't all the same weight:
|
||
`#reminders-rules { display: flex }` is an id selector and outranks any
|
||
attribute selector we could write. Nothing should ever want a hidden element
|
||
painted, so the blunt instrument is the right one here. */
|
||
[hidden] { display: none !important; }
|
||
|
||
html, body {
|
||
margin: 0;
|
||
padding: 0;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
body {
|
||
max-width: 720px;
|
||
margin: 0 auto;
|
||
padding: env(safe-area-inset-top, 0) 16px env(safe-area-inset-bottom, 0) 16px;
|
||
}
|
||
|
||
header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 20px 4px 8px;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 1.5rem;
|
||
margin: 0;
|
||
}
|
||
|
||
.title {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
/* Truncate the name so a long one never runs into the action buttons. Scoped to
|
||
the header title so the auth-screen heading is unaffected. */
|
||
#app-title {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.puppy-age {
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
font-variant-numeric: tabular-nums;
|
||
/* One line in the common case; on a very narrow screen it wraps rather than
|
||
truncating, so the age is never cut off. The name (#app-title) is what
|
||
truncates to keep the buttons clear. */
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.icon-btn {
|
||
padding: 6px 10px;
|
||
font-size: 1.1rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 1rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
margin: 0 0 12px;
|
||
}
|
||
|
||
main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
padding: 16px 0 32px;
|
||
}
|
||
|
||
/* ---------- tabs ---------- */
|
||
/* Sticks directly under the day bar, whose measured height JS publishes as
|
||
--day-bar-h (its contents, and so its height, differ between phones). The
|
||
fallback keeps the bar usable for the frame before that lands. */
|
||
.tabs {
|
||
position: sticky;
|
||
/* One pixel under the day bar's height rather than exactly it: the inset is
|
||
free to be fractional while the measured height is a whole number, and a
|
||
sub-pixel shortfall would show as a hairline of page ground between the
|
||
two. The overlap is invisible — the day bar paints on top, same colour. */
|
||
top: calc(env(safe-area-inset-top, 0px) + var(--day-bar-h, 52px) - 1px);
|
||
z-index: 55; /* under the day bar, over the panels */
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 6px 4px;
|
||
margin: -8px 0 0;
|
||
/* Same card as the day bar rather than the page ground, so the two read as
|
||
one thing once they are frozen together. */
|
||
background: var(--surface);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
/* At rest the two are separate cards with the log buttons between them. Scroll
|
||
the log buttons away and they meet, so the seam goes: the day bar squares its
|
||
bottom and gives up its shadow, the tab bar squares its top, and the pair
|
||
sits inside one rounded frame casting one shadow. JS adds .merged the moment
|
||
they touch — CSS has no way to ask whether a sticky element is stuck. */
|
||
.day-bar.merged {
|
||
border-bottom-left-radius: 0;
|
||
border-bottom-right-radius: 0;
|
||
box-shadow: none;
|
||
}
|
||
.tabs.merged {
|
||
border-top-left-radius: 0;
|
||
border-top-right-radius: 0;
|
||
}
|
||
.tabs .tab {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 8px 4px;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
border-radius: var(--radius);
|
||
/* Five labels across a 360px phone: let them shrink rather than wrap. */
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.tabs .tab.active {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
.tabs .tab:hover { filter: none; }
|
||
|
||
/* Five labels have to fit without truncating on the narrow phones, and the
|
||
budget is tight: at a 320px viewport (iPhone SE, smaller Androids) the body's
|
||
own 16px gutters, the bar's padding and four gaps leave about 45px of text
|
||
per tab, while "Growth" — the widest label, roughly 3.3em — wants 46px at the
|
||
default size. So the same breakpoint the day bar already uses trims the type
|
||
and the padding, which takes the widest label to about 40px against 43px of
|
||
room even on a 280px foldable cover screen. */
|
||
@media (max-width: 370px) {
|
||
.tabs { gap: 2px; padding-left: 2px; padding-right: 2px; }
|
||
.tabs .tab { font-size: 0.75rem; padding-left: 2px; padding-right: 2px; }
|
||
}
|
||
|
||
/* main's own gap sits between the pinned blocks and the tab area; each tab
|
||
then spaces its own panels the same way. */
|
||
.tab-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
|
||
section {
|
||
background: var(--surface);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 16px;
|
||
}
|
||
|
||
/* Paints over the notch/status-bar strip so content scrolling behind the
|
||
sticky day bar never peeks through above it. Zero-height where there is no
|
||
safe-area inset. */
|
||
body::before {
|
||
content: "";
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: env(safe-area-inset-top, 0px);
|
||
background: var(--bg);
|
||
z-index: 65;
|
||
}
|
||
|
||
.day-bar {
|
||
position: sticky;
|
||
top: env(safe-area-inset-top, 0px);
|
||
z-index: 60;
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
/* Wraps only when it has to — which on a narrow phone is while a walk is
|
||
running and there are two timers to fit. The two groups above are what
|
||
make that wrap land in a sensible place. The bar's height changes when it
|
||
does, and the tab bar sticks to that height, so a ResizeObserver keeps
|
||
--day-bar-h honest (see measureDayBar). */
|
||
flex-wrap: wrap;
|
||
row-gap: 6px;
|
||
padding: 8px 10px;
|
||
}
|
||
/* The face button shows the short date; the real input sits invisibly behind
|
||
it so its native picker still anchors here (and .showPicker() has a target). */
|
||
.day-date {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
}
|
||
.day-date input[type="date"] {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
/* ---------- the month grid ---------- */
|
||
/* Anchored under the date button rather than filling the screen the way the
|
||
browser's own picker does: changing day is worth doing *because* of the
|
||
figures below, so they have to stay in view while you move. Deliberately
|
||
kept short — six rows of small cells — for the same reason. */
|
||
.day-cal {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 70; /* over the day bar itself, which is 60 */
|
||
width: 268px;
|
||
max-width: calc(100vw - 24px);
|
||
padding: 10px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.day-cal-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.day-cal-month {
|
||
font-weight: 700;
|
||
font-size: 0.9rem;
|
||
}
|
||
.day-cal-weekdays,
|
||
.day-cal-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
gap: 2px;
|
||
}
|
||
.day-cal-weekdays span {
|
||
text-align: center;
|
||
font-size: 0.7rem;
|
||
color: var(--muted);
|
||
padding: 2px 0;
|
||
}
|
||
button.cal-day {
|
||
background: transparent;
|
||
color: var(--text);
|
||
font-weight: 500;
|
||
font-size: 0.85rem;
|
||
font-variant-numeric: tabular-nums;
|
||
padding: 6px 0;
|
||
border-radius: 8px;
|
||
}
|
||
button.cal-day.other-month { color: var(--muted); opacity: 0.5; }
|
||
button.cal-day:disabled { opacity: 0.25; cursor: default; }
|
||
button.cal-day:disabled:hover { filter: none; }
|
||
/* Today is outlined, the selected day is filled — so "where I am" and "where
|
||
now is" stay tellable apart when they are different days. */
|
||
button.cal-day.is-today { box-shadow: inset 0 0 0 1.5px var(--accent); }
|
||
button.cal-day.is-selected {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-weight: 700;
|
||
}
|
||
.day-cal-today { width: 100%; margin-top: 8px; padding: 7px 10px; font-size: 0.85rem; }
|
||
|
||
#day-date-face {
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
.day-bar button:not(.bar-clock) {
|
||
padding: 8px 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
/* Keep the single row intact on narrow phones. */
|
||
@media (max-width: 370px) {
|
||
/* Three columns leave ~55px of text room on the narrowest phones, and
|
||
"⚖️ Weight" is the longest of the three; trimming the side padding keeps
|
||
it on one line down to about 360px. */
|
||
.action-row.three-up button { padding-left: 6px; padding-right: 6px; }
|
||
.day-bar { gap: 4px; }
|
||
.day-bar button:not(.bar-clock) { padding: 8px 7px; }
|
||
.bar-clock { font-size: 0.9rem; padding: 6px 8px; gap: 5px; }
|
||
}
|
||
.day-bar button:not(.bar-clock):disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
/* The timers sit left, the day controls right. Both are groups so that when a
|
||
running walk makes the row too long the bar wraps between them, rather than
|
||
stranding "Today" on a line by itself. */
|
||
.bar-timers {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-right: auto;
|
||
min-width: 0;
|
||
}
|
||
.day-nav {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
/* An empty timer group must not hold a line open once it has wrapped. */
|
||
.bar-timers:empty { display: none; }
|
||
|
||
.bar-clock {
|
||
display: flex;
|
||
/* It is a button (tapping it flips the sleep state), so undo the default
|
||
accent look — the asleep/awake/walking classes below paint it. */
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
align-items: center;
|
||
/* Sized so two of these fit beside the day controls on one row. The big card
|
||
is where a timer is meant to be read at size; this is a glance, which is
|
||
also why the pills count in minutes and the card keeps the seconds. */
|
||
gap: 4px;
|
||
padding: 7px 9px;
|
||
border-radius: 999px;
|
||
font-weight: 700;
|
||
font-size: 0.9rem;
|
||
font-variant-numeric: tabular-nums;
|
||
flex-shrink: 0;
|
||
}
|
||
/* Big timer still on screen: keep the pill's slot but show nothing. */
|
||
.bar-clock.standby { visibility: hidden; }
|
||
.bar-clock.asleep { background: color-mix(in srgb, var(--sleep) 18%, var(--surface)); color: var(--sleep-ink); }
|
||
/* Dark text on the yellow, the way the pee button already does it: a gold
|
||
light enough to read as sunshine is never legible as text on a pale ground.
|
||
12% where asleep takes 18% — equal percentages of these two hues are not
|
||
equally strong, and yellow at 18% shouted while the blue did not. */
|
||
.bar-clock.awake { background: color-mix(in srgb, var(--wake) 12%, var(--surface)); color: var(--wake-timer-ink); }
|
||
/* The walk timer takes the walk colour the rest of the app already uses for
|
||
walks, so the pill says which of the two it is without needing its label. */
|
||
.bar-clock.walking { background: color-mix(in srgb, var(--walk) 16%, var(--surface)); color: var(--walk); }
|
||
|
||
.big-clock {
|
||
text-align: center;
|
||
padding: 24px 16px;
|
||
}
|
||
.big-clock .bc-label {
|
||
font-size: 0.8rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--muted);
|
||
margin-bottom: 6px;
|
||
}
|
||
.big-clock .bc-time {
|
||
font-size: 3.25rem;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
line-height: 1.05;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.big-clock .bc-since {
|
||
margin-top: 6px;
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.big-clock.asleep { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--sleep) 10%, var(--surface))); }
|
||
.big-clock.asleep .bc-time { color: var(--sleep-ink); }
|
||
.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--wake) 10%, var(--surface))); }
|
||
.big-clock.awake .bc-time { color: var(--wake-timer-ink); }
|
||
.big-clock.walking { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--walk) 10%, var(--surface))); }
|
||
.big-clock.walking .bc-time { color: var(--walk); }
|
||
|
||
/* Quick actions: a stack of explicit rows (see index.html) instead of one
|
||
auto-fit grid, so the grouping is the same at every width. Equal columns
|
||
within a row, two by default and three for the dialog row at the bottom. */
|
||
.actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.action-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 10px;
|
||
}
|
||
.action-row.three-up { grid-template-columns: repeat(3, 1fr); }
|
||
|
||
button {
|
||
font: inherit;
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
padding: 14px 12px;
|
||
cursor: pointer;
|
||
background: var(--accent);
|
||
color: white;
|
||
font-weight: 600;
|
||
transition: transform 0.08s ease, filter 0.15s ease;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
button:active { transform: scale(0.97); }
|
||
button:hover { filter: brightness(1.05); }
|
||
|
||
button.action.sleep { background: var(--sleep); }
|
||
button.action.eat { background: var(--eat); }
|
||
button.action.pee { background: var(--pee); color: #2b240a; }
|
||
button.action.poo { background: var(--poo); }
|
||
button.action.weight { background: var(--weight); }
|
||
button.action.walk { background: var(--walk); }
|
||
button.action.note { background: var(--note); }
|
||
/* Unlikely given the current sleep state (see renderActionHints) — dimmed
|
||
but fully tappable, so corrections are never blocked. */
|
||
button.action.unlikely { opacity: 0.4; }
|
||
|
||
/* The boundary that would repeat the latest sleep event is disabled outright
|
||
(see renderActionHints) — greyed out rather than merely dimmed, so it reads
|
||
as unavailable instead of just discouraged. */
|
||
button.action:disabled {
|
||
opacity: 0.35;
|
||
filter: grayscale(1);
|
||
cursor: not-allowed;
|
||
}
|
||
button.action:disabled:active { transform: none; }
|
||
button.action:disabled:hover { filter: grayscale(1); }
|
||
|
||
button.ghost {
|
||
background: transparent;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
font-weight: 500;
|
||
padding: 10px 14px;
|
||
}
|
||
|
||
button.danger { background: var(--danger); }
|
||
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.stat {
|
||
background: var(--accent-soft);
|
||
padding: 12px;
|
||
border-radius: var(--radius);
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 0.75rem;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.stat-sub {
|
||
color: var(--muted);
|
||
font-size: 0.7rem;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 1.25rem;
|
||
font-weight: 700;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.lasts {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.last-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 6px 4px;
|
||
font-size: 0.95rem;
|
||
border-bottom: 1px dashed var(--border);
|
||
}
|
||
.last-row:last-child { border-bottom: none; }
|
||
.last-row span:first-child { color: var(--muted); }
|
||
|
||
.muted-note {
|
||
color: var(--muted);
|
||
font-size: 0.8rem;
|
||
font-weight: normal;
|
||
}
|
||
|
||
/* ---------- timing range charts ---------- */
|
||
.timing-charts {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.timing-name {
|
||
color: var(--muted);
|
||
font-size: 0.95rem;
|
||
padding: 0 4px;
|
||
}
|
||
.timing-chart { display: block; width: 100%; height: auto; }
|
||
/* Values wear ink, the words beside them are muted — the band alone carries
|
||
which type a chart is about (its heading says it too). */
|
||
.timing-chart text { fill: var(--text); font-size: 9px; font-variant-numeric: tabular-nums; }
|
||
.timing-chart .tm-word { fill: var(--muted); }
|
||
.timing-chart .tm-track { fill: var(--border); }
|
||
.timing-chart .tm-tick { stroke: var(--muted); stroke-width: 1; opacity: 0.45; }
|
||
.timing-chart .tm-band { fill-opacity: 1; }
|
||
/* The full spread sits behind the solid shortest-to-typical stretch, faded so
|
||
the everyday half of the range stays the one that reads first. */
|
||
.timing-chart .tm-range { fill-opacity: 0.32; }
|
||
.timing-chart .tm-pee { fill: var(--pee); }
|
||
.timing-chart .tm-poo { fill: var(--poo); }
|
||
.timing-chart .tm-eat { fill: var(--eat); }
|
||
/* "Since the last one" marker: ink over a surface-coloured ring, so it stays
|
||
readable on top of any band colour in either theme. */
|
||
.timing-chart .tm-now-ring { stroke: var(--surface); stroke-width: 7; stroke-linecap: round; }
|
||
.timing-chart .tm-now { stroke: var(--text); stroke-width: 3; stroke-linecap: round; }
|
||
.timing-chart .tm-over { fill: var(--text); }
|
||
.timing-note { margin: 2px 4px 0; }
|
||
.settings-hint { color: var(--muted); font-size: 0.8rem; margin: -4px 0 4px; line-height: 1.4; }
|
||
|
||
.history-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.history-controls label { color: var(--muted); font-size: 0.85rem; }
|
||
|
||
input[type="date"], input[type="datetime-local"], input[type="text"], input[type="number"], textarea {
|
||
font: inherit;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 8px 10px;
|
||
width: 100%;
|
||
}
|
||
|
||
textarea { resize: vertical; }
|
||
|
||
.event-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.wake-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.ww {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 10px 12px;
|
||
}
|
||
.ww .ww-range {
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--muted);
|
||
min-width: 120px;
|
||
}
|
||
.ww .ww-dur {
|
||
font-weight: 600;
|
||
flex: 1;
|
||
}
|
||
.ww.ongoing {
|
||
border-color: var(--accent);
|
||
background: var(--accent-soft);
|
||
}
|
||
/* Every row can carry a state, not only the open one: the merged sleep/wake
|
||
list labels each row asleep or awake, where the label is the whole point. */
|
||
.ww .ww-tag {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--muted);
|
||
}
|
||
/* A stripe in the row's own colour, so an alternating list reads as alternating
|
||
before you get to the words. */
|
||
.ww.sleep-ww { border-left: 3px solid color-mix(in srgb, var(--sleep) 55%, transparent); }
|
||
.ww.wake-ww { border-left: 3px solid color-mix(in srgb, var(--wake) 75%, transparent); }
|
||
.ww.walk-ww { border-left: 3px solid color-mix(in srgb, var(--walk) 55%, transparent); }
|
||
.ww.sleep-ww .ww-tag { color: var(--sleep-ink); }
|
||
.ww.wake-ww .ww-tag { color: var(--wake-ink); }
|
||
.ww.walk-ww .ww-tag { color: var(--walk); }
|
||
.ww.ongoing .ww-tag { color: var(--accent); }
|
||
.ww.sleep-ww.ongoing {
|
||
border-color: var(--sleep);
|
||
background: color-mix(in srgb, var(--sleep) 14%, var(--surface));
|
||
}
|
||
.ww.sleep-ww.ongoing .ww-tag { color: var(--sleep-ink); }
|
||
/* Spelled out like the other two: the faded stripe above would otherwise win
|
||
over the generic .ww.ongoing on specificity ties and leave the open awake
|
||
row without its full-strength edge. The tag needs saying too — .ww.wake-ww
|
||
.ww-tag ties with .ww.ongoing .ww-tag and loses on order. */
|
||
.ww.wake-ww.ongoing {
|
||
border-color: var(--wake);
|
||
background: color-mix(in srgb, var(--wake) 16%, var(--surface));
|
||
}
|
||
.ww.wake-ww.ongoing .ww-tag { color: var(--wake-ink); }
|
||
.ww.walk-ww.ongoing {
|
||
border-color: var(--walk);
|
||
background: color-mix(in srgb, var(--walk) 14%, var(--surface));
|
||
}
|
||
.ww.walk-ww.ongoing .ww-tag { color: var(--walk); }
|
||
|
||
.event {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.event .dot {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Rail bracketing a sleep or walk pair in the history log (see historyRails).
|
||
It is drawn in the section's own padding, left of the cards, so tying rows
|
||
together costs no layout: nothing indents and no row moves. The anchored end
|
||
stops at the row's middle, level with its dot; the other ends overshoot the
|
||
6px list gap by 4px each so consecutive segments overlap into one line. */
|
||
.event.rail { position: relative; }
|
||
.event.rail::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: -10px;
|
||
width: 0;
|
||
border-left: 2px dotted var(--rail, var(--border));
|
||
}
|
||
.event.rail-top::before { top: 50%; bottom: -4px; }
|
||
.event.rail-mid::before { top: -4px; bottom: -4px; }
|
||
.event.rail-bottom::before { top: -4px; bottom: 50%; }
|
||
.event.rail-sleep { --rail: color-mix(in srgb, var(--sleep) 70%, transparent); }
|
||
.event.rail-walk { --rail: color-mix(in srgb, var(--walk) 70%, transparent); }
|
||
.event[data-type="sleep-start"] .dot,
|
||
.event[data-type="sleep-end"] .dot { background: var(--sleep); }
|
||
.event[data-type="eat"] .dot { background: var(--eat); }
|
||
.event[data-type="pee"] .dot { background: var(--pee); }
|
||
.event[data-type="poo"] .dot { background: var(--poo); }
|
||
.event[data-type="weight"] .dot { background: var(--weight); }
|
||
.event[data-type="training"] .dot { background: var(--training); }
|
||
.event[data-type="walk-start"] .dot,
|
||
.event[data-type="walk-end"] .dot { background: var(--walk); }
|
||
.event[data-type="note"] .dot { background: var(--note); }
|
||
|
||
.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
|
||
.event .label { font-weight: 600; min-width: 110px; }
|
||
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; }
|
||
|
||
/* Notes log rows: a date instead of a time-of-day, then the note text. */
|
||
.event .note-date { font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||
.event .note-text { flex: 1; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
|
||
.empty {
|
||
color: var(--muted);
|
||
text-align: center;
|
||
padding: 12px 0;
|
||
}
|
||
|
||
.status-pill {
|
||
font-size: 0.75rem;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
.status-pill.offline { background: #ffe0e0; color: #b03030; }
|
||
.status-pill.error { background: #ffe0e0; color: #b03030; }
|
||
.status-pill.syncing { background: #e0eaff; color: #2a4ab0; }
|
||
.status-pill.pending { background: #fff1d4; color: #8a5a00; }
|
||
@media (prefers-color-scheme: dark) {
|
||
.status-pill.offline { background: #4a1f1f; color: #ff9090; }
|
||
.status-pill.error { background: #4a1f1f; color: #ff9090; }
|
||
.status-pill.syncing { background: #1f2c4a; color: #9eb6ff; }
|
||
.status-pill.pending { background: #4a3a1f; color: #ffd28a; }
|
||
}
|
||
|
||
dialog {
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
padding: 20px;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
box-shadow: var(--shadow);
|
||
max-width: 480px;
|
||
width: calc(100% - 32px);
|
||
}
|
||
|
||
dialog::backdrop { background: rgba(0,0,0,0.4); }
|
||
|
||
dialog h3 { margin: 0 0 12px; }
|
||
|
||
dialog label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 10px; }
|
||
dialog label input, dialog label textarea { margin-top: 4px; }
|
||
|
||
dialog menu {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 0;
|
||
margin: 14px 0 0;
|
||
}
|
||
|
||
.photo-field {
|
||
margin-top: 10px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.photo-field button { padding: 8px 12px; }
|
||
.photo-preview {
|
||
margin-top: 8px;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
.photo-thumb { position: relative; }
|
||
.photo-thumb img {
|
||
display: block;
|
||
width: 76px;
|
||
height: 76px;
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--accent-soft);
|
||
}
|
||
.photo-thumb-remove {
|
||
position: absolute;
|
||
top: -7px;
|
||
right: -7px;
|
||
width: 22px;
|
||
height: 22px;
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
background: var(--danger);
|
||
color: #fff;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.event .thumb {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 6px;
|
||
object-fit: cover;
|
||
border: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
cursor: zoom-in;
|
||
background: var(--accent-soft);
|
||
}
|
||
|
||
#lightbox {
|
||
background: #000;
|
||
padding: 0;
|
||
border: none;
|
||
max-width: 100vw;
|
||
max-height: 100vh;
|
||
width: auto;
|
||
height: auto;
|
||
}
|
||
#lightbox::backdrop { background: rgba(0,0,0,0.85); }
|
||
#lightbox img {
|
||
display: block;
|
||
max-width: 95vw;
|
||
max-height: 95vh;
|
||
object-fit: contain;
|
||
}
|
||
.lightbox-close {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
background: rgba(0,0,0,0.5);
|
||
color: #fff;
|
||
font-size: 1.5rem;
|
||
line-height: 1;
|
||
padding: 4px 12px 6px;
|
||
border-radius: 999px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.time-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.time-row input { flex: 1 1 120px; min-width: 0; }
|
||
.time-row button { padding: 8px 12px; }
|
||
|
||
/* The chart window is a page-level control rather than a panel's, so its row
|
||
skips the card chrome the sections have: no surface, no shadow, just a label
|
||
and the buttons sitting on the page background between two cards. */
|
||
.chart-window {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 0 4px;
|
||
}
|
||
.chart-window-label {
|
||
font-size: 0.75rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.chart-days-picker {
|
||
display: flex;
|
||
gap: 6px;
|
||
margin: -4px 0 14px;
|
||
}
|
||
/* No heading above it out here, so nothing to pull up under or clear beneath. */
|
||
.chart-window .chart-days-picker { margin: 0; }
|
||
.chart-days-picker button {
|
||
padding: 5px 12px;
|
||
font-size: 0.8rem;
|
||
}
|
||
.chart-days-picker button.active {
|
||
background: var(--accent);
|
||
border-color: transparent;
|
||
color: #fff;
|
||
}
|
||
|
||
.chart { margin-bottom: 16px; }
|
||
.chart:last-child { margin-bottom: 0; }
|
||
.chart-title {
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
margin-bottom: 6px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.chart-svg { display: block; width: 100%; height: auto; }
|
||
.chart-svg text { fill: var(--muted); font-size: 9px; }
|
||
/* Dense 1h y-axes: smaller labels so every gridline can carry one. */
|
||
.chart-svg text.y-dense { font-size: 7px; }
|
||
.chart-svg .grid {
|
||
stroke: var(--border);
|
||
stroke-dasharray: 2 3;
|
||
}
|
||
.chart-svg .bar { cursor: pointer; }
|
||
.chart-svg .bar-faded { opacity: 0.7; }
|
||
/* The selected day's slice in every per-day chart: a soft accent band behind
|
||
the bars/cells (visible even when the day's values are all zero) plus an
|
||
accent-colored day label. */
|
||
.chart-svg .day-highlight {
|
||
fill: var(--accent);
|
||
fill-opacity: 0.08;
|
||
stroke: var(--accent);
|
||
stroke-opacity: 0.45;
|
||
}
|
||
.chart-svg text.day-label-sel { fill: var(--accent); font-weight: 600; }
|
||
.chart-svg .bar-sleep { fill: var(--sleep); }
|
||
.chart-svg .bar-walk { fill: var(--walk); }
|
||
.chart-svg .bar-pee { fill: var(--pee); }
|
||
.chart-svg .bar-poo { fill: var(--poo); }
|
||
.chart-svg .bar-eat { fill: var(--eat); }
|
||
.chart-svg .weight-line { stroke: var(--weight); stroke-width: 2; fill: none; }
|
||
.chart-svg .weight-dot { fill: var(--weight); }
|
||
.chart-svg .weight-hit { fill: transparent; cursor: pointer; }
|
||
.chart-svg .weight-hit.active {
|
||
fill: color-mix(in srgb, var(--weight) 22%, transparent);
|
||
stroke: var(--weight);
|
||
stroke-width: 1.5;
|
||
}
|
||
|
||
.weight-point-info,
|
||
.hour-point-info {
|
||
margin: 6px 4px 0;
|
||
min-height: 1.2em;
|
||
text-align: center;
|
||
}
|
||
|
||
.weight-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.weight-summary .stat-value.up { color: var(--gain); }
|
||
.weight-summary .stat-value.down { color: var(--danger); }
|
||
.ww.weight-ww { cursor: pointer; }
|
||
/* Keep each weight row to one line: the date/age column shrinks and ellipses,
|
||
the weight value stays a fixed size, right-aligned and always visible. */
|
||
.ww.weight-ww .ww-range {
|
||
min-width: 0;
|
||
flex: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.ww.weight-ww .ww-dur { flex: 0 0 auto; text-align: right; }
|
||
|
||
.legend {
|
||
display: flex;
|
||
gap: 14px;
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
margin-top: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.lg { display: inline-flex; align-items: center; gap: 5px; }
|
||
.lg .sw {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 2px;
|
||
display: inline-block;
|
||
}
|
||
.lg.pee .sw { background: var(--pee); }
|
||
.lg.poo .sw { background: var(--poo); }
|
||
.lg.eat .sw { background: var(--eat); }
|
||
|
||
/* Interactive legend: each item is a checkbox that toggles its metric. */
|
||
.legend-toggle label.lg { cursor: pointer; user-select: none; }
|
||
.legend-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
|
||
.legend-toggle label.pee input { accent-color: var(--pee); }
|
||
.legend-toggle label.poo input { accent-color: var(--poo); }
|
||
.legend-toggle label.eat input { accent-color: var(--eat); }
|
||
/* Dim an unchecked item so it's clear its bars are hidden. */
|
||
.legend-toggle label.lg:has(input:not(:checked)) { opacity: 0.5; }
|
||
|
||
/* ---------- auth (login / register) ---------- */
|
||
.auth-screen {
|
||
position: fixed;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
background: var(--bg);
|
||
z-index: 50;
|
||
}
|
||
.auth-card {
|
||
width: 100%;
|
||
max-width: 360px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 28px 22px;
|
||
}
|
||
.auth-card h1 { margin: 0 0 4px; font-size: 1.5rem; text-align: center; }
|
||
.auth-sub { margin: 0 0 20px; text-align: center; color: var(--muted); font-size: 0.9rem; }
|
||
.auth-card label {
|
||
display: block;
|
||
font-size: 0.85rem;
|
||
color: var(--muted);
|
||
margin-bottom: 12px;
|
||
}
|
||
.auth-card input[type="email"],
|
||
.auth-card input[type="password"],
|
||
.auth-card input[type="text"] {
|
||
font: inherit;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
width: 100%;
|
||
margin-top: 4px;
|
||
}
|
||
.auth-card #auth-submit { width: 100%; margin-top: 6px; }
|
||
.auth-error {
|
||
color: var(--danger);
|
||
font-size: 0.85rem;
|
||
margin: 0 0 12px;
|
||
text-align: center;
|
||
}
|
||
.auth-toggle {
|
||
margin: 16px 0 0;
|
||
text-align: center;
|
||
font-size: 0.85rem;
|
||
color: var(--muted);
|
||
}
|
||
button.linklike {
|
||
background: none;
|
||
border: none;
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
padding: 0 0 0 4px;
|
||
display: inline;
|
||
cursor: pointer;
|
||
}
|
||
button.linklike:hover { text-decoration: underline; filter: none; }
|
||
|
||
/* ---------- delete account ---------- */
|
||
.settings-sep {
|
||
border: none;
|
||
border-top: 1px solid var(--border);
|
||
margin: 18px 0 12px;
|
||
}
|
||
.danger-block { width: 100%; }
|
||
.danger-text {
|
||
font-size: 0.9rem;
|
||
color: var(--muted);
|
||
margin: 0 0 14px;
|
||
line-height: 1.4;
|
||
}
|
||
.danger-text strong { color: var(--danger); }
|
||
|
||
/* ---------- days that don't count ---------- */
|
||
.overview-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.overview-head h2 { margin: 0; }
|
||
.exclude-btn {
|
||
flex: none;
|
||
padding: 4px 10px;
|
||
font-size: 0.78rem;
|
||
}
|
||
.exclude-btn.active {
|
||
background: var(--accent);
|
||
border-color: transparent;
|
||
color: #fff;
|
||
}
|
||
|
||
.excluded-note { margin: 8px 0 0; }
|
||
|
||
/* The day's own figures stay readable but visibly step back, so "this one is
|
||
not in the numbers" is legible from the day as well as from the charts. */
|
||
.overview.day-excluded .stats,
|
||
.overview.day-excluded .last-row { opacity: 0.55; }
|
||
|
||
/* The hatch every chart uses for a day that doesn't count. The pattern itself
|
||
is defined once in index.html; the stroke is set here so it follows the
|
||
theme, and the fill sits on a faint wash so the column reads as a marked
|
||
slot rather than as ink. */
|
||
.hatch-line {
|
||
stroke: var(--muted);
|
||
stroke-width: 2;
|
||
opacity: 0.5;
|
||
}
|
||
.bar-excluded {
|
||
fill: url(#hatch);
|
||
opacity: 0.45;
|
||
}
|
||
|
||
/* ---------- guest links ---------- */
|
||
.guest-list {
|
||
list-style: none;
|
||
margin: 12px 0 0;
|
||
padding: 0;
|
||
}
|
||
/* A link is two stacked rows: who and when on top, the URL underneath. */
|
||
.guest-item {
|
||
padding: 10px 0;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.guest-item-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.guest-item-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.guest-item-label {
|
||
display: block;
|
||
font-weight: 600;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.guest-item-sub {
|
||
display: block;
|
||
color: var(--muted);
|
||
font-size: 0.8rem;
|
||
}
|
||
button.guest-revoke { color: var(--danger); flex: none; }
|
||
|
||
/* The URL, always available so the link can be re-sent. It is 90-odd characters
|
||
of hex, so it gets one truncated line rather than wrapping into a wall of it
|
||
— the Copy button is the way it is meant to be taken, and tapping the text
|
||
selects the whole thing for anywhere the clipboard API is unavailable. */
|
||
.guest-item-url {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 6px 0 0;
|
||
}
|
||
.guest-url {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 0.75rem;
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
cursor: text;
|
||
}
|
||
button.guest-copy {
|
||
flex: none;
|
||
padding: 4px 10px;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
/* Only a guest ever sees this, directly under the header. No bottom margin:
|
||
main's own top padding provides the gap to the first panel. */
|
||
.guest-banner {
|
||
margin: 4px 0 0;
|
||
padding: 8px 12px;
|
||
border-radius: var(--radius);
|
||
background: var(--accent-soft);
|
||
color: var(--text);
|
||
font-size: 0.85rem;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* Who logged an event, when it came in on a guest link. Small caps so it reads
|
||
as a margin note against the event label rather than competing with it.
|
||
The row is a single non-wrapping line, so the badge is capped and ellipsised:
|
||
a long label ("Anna the neighbour's daughter") must not squeeze the note out. */
|
||
.event .by {
|
||
flex: none;
|
||
max-width: 10ch;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
background: var(--accent-soft);
|
||
border-radius: 999px;
|
||
padding: 2px 8px;
|
||
}
|
||
|
||
/* ---------- settings toggle switch ---------- */
|
||
.toggle-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.toggle-row > span { color: var(--text); font-size: 0.95rem; }
|
||
input.switch {
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
width: 44px;
|
||
height: 26px;
|
||
margin: 0;
|
||
border-radius: 13px;
|
||
background: var(--border);
|
||
position: relative;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
transition: background 0.15s ease;
|
||
}
|
||
input.switch::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: 3px;
|
||
left: 3px;
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
transition: transform 0.15s ease;
|
||
}
|
||
input.switch:checked { background: var(--accent); }
|
||
input.switch:checked::after { transform: translateX(18px); }
|
||
|
||
/* ---------- update banner ---------- */
|
||
.update-banner {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 70;
|
||
padding: calc(10px + env(safe-area-inset-top, 0)) 16px 10px;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.update-banner-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.update-changelog {
|
||
max-width: 480px;
|
||
margin: 6px auto 0;
|
||
padding: 0 0 0 18px;
|
||
font-size: 0.8rem;
|
||
line-height: 1.45;
|
||
opacity: 0.92;
|
||
}
|
||
|
||
.app-footer {
|
||
text-align: center;
|
||
padding: 4px 0 24px;
|
||
}
|
||
.app-footer .linklike { color: var(--muted); font-size: 0.8rem; }
|
||
|
||
.changelog-list {
|
||
list-style: none;
|
||
margin: 0 0 12px;
|
||
padding: 0;
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
.changelog-date {
|
||
color: var(--muted);
|
||
font-size: 0.75rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
margin: 10px 0 4px;
|
||
}
|
||
.changelog-date:first-child { margin-top: 0; }
|
||
.changelog-entry {
|
||
font-size: 0.9rem;
|
||
line-height: 1.45;
|
||
padding: 3px 0 3px 14px;
|
||
position: relative;
|
||
}
|
||
.changelog-entry::before {
|
||
content: "•";
|
||
position: absolute;
|
||
left: 2px;
|
||
color: var(--accent);
|
||
}
|
||
.update-banner-msg { font-size: 0.9rem; font-weight: 600; }
|
||
.update-banner-actions { display: flex; gap: 8px; }
|
||
.update-banner-btn {
|
||
background: #fff;
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
padding: 6px 14px;
|
||
}
|
||
.update-banner-btn.ghostish {
|
||
background: transparent;
|
||
color: #fff;
|
||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||
}
|
||
.update-banner-btn:hover { filter: brightness(0.97); }
|
||
|
||
/* ---------- quick-log snackbar ---------- */
|
||
.snackbar {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: calc(16px + env(safe-area-inset-bottom, 0));
|
||
transform: translate(-50%, 12px);
|
||
z-index: 60;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
max-width: calc(100% - 32px);
|
||
padding: 8px 8px 8px 16px;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
box-shadow: var(--shadow);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||
}
|
||
.snackbar.show {
|
||
opacity: 1;
|
||
transform: translate(-50%, 0);
|
||
pointer-events: auto;
|
||
}
|
||
.snackbar-msg {
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.snackbar-action {
|
||
background: transparent;
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
padding: 6px 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
.snackbar-action:hover { filter: none; background: var(--accent-soft); }
|
||
|
||
/* ---------- pattern charts (sleep timeline + hour heatmap) ---------- */
|
||
.chart-svg .stl-track {
|
||
fill: var(--bg);
|
||
stroke: var(--border);
|
||
stroke-width: 0.5;
|
||
}
|
||
.chart-svg .stl-sleep { fill: var(--sleep); }
|
||
/* Same actogram, walk windows instead of sleep ones (see drawActogram). */
|
||
.chart-svg .wtl-walk { fill: var(--walk); }
|
||
.chart-svg .stl-today { fill: var(--accent); font-weight: 600; }
|
||
/* Selected day: accent ring on the row's track + accent label. */
|
||
.chart-svg .stl-track.stl-selected {
|
||
stroke: var(--accent);
|
||
stroke-width: 1.2;
|
||
stroke-opacity: 0.8;
|
||
}
|
||
.chart-svg .stl-sel { fill: var(--accent); }
|
||
.chart-svg .stl-hit { fill: transparent; cursor: pointer; }
|
||
.chart-svg .stl-hit:hover { fill: var(--accent); fill-opacity: 0.08; }
|
||
|
||
/* "Now" rule on the midnight-to-midnight charts (actograms + hour heatmap):
|
||
ink over a surface-coloured underlay so it reads over any shaded window or
|
||
cell, and never eats a tap meant for the row underneath. */
|
||
.chart-svg .now-rule-ring { stroke: var(--surface); stroke-width: 3; pointer-events: none; }
|
||
.chart-svg .now-rule { stroke: var(--text); stroke-width: 1; opacity: 0.75; pointer-events: none; }
|
||
.chart-svg .now-rule-cap { fill: var(--text); opacity: 0.75; pointer-events: none; }
|
||
|
||
/* Sleep trend lines: today strongest, the reference curves lighter/dashed. */
|
||
.chart-svg .trend-today {
|
||
stroke: var(--sleep);
|
||
stroke-width: 2.5;
|
||
stroke-linejoin: round;
|
||
fill: none;
|
||
}
|
||
.chart-svg .trend-yesterday {
|
||
stroke: var(--eat); /* orange — the gray it had before sank into the grid */
|
||
stroke-width: 1.5;
|
||
stroke-linejoin: round;
|
||
fill: none;
|
||
opacity: 0.85;
|
||
}
|
||
.chart-svg .trend-avg {
|
||
stroke: var(--weight); /* teal — keeps it apart from today's blue and its blue projected tail */
|
||
stroke-width: 1.5;
|
||
stroke-dasharray: 4 3;
|
||
stroke-linejoin: round;
|
||
fill: none;
|
||
opacity: 0.85;
|
||
}
|
||
/* Today's projected tail: same weight/color as today so it reads as its
|
||
continuation, dashed + faded because it hasn't happened yet. */
|
||
.chart-svg .trend-projected {
|
||
stroke: var(--sleep);
|
||
stroke-width: 2.5;
|
||
stroke-dasharray: 2 4;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
fill: none;
|
||
opacity: 0.55;
|
||
}
|
||
.chart-svg .trend-goal { fill: var(--sleep); fill-opacity: 0.12; }
|
||
.lg.trend-goal .sw { background: color-mix(in srgb, var(--sleep) 20%, var(--surface)); }
|
||
.lg.trend-today .sw { background: var(--sleep); }
|
||
.lg.trend-projected .sw { background: color-mix(in srgb, var(--sleep) 40%, var(--surface)); }
|
||
.lg.trend-yesterday .sw { background: var(--eat); }
|
||
.lg.trend-avg .sw { background: var(--weight); }
|
||
|
||
/* Walk trend: the sleep trend's line weights, in the walk palette. The goal is
|
||
a single line rather than a band — a walk total is a target to reach, with no
|
||
upper bound to overshoot. */
|
||
.chart-svg .wtrend-today {
|
||
stroke: var(--walk);
|
||
stroke-width: 2.5;
|
||
stroke-linejoin: round;
|
||
fill: none;
|
||
}
|
||
.chart-svg .wtrend-yesterday {
|
||
stroke: var(--eat);
|
||
stroke-width: 1.5;
|
||
stroke-linejoin: round;
|
||
fill: none;
|
||
opacity: 0.85;
|
||
}
|
||
.chart-svg .wtrend-avg {
|
||
stroke: var(--weight);
|
||
stroke-width: 1.5;
|
||
stroke-dasharray: 4 3;
|
||
stroke-linejoin: round;
|
||
fill: none;
|
||
opacity: 0.85;
|
||
}
|
||
.lg.wtrend-today .sw { background: var(--walk); }
|
||
.lg.wtrend-yesterday .sw { background: var(--eat); }
|
||
.lg.wtrend-avg .sw { background: var(--weight); }
|
||
|
||
.chart-svg .hm-cell { stroke: none; }
|
||
/* Focused block: an accent ring, drawn at full opacity so it stays visible on
|
||
the palest (zero-count) cells. */
|
||
.chart-svg .hm-cell.hm-active {
|
||
stroke: var(--accent);
|
||
stroke-width: 1.2;
|
||
}
|
||
.chart-svg .hm-hit { fill: transparent; cursor: pointer; }
|
||
.chart-svg .hm-pee { fill: var(--pee); }
|
||
.chart-svg .hm-poo { fill: var(--poo); }
|
||
.chart-svg .hm-eat { fill: var(--eat); }
|
||
.chart-svg .hm-training { fill: var(--training); }
|
||
|
||
/* ---------- training ---------- */
|
||
.training-list {
|
||
list-style: none;
|
||
margin: 0 0 10px;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.training-list:empty { margin: 0; }
|
||
|
||
.exercise {
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.ex-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ex-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.ex-name { font-weight: 600; }
|
||
.ex-meta { color: var(--muted); font-size: 0.8rem; }
|
||
|
||
button.ex-log {
|
||
background: var(--training);
|
||
padding: 8px 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.ex-detail { display: none; }
|
||
.exercise.expanded .ex-detail {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.ex-note {
|
||
flex: 1;
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 0.9rem;
|
||
line-height: 1.4;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
button.ex-edit { padding: 6px 12px; flex-shrink: 0; }
|
||
|
||
.training-add { width: 100%; }
|
||
/* Charts that sit under a panel's list rather than being the whole panel, so
|
||
they need the separation a list-then-chart stack doesn't get for free. */
|
||
.training-chart { margin-top: 16px; }
|
||
.walk-chart { margin-top: 16px; }
|
||
|
||
/* ---------- collapsible panels ---------- */
|
||
/* A panel folds by its own heading: h2 for a whole section, h3 for a block
|
||
inside one. Scoped to main on purpose — the pedigree tree uses .collapsed
|
||
for its own branches, and a bare selector here would fold those too. */
|
||
main .collapsible > h2,
|
||
main .collapsible > h3 {
|
||
cursor: pointer;
|
||
position: relative;
|
||
padding-right: 20px;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
main .collapsible > h2::after,
|
||
main .collapsible > h3::after {
|
||
content: "▾";
|
||
position: absolute;
|
||
right: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
transition: transform 0.15s ease;
|
||
color: var(--muted);
|
||
font-size: 0.85em;
|
||
}
|
||
main .collapsed > h2::after,
|
||
main .collapsed > h3::after { transform: translateY(-50%) rotate(-90deg); }
|
||
main .collapsed > h2,
|
||
main .collapsed > h3 { margin-bottom: 0; }
|
||
main .collapsed > :not(h2):not(h3) { display: none; }
|
||
|
||
/* Sub-heading for a foldable block inside a panel: the section h2's look, a
|
||
size down, so it reads as subordinate to it rather than as a second panel. */
|
||
.subpanel > h3 {
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
/* ---------- pedigree lookup ---------- */
|
||
.pedigree-screen {
|
||
padding-top: 12px;
|
||
}
|
||
.pedigree-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 4px 8px;
|
||
}
|
||
.pedigree-header { flex-wrap: wrap; }
|
||
.pedigree-header h1 { font-size: 1.4rem; }
|
||
.ped-controls {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
#ped-foldall { padding: 5px 10px; font-size: 0.8rem; white-space: nowrap; }
|
||
.ped-zoom {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
.ped-zoom .icon-btn { padding: 4px 9px; font-size: 1.1rem; }
|
||
#ped-zoom-reset {
|
||
padding: 5px 8px;
|
||
font-size: 0.8rem;
|
||
font-variant-numeric: tabular-nums;
|
||
min-width: 48px;
|
||
}
|
||
|
||
.pedigree-hint { margin: 4px 0 12px; }
|
||
/* The tree scales with the CSS `zoom` property, which reflows so the container
|
||
still scrolls to reach the edges at any zoom. */
|
||
.pedigree-tree { touch-action: pan-x pan-y; }
|
||
|
||
.pedigree-status {
|
||
margin: 10px 0;
|
||
font-size: 0.9rem;
|
||
color: var(--muted);
|
||
}
|
||
.pedigree-status.busy::before {
|
||
content: "";
|
||
display: inline-block;
|
||
width: 12px; height: 12px;
|
||
margin-right: 8px;
|
||
vertical-align: -1px;
|
||
border: 2px solid var(--accent);
|
||
border-top-color: transparent;
|
||
border-radius: 50%;
|
||
animation: ped-spin 0.8s linear infinite;
|
||
}
|
||
@keyframes ped-spin { to { transform: rotate(360deg); } }
|
||
.pedigree-status.error { color: var(--danger); }
|
||
.pedigree-status.done { color: var(--gain); }
|
||
|
||
/* disambiguation list */
|
||
.pedigree-choose {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin: 8px 0 16px;
|
||
}
|
||
.ped-match {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
text-align: left;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
}
|
||
.ped-match:hover { border-color: var(--accent); }
|
||
.ped-match-name { font-weight: 600; }
|
||
.ped-match-meta { font-size: 0.8rem; color: var(--muted); }
|
||
|
||
/* looked-up dog */
|
||
.pedigree-subject {
|
||
margin: 6px 0 14px;
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--border);
|
||
border-left: 4px solid var(--accent);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
}
|
||
.ped-subject-name { font-size: 1.15rem; font-weight: 700; }
|
||
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
|
||
|
||
/* 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 {
|
||
overflow-x: auto;
|
||
padding: 8px 0 28px;
|
||
}
|
||
.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 {
|
||
position: relative;
|
||
width: 140px;
|
||
box-sizing: border-box;
|
||
padding: 7px 18px 7px 20px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
background: var(--surface);
|
||
box-shadow: var(--shadow);
|
||
text-align: center;
|
||
}
|
||
.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-titles { font-size: 0.66rem; color: var(--accent); margin-top: 2px; line-height: 1.2; }
|
||
.ped-reg {
|
||
font-size: 0.66rem;
|
||
color: var(--muted);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
margin-top: 1px;
|
||
}
|
||
/* sire ♂ (blue) / dam ♀ (purple) accents on the parent cards */
|
||
.ped-sire > .ped-card { border-top: 3px solid var(--sleep); }
|
||
.ped-dam > .ped-card { border-top: 3px solid var(--training); }
|
||
.ped-sire > .ped-card::after,
|
||
.ped-dam > .ped-card::after {
|
||
position: absolute;
|
||
right: 6px;
|
||
top: 5px;
|
||
font-size: 0.72rem;
|
||
}
|
||
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
|
||
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
|
||
|
||
/* 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 {
|
||
position: absolute;
|
||
left: 5px;
|
||
top: 5px;
|
||
width: 18px; height: 18px;
|
||
padding: 0;
|
||
font-size: 0.9rem;
|
||
line-height: 1;
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
background: var(--bg);
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
}
|
||
.ped-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
||
|
||
/* ---- radial fan chart ---- */
|
||
.ped-fan { display: block; margin: 0 auto; max-width: none; }
|
||
.ped-wedge {
|
||
cursor: pointer;
|
||
stroke: var(--border);
|
||
stroke-width: 1;
|
||
/* outer rings tint gradually darker for depth */
|
||
fill: color-mix(in srgb, var(--accent-soft) calc(var(--gen, 1) * 7%), var(--surface));
|
||
transition: filter 0.1s ease;
|
||
}
|
||
.ped-wedge:hover { filter: brightness(0.95); }
|
||
/* a dog that appears more than once is filled with its stable hue */
|
||
.ped-wedge-repeat { fill: hsl(var(--repeat-hue, 0), 60%, 80%); stroke: hsl(var(--repeat-hue, 0), 45%, 60%); }
|
||
.ped-wedge.ped-lit {
|
||
fill: hsl(var(--repeat-hue, 0), 72%, 62%);
|
||
stroke: hsl(var(--repeat-hue, 0), 72%, 38%);
|
||
stroke-width: 2;
|
||
}
|
||
.ped-wedge-label {
|
||
font-size: 8px;
|
||
fill: var(--text);
|
||
text-anchor: middle;
|
||
dominant-baseline: central;
|
||
pointer-events: none;
|
||
}
|
||
.ped-fan-center { fill: var(--accent); stroke: none; cursor: pointer; }
|
||
.ped-fan-center-label {
|
||
fill: #fff;
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
dominant-baseline: central;
|
||
pointer-events: none;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) .ped-wedge-label { fill: var(--text); }
|
||
}
|
||
.pedigree-caption {
|
||
margin: 10px 0 0;
|
||
font-size: 0.85rem;
|
||
color: var(--muted);
|
||
text-align: center;
|
||
}
|
||
|
||
/* ---- pee/poo confetti ---- */
|
||
#confetti-layer {
|
||
position: fixed;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
z-index: 9999;
|
||
}
|
||
.confetti-piece {
|
||
position: absolute;
|
||
line-height: 1;
|
||
will-change: transform, opacity;
|
||
animation-name: potty-firework;
|
||
animation-timing-function: ease-out;
|
||
/* `both` so the 0% state (invisible, at the bottom) also applies during the
|
||
per-piece launch delay — no flash before it takes off. */
|
||
animation-fill-mode: both;
|
||
}
|
||
/* Launch up from the bottom, rise to a peak while spreading sideways, slowing
|
||
(ease-out) and fading out as it reaches the top — a firework fountain.
|
||
Distances come from JS custom props. */
|
||
@keyframes potty-firework {
|
||
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5) rotate(0deg); }
|
||
10% { opacity: 1; }
|
||
70% { opacity: 1; }
|
||
100% { opacity: 0; transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--peakY))) scale(1) rotate(var(--rot)); }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.confetti-piece { display: none; }
|
||
}
|
||
|
||
/* ---------- reminders ---------- */
|
||
.settings-subhead {
|
||
margin: 0 0 10px;
|
||
font-size: 0.95rem;
|
||
color: var(--text);
|
||
}
|
||
#reminders-rules {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
margin: 12px 0;
|
||
}
|
||
.reminder-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
/* Overrides dialog's block labels: each rule is a switch and its name on one
|
||
line, with the interval pushed to the far end. */
|
||
.reminder-row .reminder-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin: 0;
|
||
min-width: 0;
|
||
font-size: 0.9rem;
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
}
|
||
.reminder-row .reminder-name input { margin-top: 0; }
|
||
.reminder-after {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
font-size: 0.85rem;
|
||
color: var(--muted);
|
||
}
|
||
input.reminder-mins {
|
||
width: 4.75rem;
|
||
padding: 6px 8px;
|
||
text-align: right;
|
||
}
|
||
/* The full-size switch crowds a row that also carries a number field. */
|
||
.reminder-row input.switch {
|
||
width: 36px;
|
||
height: 22px;
|
||
border-radius: 11px;
|
||
}
|
||
.reminder-row input.switch::after {
|
||
top: 3px;
|
||
left: 3px;
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
.reminder-row input.switch:checked::after { transform: translateX(14px); }
|
||
#reminders-test { width: 100%; }
|