# HabitGrid — implementation spec Habit tracker for Omarchy Quattro (Hyprland + Quickshell/QML). The UI is a GitHub-contribution graph for one habit at a time, with a habit chip row and a prominent +1. This document is authoritative. `reference.html` is a working prop of the same design — open it to feel the interactions, but build from the numbers here. Every value below is a device-independent pixel at scale 1. --- ## 1. Window: 53 weeks **53 columns × 7 rows = 371 day slots.** - 371 ≥ 366 guarantees a full year is always visible, leap year included. - Column 0 is the Sunday exactly 52 weeks before the Sunday of the current week, so **every column is a full Sun–Sat week** — no ragged left edge, no special-cased first column. - The last column is the current week. Slots after today are **not drawn at all** (transparent, no fill, no stroke) — they are not level-0. A level-0 cell means "you did nothing"; a future day means nothing yet. - At 11px cells the whole grid is 739px wide. Panel total 808px — comfortable on a 1366px laptop and small on 1920px, so the overlay never needs to scroll or shrink cells. 53 weeks is the widest window that still fits a sane panel; going wider would force cells below 11px, where the GitHub palette's level 1 becomes unreadable. Derivations: ``` PITCH = CELL + GAP = 11 + 3 = 14 gridW = 53*14 - 3 = 739 gridH = 7*14 - 3 = 95 TODAY_IDX = 52*7 + today.dayOfWeek() // Sunday = 0 ``` **Date math must be calendar-based** (`QDate.addDays` / `setDate`), never `msecs += 86400000`. Millisecond arithmetic slips a day across a DST boundary and shears the entire grid by one row for half the year. This bug was live in the prop until it was caught by looking at the rendered pushups pattern. --- ## 2. Panel **808 × 264** (normal state), `overflow: hidden`, box-sizing border-box. | Property | Value | |---|---| | Background | `bg` | | Border | 1px `border`, radius 12px | | Padding | 16px on all four sides | | Content width | 776px | If Hyprland already draws a border/rounding on the layer surface, drop the panel's own border and set radius 0 — never stack two borders. ### Vertical layout tree (sums to 264) ``` 16 padding-top 36 Header row, spaceBetween 14 spacing 26 ChipRow row, 8px gap 14 spacing GridBlock row 28 WeekdayLane (+ 9px gap to grid) 739 GridColumn 14 MonthStrip 4 spacing 95 Grid 13 spacing 16 Footer row, spaceBetween 16 padding-bottom ``` ### Horizontal (sums to 808) `16 + 28 (weekday lane) + 9 (gap) + 739 (grid) + 16 = 808` ### Narrower-than-808 fallback Do **not** scroll and do **not** shrink the cell. Drop whole weeks off the **left**: ``` weeks = clamp(floor((availableWidth - 68 + GAP) / PITCH), 26, 53) ``` Below 26 weeks the graph stops being a year view — show the panel at its 26-week minimum and let the compositor clip. `68 = 32 padding + 28 lane + 9 gap - 1`. --- ## 3. Type One family: the shell's monospace (`JetBrains Mono`, `CaskaydiaMono Nerd Font`, fallback `monospace`). No second family anywhere, including the empty states. | Role | Size | Line | Weight | Color | |---|---|---|---|---| | Habit name (header) | 13 | 15 | 600 | `textPrimary` | | Today line | 11 | 13 | 400 | `textSecondary` | | Today count value | 11 | 13 | 600 | `textPrimary`, tabular figures | | +1 button | 12 | — | 700 | `onAccent` | | Chip label | 11 | 1.0 | 500 (600 when selected) | `textSecondary` / `textPrimary` | | Month label | 9 | 14 | 500, +0.02em tracking | `textSecondary` | | Weekday label | 9 | 11 | 400 | `textSecondary` | | Stats line | 10 | 16 | 400 | keys `textMuted`, values `textSecondary`, tabular | | Legend "Less/More" | 9 | — | 400 | `textMuted` | | Tooltip | 11 | 14 | 600 value / 400 date | `#E6E9EF` / `#9AA3B2` | | Empty title | 13 | 15 | 600 | `textPrimary` | | Empty body | 11 | 16 | 400 | `textSecondary` | | Empty code sample | 10 | 14 | 400 | `textMuted` | Use tabular figures **only** in the stats line and the today count (they sit in runs and should not jitter on increment). Nowhere else. --- ## 4. Color ### 4.1 Themed tokens — map to Omarchy theme variables Bind these to your shell's theme singleton. The concrete hexes are the dark defaults, used verbatim when no theme is loaded. | Token | Dark default | Light default | Maps to | |---|---|---|---| | `bg` | `#101216` | `#FFFFFF` | theme background | | `panelBg` | `#141719` | `#FBFBFB` | theme popup-card background | | `surface` | `#191C22` | `#F6F7F9` | background, +6% lightness | | `surfaceHover` | `#22262E` | `#ECEEF1` | background, +11% | | `surfacePress` | `#14171C` | `#E2E5E9` | background, −2% / +14% | | `border` | `#2A2F37` | `#D8DCE1` | background, +14% | | `textPrimary` | `#E6E9EF` | `#1F2328` | theme foreground | | `textSecondary` | `#9AA3B2` | `#59636E` | foreground @ 65% | | `textMuted` | `#7C8695` | `#6E7781` | foreground @ 45% | | `accent` | `#7AA2F7` | `#2F6FEB` | theme accent (usually color4) | | `accentHover` | `#8FB3FF` | `#4680F0` | accent, +8% lightness | | `accentPress` | `#6690E8` | `#2560D0` | accent, −8% lightness | | `accentSubtle` | `#1E2637` | `#E4EDFD` | accent @ 16% over background | | `onAccent` | `#0C0E12` | `#FFFFFF` | text on accent fill | | `tooltipBg` | `#1C2027` | `#24292F` | (inverted in light, on purpose) | | `tooltipBorder` | `#343A44` | `#24292F` | | Contrast against `bg`, all ≥ 4.5:1: textSecondary 7.2, textMuted 5.0 (dark) / 6.0, 4.6 (light); accent 7.3 (dark) / 4.6 (light). `accentSubtle` is precomputed rather than an alpha blend so a themed accent doesn't produce a muddy chip on an unusual background — recompute it as `mix(background, accent, 0.16)` when the theme changes. `panelBg` is the same colour the shell paints on the popup card, drawn a second time by the panel behind its own content. Still, that is invisible; it exists so the whole panel surface takes part in the theme crossfade (§10) instead of the card flipping instantly under controls that are still easing. One colour lives outside the table: **`urgent`**, the shell's own urgent/error colour, whatever the loaded theme makes of it. It carries the delete question and its chip (§6.8), the reason a refused add or delete gives (§6.7), and the error copy in the unconfigured state (§9.1) — and nothing else. It is deliberately not a HabitGrid token: a destructive question should read as destructive in the same colour the rest of the shell already uses for that, rather than in a shade only this panel knows about. ### 4.2 Grid palette — FIXED, never themed The five cell levels do **not** move with the Omarchy theme. Pick the set by whether the theme is dark or light, and nothing else. | Level | Dark | Light | |---|---|---| | 0 (none) | `#161B22` | `#EBEDF0` | | 1 | `#0E4429` | `#9BE9A8` | | 2 | `#006D32` | `#40C463` | | 3 | `#26A641` | `#30A14E` | | 4 | `#39D353` | `#216E39` | Cell strokes (grid chrome, derived from text, not from the accent): | | Dark | Light | |---|---|---| | `cellStroke` (every cell) | `rgba(255,255,255,0.045)` | `rgba(27,31,35,0.06)` | | `cellStrokeHover` | `rgba(230,233,239,0.55)` | `rgba(31,35,40,0.45)` | All strokes are **inset** 1px (a border drawn inside the cell rect) so hover never changes layout. Note `bg` (`#101216`) is deliberately darker than level 0 (`#161B22`) so the empty grid reads as a field sitting on the panel, mirroring GitHub's own page-vs-empty-cell relationship. Do not set the panel background to `#161B22`. --- ## 5. Data → level The color is the only thing the grid encodes, so this rule must be documented for the user somewhere in the plugin's README. ``` level(count, habit): if count == 0: return 0 r = count / habit.ref if r >= 1.00: return 4 if r >= 0.67: return 3 if r >= 0.34: return 2 return 1 habit.ref = habit.goal if a goal is set = max(1, p75(nonzero counts in window)) otherwise ``` - **With a goal, level 4 means "goal met."** That is the single most useful thing the graph can say, and it is why the ramp is goal-relative rather than absolute. Counts above the goal stay at level 4 — overachieving does not get a sixth color. - **Without a goal, `ref` is the 75th percentile of the habit's own nonzero days.** Not max, not p90: for a near-binary habit like `read` those put a normal day at level 2 and the year looks perpetually half-finished. p75 puts a typical day at level 4 and still leaves headroom on a genuinely variable habit. - `ref` is recomputed when the window slides (i.e. at most daily), not per frame. **Stats** (over the visible window only): - `total` — sum of counts. - `best` — longest run of consecutive days with count > 0. - `streak` — run of consecutive days with count > 0 ending at today; if today is still 0, count the run ending yesterday, so an unlogged morning doesn't read as a broken streak. --- ## 6. Components ### 6.1 Header (36px) Left, a 2-line column with 3px spacing: 1. Habit name — 13/600 `textPrimary`. 2. Today line — `Today ` + ` / ` when a goal exists, then 7px, then the goal meter. **Goal meter**: 96 × 3px, radius 2, track `level0` with `cellStroke` inset, fill width `min(1, count/goal) * 96`, fill color = the *level color of today's count*. So the meter and today's cell always agree, and hitting the goal turns both to level 4 at the same instant. Hidden entirely for goal-less habits. Right: a 9px `textMuted` hint reading `+ key`, 10px gap, then the **+1 button** — 30px tall, min-width 54px, 12px horizontal padding, radius 6, fill `accent`, label `+1` in 12/700 `onAccent`. **While a delete is pending, the today line becomes the question** (§6.8): `Delete ?` in 11/600 `urgent`, 7px, then `its history stays in the file` in 11/400 `textSecondary`. The count, the goal meter and the past-year line all step aside for it; the habit name on the line above does not move. That is the whole reason the question lives here and not in a dialog — it appears directly under the name it is about, in the one line that is already about the selected habit, so it cannot be answered without having been read. The same slot afterwards carries the reason a refused delete gave, in `urgent`. ### 6.2 Chip row (26px) One horizontal row, 8px gap, left-aligned, **all habits, never wrapped and never scrolled**. Each chip: 26px tall, 10px horizontal padding, radius 6, 7px internal gap. Chip contents: a **6px status dot** (radius 1.5, filled with the level color of *that habit's* today count, `level0` + `cellStroke` when it's zero), then the label. The dot is why there is **no "All" chip**. An aggregate would have to sum counts across habits with incompatible units — 8 glasses + 30 pushups + 1 book is not a number — and a normalized "habits completed today" reuses the same green ramp to mean something entirely different, which reads as "did a lot" when it means "did many *kinds*." The dots give the all-habits glance honestly, in the space a chip would have cost. If you later want the aggregate anyway, make it a separate chip labelled with its own semantics (`completion`), map it to `habits_meeting_goal / habits_defined`, and never let it be the default selection. Chip states: | State | Fill | Border | Label | |---|---|---|---| | Default | `surface` | 1px `border` | `textSecondary`, 500 | | Hover | `surfaceHover` | 1px `border` | `textPrimary`, 500 | | Pressed | `surfacePress` | 1px `border` | `textPrimary`, 500 | | Selected | `accentSubtle` | 1px `accent` | `textPrimary`, 600 | | Focused (keyboard) | as above | + 2px `accent` ring, 2px offset | | | Pending delete (§6.8) | `urgent` @ 14% | 1px `urgent` | `urgent`, weight unchanged | Selected wins over hover. Hovering the selected chip does not change its fill. Pending delete wins over everything: the question is in the header, but the thing that would disappear is here, and the chip says so rather than leaving the reader to match a name in one place against a list in another. **The right end of the row is where a new habit is named** (§6.7). The inline field sits flush with the panel's right edge on the chip row's own line, never overlapping the chips it is about to join: its width is `clamp(96, rowWidth − chipsWidth − 12, 170)`. A habit is named in the shape it is about to take, in the place habits live — which is also why the field is a chip and not a dialog, a popup, or a second row. ### 6.3 Weekday lane (28px) & month strip (14px) - Weekday labels: rows 1, 3, 5 read `Mon`, `Wed`, `Fri`; rows 0, 2, 4, 6 blank. Right-aligned in the 28px lane, each 11px tall with 3px below to match the grid pitch. The lane's top padding is 18px (month strip 14 + gap 4) so row 0 aligns. - Month labels: for column `c`, let `ws` be that column's Sunday. Label it with `MMM` when `ws.day <= 7` (the week contains the 1st) **and** at least 3 columns have passed since the last label **and** `c <= 50`. Left-aligned at `x = c * 14`. The `c <= 50` guard stops a label from overflowing the right edge; the 3-column guard stops adjacent labels from colliding. That same guard leaves the last two columns of the strip permanently unlabelled — 28px of chrome that can never hold text, which is where the `+` goes (§6.7). ### 6.4 Grid (739 × 95) 53 columns, each a 7-cell vertical stack. Cell 11 × 11, radius **2**, gap 3 both axes. | Cell state | Fill | Inset stroke | |---|---|---| | Future (after today) | none | none | | Level 0–4 | palette | `cellStroke` | | Today | palette | `textSecondary` | | Hovered | palette | `cellStrokeHover` | | Keyboard-focused | palette | `accent` + 2px outer `accent` @ 45% | | Just incremented | palette | `textPrimary`, 120ms | Precedence, highest last: level → today → hover → keyboard focus → increment flash. The stroke is the *only* thing that changes on hover; the fill never lightens, so the reader is never misled about a cell's value. **Hit target** is the full 14 × 14 pitch rect (cell plus its gap), not the painted 11px. See §11 for why this is under the usual 24px minimum and what compensates. ### 6.5 Footer (16px) Left: stats, `Streak 14d · Best 29d · Total 1,564`. Keys in `textMuted`, values in `textSecondary`, `·` separators with 2 spaces either side. This is one line of 10px text in space that is otherwise empty — it does not bloat the panel, so keep it. Centre: the **key hint**, 9px `textMuted`, `·` separators with 2 spaces either side — the panel's keyboard vocabulary on one line, in the only strip of empty space it has. It is composed, not fixed: every entry is gated on being possible *right now*, so the line never advertises a key that would do nothing. - `h l habit · j k day` always. - `H L year` only when the year stepper is rendered at all (§15.2). - In a past year the line ends at `0 today`: nothing that writes is offered (§15.4). - A read-only source says `read-only` where a writable one says `⏎ +1 · x −1` (§15.6). - `n new` only when a source can take a new definition — the same condition that puts the `+` on screen (§6.7). - `e edit` only when the selected chip's source can change a goal or a name, and `e goal` rather than `e edit` when the goal is all it can change (§6.9). - `d delete` only when the selected chip's source can undefine one (§6.8). The states that are waiting for an answer replace the line entirely rather than appending to it, because while one is up the only keys that matter are the ones that answer it: `⏎ create · esc cancel` while a habit is being named, `⏎ delete · esc keep` — the whole line in `urgent` — while a delete is pending, and `⏎ save · esc cancel` while a habit is being edited. That last one names what is still reachable from where the keyboard is: `· ↑↓ name` beside the goal field, `· ↑↓ goal` beside the name field, and `· empty clears` wherever the goal is in hand (§6.9). Right: the scale legend — `Less`, five 10px swatches (radius 2, `cellStroke` inset) at levels 0–4, `More`. 5px gaps. **The legend is not optional**: it is the only thing that tells a reader what green means without hovering. ### 6.6 Tooltip **Content** — exactly one line, two runs: ``` × · , ``` - Value run: `6 × water` when count > 0, `No water` when count is 0. 11/600, near-white. - Date run: ` · Tue, Aug 12 2026`. 11/400, `#9AA3B2`. Day-of-month has **no** leading zero. Examples: `3 × water · Tue, Aug 12 2026` — `No water · Sun, Aug 10 2026`. The value leads and the date follows because the reader already knows roughly where they are pointing; they want the number. **Do not** add the goal, the level, a percentage, or a second line — the header owns goal context. The habit name comes from a user-authored markdown file. Insert it as **text**, never by string-building markup. **Geometry**: radius 6, padding 5px vertical / 8px horizontal, 1px `tooltipBorder`, drop shadow `0 4px 14px rgba(0,0,0,0.45)` (dark) / `rgba(31,35,40,0.16)` (light). A 4px triangular arrow. **Placement**: horizontally centered on the cell, 6px above it, arrow pointing down at the cell's center. Clamp `x` to `[6, panelWidth - tipWidth - 6]` and keep the arrow on the cell's true center after clamping, so the arrow slides within the tooltip near the panel edges. Flip below the cell only if there isn't 4px of room above — with this layout there always is, but implement the flip anyway. The tooltip is a **sibling of the grid inside the panel root**, z-above everything, clamped to panel bounds. A layer-shell surface cannot paint outside itself, so a tooltip that overflows the window will simply be cut off. It is expected and fine for the tooltip to overlap the chip row. **Timing**: | Event | Behavior | |---|---| | Pointer enters a cell, nothing shown | show after **150ms** | | Pointer moves to another cell, already shown | update content **immediately**, no re-delay | | Pointer leaves the grid | hide after **80ms** | | Keyboard focus moves to a cell | show **immediately**, no delay | | Increment while today's cell is hovered | content updates immediately | Fade in over 100ms. Never animate the tooltip's position — snap it. ### 6.7 Adding a habit — the `+` and the name field **The affordance is a 14 × 14 `+` glyph at the right end of the month strip**, flush with the grid's right edge: the same right edge as the +1 button above it and the legend below, so everything that acts on the panel sits in one column. 11px, `textMuted`, `textPrimary` on hover over 120ms. Its hit target reaches **8px above and 6px left** of the glyph — 14px of type is not a target, and the gap above the month strip holds nothing else to hit. Same compensation logic as §11's cells. **Why not beside the grid.** The grid is 739 of the 776px of content width (§2); anything parked to its right is paid for in whole weeks, and §2 drops weeks only to survive a narrow panel, never to make room for a control. In the month strip it costs nothing at all: §6.3's label algorithm leaves the last two columns permanently blank, so the glyph occupies chrome that was already empty and the year keeps all 53 of its weeks. **Why a glyph and not a button**: §12 — nothing may compete with +1, and a second filled control on the same edge would. **Shown only when a new habit could actually go somewhere**: a source that takes new definitions, and not while a past year is on screen (nothing is created into last year). Hidden while the field it opens is open — the glyph and the field are one control in two states, never both at once. **The field** is a chip you type into: 26px tall, radius 6, `surface`, 1px border in `focusRing` (`accent`, or `textPrimary` when §14.3 has given the accent to the grid), 11px `textPrimary` text, placeholder `new habit | goal` in `textMuted`, 64 characters maximum. It is a chip deliberately — the thing being named will *be* a chip, and the panel has no second idiom for a small labelled control (§6.2). The placeholder is the whole syntax lesson, present in the one moment the field is empty and there is nothing else to read, gone the instant it is not needed. **Focus contract.** While the field is open it owns every key: the panel's key catcher is **blocked**, so `x` is an x rather than the undo, `8` is a digit rather than the eighth habit, and Enter is "create" rather than "+1". There are exactly three ways out and there must never be a fourth — Enter **accepts**, Esc **cancels**, and the field says so when it **loses focus**. What that third one *means* belongs to whoever owns the field, not to the field: every owner of a single field maps it to cancel, so clicking a chip (or anything else that takes the keyboard) ends the add rather than leaving a live-looking field behind, and the two-field edit state (§6.9) maps it to "a transfer if the sibling field took the keyboard, cancel otherwise". Closing the field has to hand the keyboard back to the key catcher explicitly: a panel left with no focused item stops answering keys at all. Closing the panel discards a half-typed name — nothing may be waiting for an Enter the next time it opens. **A goal goes in the same field.** `water | goal: 8`, or the shorthand `water | 8`. The first `|` is unambiguously the separator and nothing needs escaping, because a habit name may not contain one (SPEC forbids it — a log line would be ambiguous). The `goal:` label is matched case-insensitively and normalised on the way out; both spellings are written to the file in SPEC's canonical form, so the backend normalises and the user never has to. A pipe with nothing usable after it is a goal that was meant and not finished: it is passed on as typed, so the answer is the backend's own "goal must be a positive whole number, like 8" beside the field, which is the syntax lesson at the moment it is needed. **The panel validates neither the name nor the goal.** Whatever stores the habit is the only judge of what it will accept, and its refusal is what the user reads. **A refusal never becomes the panel's error state.** The reason appears beside the field in 10px `urgent` — left of the chip-row field, right of the empty-state one — and the field stays open holding what was typed, because the fix for "habit 'water' is already defined" is usually one character. The panel-wide error state replaces *everything*, and is for "HabitGrid could not read your habits" (§9.1); a name that was turned down is not that. **On success** the created habit becomes the selected one, the cell cursor clears and the panel re-reads — the new chip is what you are looking at, since naming a habit is how you say you intend to log it. Enter on an empty field is the same as backing out. ### 6.8 Deleting a habit — the confirmation `d` asks; only Enter answers. Nothing is written in between, so one keystroke never deletes anything. | State | Input | Result | |---|---|---| | idle | `d` | pin the selected habit **and its source**; the question goes up | | pending | `Enter` | delete the pinned habit | | pending | `Space` | keep — and no +1 | | pending | any other key, `Esc` included | keep; the key does nothing else | | pending | chip click, habit switch, year step, +1, undo, panel close | keep | | pending | the pinned habit leaves the file (a sync pull) | the question is withdrawn | Three things say it at once, in the three places already reserved for the selected habit: the header asks (§6.1), the chip turns `urgent` (§6.2), and the footer offers `⏎ delete · esc keep` with the whole line in `urgent` (§6.5). **Enter and Space part company here, and only here.** Everywhere else the panel treats them as one gesture (§8: both are +1 on today). A confirmation that Space could answer would be answered by the very key people press to log — "yes, delete it" in one state and "count one more" in every other. Note the consequence for the implementation: a key catcher that reports one Enter as both a return *and* an activation must swallow the confirming Enter, or it falls through to the +1 behind the question. **The question is pinned to a habit, not to the selection.** The panel keeps polling the file while it waits (§7), and a habit deleted in Obsidian or synced away from a phone can move the selection underneath the question — so an Enter meant for `water` must land on `water` or on nothing. If the pinned habit leaves the file while the question is up, the question goes with it rather than retargeting. **The confirmation deliberately does not block the key catcher**, unlike the name field (§6.7). It has no field to hand the keyboard to, and blocking would send Enter and Esc — the only two keys it is waiting for — to nothing at all. Every other action guards itself against a pending question instead, which is what makes "any other key keeps" true without enumerating the keys. **What it deletes is the definition, not the history.** The habit's line leaves the file's habit list and every logged count stays exactly where it is, so adding the name back later brings the whole grid with it. That is why the reassurance is written into the question rather than into a docs page: it is half the answer, and it is what makes this a decision small enough to take with one key instead of a dialog. Afterwards the panel selects the **next chip along**, the previous one when the deleted habit was the last, and falls into the empty state (§9.1) when it was the only one — decided before the habit disappears, not looked up afterwards. **A refused delete** puts its reason in the header's question slot in `urgent` (§6.1), never in the panel's error state, for §6.7's reason: the habit is still perfectly fine and the panel should not act as though the file had become unreadable. **There is no delete button**, and there must not be one, for the same reason there is no minus button (§7, §12): a control that destroys something must not sit a pixel away from the one people press all day. `d` is one keystroke away from nothing else in the panel, and it still only asks. ### 6.9 Editing a habit — goal and name `e` edits the two things a habit *is*, each where it is already being read: the **name** in the header's heading, and the **goal** in the Today line under it (`6 / [8]`). One key, one state, one Enter — not a goal editor and a rename beside it. There is nothing to find and nothing to explain, which is the same argument §6.7 makes for naming a habit at the end of the chip row. **The goal takes the keyboard first.** It is the edit people come for; the name is the one they arrive at having meant something else. The name field is open the whole time regardless — see the indicator below — so getting to it is one arrow rather than a second decision. What `e` offers depends on what the selected habit's source advertises, and it is pinned when the key is pressed (a poll landing mid-edit must not take a field away from under the caret): | `canSetGoal` | `canRename` | `e` | |---|---|---| | yes | yes | both fields, goal focused | | yes | no | the goal field alone — the heading stays plain text | | no | yes | the name field alone, focused — the count line stays a count | | no | no | not offered: the footer hint is hidden and the key is inert | **Keys.** `↑` `↓` `Tab` `Shift+Tab` all **toggle** to the other field, so no press is ever dead — the same wrap rule the chip row's `[` `]` follow. `←` `→` never cross; they belong to the caret. `Enter` saves the **edit**, never "this field", whichever field it is pressed in. `Esc` discards both. Tab is safe to take because the key catcher is blocked while a field is open (§6.7), so it was never reaching panel switching from here. **The unfocused-editable indicator.** Both slots are chips for the whole state: the heading *becoming* a bordered, filled chip is the "this can be typed into" signal, and the panel's vocabulary for that is borders and fills, with one idiom for a small control (§6.2). Which chip holds the keyboard is said the way the chip row says "selected": | | border (1px) | fill | text | caret | |---|---|---|---|---| | focused | `focusRing` | `surface` | `textPrimary` | yes, selection shown | | editable, unfocused | `border` | `surface` | `textPrimary` | none | On a transfer the ring fades off one chip and onto the other over `quick` (120ms). That is the entire motion of this state; the grid cell animates for none of it, because the feedback contract reserves the cell for log writes (§7). **Layout.** The header grows from 36px to 56px for the duration of a two-field edit — two 26px chips and the 3px between them do not fit 36 — and shrinks back on close. The name field is 170px wide, 48 characters (the name's share of §6.7's 64-character line, so a goal never eats into it), placeholder `name`; the goal field is unchanged at 62px and 4 characters. A refusal sits **beside the field it refuses**, in 10px `urgent`, right of each — two fields need two slots, and §6.7's rule is that the reason stands next to the thing it is about. **Commit order: the name first, then the goal against the new name.** A refused rename aborts before anything is written, so failure has one clean shape; and writing the goal against the *new* name means exactly one name transition, and the second write can never race the old spelling. Nothing is spawned at all when neither value changed — `e Enter` is a no-op, deliberately, because an editor prefilled with what is already there and submitted unchanged is how people look at things. **Partial failure is a state, not an error.** If the rename lands and the goal is refused, the new name is already in the file: the pin follows it, the chip row and the name field show the habit as it now is, and only the goal stays open beside its reason. `Esc` from there discards the goal change and nothing else, which is correct — the rename is real. The other order cannot happen: a refused rename never reaches the goal write. **The edit is pinned to a habit**, like §6.8's question and for the same reason: the panel keeps polling, and an Enter meant for `water` must land on `water` or on nothing. The pinned habit leaving the file withdraws the edit. --- ## 7. Increment **Affordances**, all doing the same thing: - The **+1 button** in the header. - **Left-click on today's cell** (cursor becomes a pointer over it; no other cell is clickable). - The **`+`** or **`=`** key, whenever the panel has focus. - **Enter/Space** when today's cell is the keyboard cursor. **Feedback**, all within one frame of the click: 1. Today's cell fill jumps to the new level (120ms color cross-fade). 2. Today's cell shows a `textPrimary` inset stroke for **120ms**, then reverts to its `today` stroke. This is the "it registered" tick — it fires even when the level does not change (7 → 8 with goal 30 is still a real increment with no color change), which is exactly when the user most needs the confirmation. 3. Header count and goal meter update; the meter's width transitions over 140ms. 4. The selected chip's status dot updates. 5. Stats line updates. **Decrement / undo**: `-` key, or right-click on today's cell. Floors at 0. This is not advertised in the UI (no minus button — it would compete with the +1) but it must exist; a fat-fingered +1 that can only be fixed by editing the markdown in Obsidian is a bad day. **Writing**: increments mutate today's entry in the markdown file. Because the file is Syncthing-replicated, **re-read the file immediately before writing** and merge, rather than writing a cached buffer — two devices incrementing the same day must not clobber each other. Watch the file and re-render on external change; when re-reading, hold the current render rather than blanking the grid. --- ## 8. Keyboard Focus order: chip row (one stop) → +1 button → grid (one stop). | Key | Action | |---|---| | `←` / `→` | Move cell cursor one **week** (±7 days) | | `↑` / `↓` | Move cell cursor one **day** within the column | | `PgUp` / `PgDn` | ±4 weeks | | `Home` / `End` | First day of window / today | | `Enter`, `Space` | +1, only when the cursor is on today | | `←` / `→` on the chip row | Previous / next habit | | `[` / `]` | Previous / next habit, from anywhere | | `1`–`9` | Select the Nth habit | | `+` / `=` | +1 today | | `-` | −1 today | | `n` | Name a new habit — the same control the `+` opens (§6.7) | | `e` | Edit the selected habit — goal focused first; `↑`/`↓`/`Tab` switch between goal and name (§6.9) | | `d` | Ask to delete the selected habit; `Enter` confirms (§6.8) | | `Esc` | Close the panel | The cell cursor clamps to `[0, TODAY_IDX]` — it never lands on a future slot. The cursor always shows the tooltip immediately, which is what keeps every value reachable without a mouse. Entering the grid with no prior cursor starts on today. `n` is live only when a new habit could actually go somewhere, `d` only when the selected chip's source can undefine one, and `e` only when that source can change a goal or a name — the same conditions that gate the `+` (§6.7) and the footer's hint line (§6.5), so the panel never names a key that would do nothing. **Two states then change what the keyboard means, in two different ways:** - **A name field is open** — the add field (§6.7) or either of the edit state's two (§6.9): the table above does not apply at all. Every key goes into the field, and only Enter and Esc get back out. The edit state adds one exception of its own, and only within itself: `↑`/`↓`/`Tab`/`Shift+Tab` move between its two fields rather than being typed into either. - **A delete question is pending** (§6.8): the table still applies in one respect. `Enter` deletes; every other key in it — `Space` included — cancels the question **and does nothing else**, so the first press after `d` never both keeps the habit and moves the cursor. `Esc` closes the question before it closes the panel; the year-view rule in §15.3 is unchanged, since a year view is not a state that swallows anything. --- ## 9. Empty states ### 9.1 Unconfigured — no habit file Panel **808 × 220**. Header, chips, grid and footer are all hidden; nothing gestures at a UI that isn't usable yet. ``` 16 padding 15 "No habit file found" 13/600 textPrimary 6 32 "Expected at ~/Documents/Obsidian/Vault/habits.md" 11/16 textSecondary "Create it, or point HabitGrid at an existing note." (path in textPrimary) 8 84 code sample — surface fill, 1px border, radius 6, 7/9px padding, 10/14 textMuted, width hugs content (do NOT span the panel) 12 28 [ Create file ] [ Choose file… ] 8px gap 16 padding ``` `Create file` is the accent button; `Choose file…` is `surface` + 1px `border`, 11/500 `textPrimary`. The sample shown is illustrative — match it to whatever grammar the parser actually accepts: ``` - water (goal: 8) - read ## 2026-08-21 water: 6 ``` Same panel, different copy, when the file exists but defines zero habits: title `No habits in habits.md`, body naming the path, same sample, buttons `Open file` / `Choose file…`. **The empty state carries its own add affordance.** There is no chip row here to put a `+` at the end of, and a state that says "no habits yet" is precisely where naming one has to be reachable — so the button row holds the one button this panel can honour: **`+ new habit`**, chip-shaped (26px, radius 6, `surface`, 1px `border`, 11px `textPrimary`, `surfaceHover` / `surfacePress`), left-aligned in the row. Pressing it — or `n`, which works here exactly as it does anywhere else — swaps it for the same field §6.7 describes at a fixed 170px, with a refusal's reason 8px to its right in 10px `urgent`. The button appears only when a source can take a new definition, and the body copy is gated with it: it opens with "Name one below," when the button is there and starts at "Add them under `## Definitions`…" when it is not. Everything else in this state stays the file's job — writing a habit into it is the only part of "set this up" the panel knows how to do, and the grammar sample keeps teaching the rest. ### 9.2 Zero history — habit exists, no entries **Render the full grid, all cells at level 0.** Do not hide it, do not shrink the panel, do not substitute an illustration — the empty grid *is* the invitation, and keeping the layout means the panel doesn't jump the moment the first entry lands. Only two things differ from the normal state: - The stats line is replaced by `No entries yet — press +1 to start.` in 10px `textMuted`. - Everything else — chips, legend, today's ring, the +1 button — is unchanged and live. --- ## 10. Motion budget | Thing | Duration | Curve | |---|---|---| | Cell fill on level change | 120ms | ease-out | | Increment stroke flash | 120ms hold, no fade | — | | Chip fill / border / label | 120ms | ease-out | | Button fill | 120ms | ease-out | | Tooltip fade in | 100ms | ease-out | | Goal meter width | 140ms | ease-out | Nothing else animates. Nothing exceeds 150ms. **One exception: the theme crossfade.** `omarchy theme set` swaps the shell's whole palette at once. The panel copies that palette into its own tokens and eases every one of them — surfaces, text, accent, tooltip, and the card fill it paints for itself — across together over **420ms, InOutCubic**. It is outside the budget on purpose: this is a change of scene rather than feedback on something the user just did, and a 150ms palette swap reads as a flicker. It runs at most once per theme change and never during a gesture. Users who want the old instant switch set `HABITGRID_THEME_TRANSITION=none`. > This is the shipped budget. A feedback overhaul is in flight; when it lands, > `docs/design/desktop-feedback-spec.md` supersedes this section. --- ## 11. Known deviations, deliberately taken **The GitHub ramp fails a contrast check.** Running the two palettes through the sequential-ramp validator: both pass monotone lightness, adjacent-step ΔL, and single-hue, but both **FAIL** light-end contrast — level 1 is 1.59:1 against the dark surface and 1.44:1 against the light one, under the 2:1 floor. This is GitHub's actual design and the palette was explicitly requested, so it stands. Three things compensate, and all three are required, not optional: 1. Every cell carries a 1px inset `cellStroke`, so a cell's **shape** is legible even when its fill isn't — a level-1 cell always reads as a cell, never as a hole. 2. The scale legend is always present. 3. Every value is reachable exactly, by hover **and** by keyboard cursor, and totals are in the stats line — color is never the only channel. **Hit targets are 14px, under the 24px guideline.** A 53-week year at 24px pitch would be 1272px of grid, which is not a compact overlay. The 14px pitch is the whole point of the form. Compensations: full-pitch hit rects rather than the painted 11px, a 150ms delay so a fly-over doesn't strobe tooltips, immediate cell-to-cell updates once shown, and full keyboard traversal for anyone who can't land an 11px target. The one target that actually matters — today — is also a 54 × 30 button in the header. --- ## 12. What NOT to do - **No scrollbars.** The panel is sized to its content in every state. If width is tight, drop weeks (§2); never scroll, never shrink the cell below 11px. - **No animation over 150ms**, and no easing fancier than ease-out. No spring, no bounce, no staggered grid reveal on habit switch — the grid repaints at once. - **No fill change on cell hover.** Only the stroke changes. Lightening the fill makes a cell look like a different value. - **No theming of the five greens.** They are fixed; only the dark/light pair selection responds to the theme. - **No accent color inside the grid**, except the keyboard focus ring. A themed accent next to fixed greens is where this design goes ugly. - **No blur, no translucency, no gradients, no glow.** Flat fills only. - **No second font, no italics, no all-caps labels.** - **No level-0 cells for future days.** Draw nothing. - **No tooltip on future cells**, and no pointer cursor on them. - **No wrapping or scrolling the chip row.** If a user has more habits than fit, that's a real constraint to solve deliberately (elide with a `+3` overflow chip) — not by silently introducing a scroll area. - **No minus button in the header.** Decrement exists (§7) but must not compete visually with +1. - **No delete button anywhere** (§6.8), and **no second filled control on the panel's right edge** — the `+` is a glyph precisely so it cannot read as a rival to +1 (§6.7). - **No modal dialog to confirm a delete.** The question belongs in the header, under the name it is about; a dialog would be the only one in the product and would make a reversible edit feel like an irreversible one (§6.8). - **No panel-wide error for a refused add or a refused delete.** That state replaces everything and means "the habits could not be read". A name or a goal that was turned down belongs beside the field, and a delete that failed belongs beside the habit it was about (§6.7, §6.8). - **No second inline-editor idiom.** One field shape, one set of focus rules; a second widget is a second set of focus rules to get wrong (§6.7). - **No "All" aggregate chip** summing incompatible units (§6.2). - **No spinner or skeleton** when the file reloads. Hold the current render. - **No leading zero** in the tooltip's day-of-month, and no extra lines in the tooltip. - **No `msecs += 86400000`** anywhere in the date code (§1). --- ## 13. The prop `reference.html` — self-contained, no external resources, dark by default. It demonstrates §1–§12 (the default `palette: "github"`, single markdown source, rolling `Past year` view). It does **not** show §15's year stepper or multi-source chip row; the spec text governs those. Deep links for reviewing states without clicking: | Hash | Shows | |---|---| | *(none)* | water, dark | | `#read` `#pushups` `#meditate` | that habit selected | | `#light` | light palette | | `#zero` | zero-history state | | `#unconf` | unconfigured state | | `#light,read` | combined, comma-joined | Sample data is deterministic (seeded PRNG) but anchored to the *real* current date, so the last column is always the live week. Four habits: `water` (goal 8, daily, builds over the year), `read` (no goal, near-binary, one long dry spell), `pushups` (goal 30, Mon/Wed/Fri only, ramps 18→42), `meditate` (goal 2, sporadic with a 3-week lapse). The prop-controls strip below the panel is not part of the design. --- ## 14. Themed ramp — verdict and opt-in recipe **The grid ramp stays fixed GitHub green by default. Everything that is not data follows the theme.** Config key `palette: "github" | "theme"`, default `"github"`. ### 14.1 Why green wins for the grid 1. **Green is semantically load-bearing.** Green = done is near-universal. A Gruvbox-red or ochre ramp reads as *warning* — a solid red year says "you failed" when it means "you hit your goal 340 days running." Rose Pine pink reads as decoration. The ramp is the only thing the grid encodes; letting the theme invert its emotional reading is a functional regression, not a style choice. 2. **The accent is already spoken for.** It is the +1 button, the selected chip, and the focus ring. Paint 371 cells in that same hue and the focus ring stops reading as focus and the selected chip stops separating from the grid behind it. §12 already forbids accent inside the grid for exactly this reason. 3. **A single accent is a point; a ramp needs a range.** Generating four distinguishable steps from one color fails unpredictably on real Omarchy themes — low-chroma accents (Everforest, Gruvbox) can't produce four separable steps, and pale accents on light themes (Rose Pine Dawn, Catppuccin Latte) have no room to go lighter. Level 1 already sits under the contrast floor with a *hand-tuned* palette (§11); a generated one makes that worse in ways nobody can check per-theme. 4. **Adjacency pressure is on the bar, not the panel.** The panel is a self-contained surface that dominates its own frame while open; the bar icon is what sits permanently beside other theme-following plugins. Fix cohesion where it actually bites — see 14.2. The grid reads as *themed content in a themed frame*: the panel background, border, chips, header, footer, tooltip and all text are theme tokens. Only the data is green. ### 14.2 What must follow the theme (all of it already does — keep it that way) | Element | Color | Note | |---|---|---| | Panel bg / border / surfaces / all text | theme tokens | unchanged | | **+1 button** fill | `accent` | it's a control, not data — keep themed | | **Selected chip** fill + border | `accentSubtle` / `accent` | keep themed | | Focus rings | `accent` | keep themed | | **Bar icon + "all logged today" glow** | `accent` | **keep themed, non-negotiable** — this is the element that lives permanently next to other plugins, and "all logged" is a state, not a data magnitude. Do not make it green to match the grid. | | Chip status dots | grid ramp | these *are* data — green | | Legend swatches | grid ramp | it's the key to the data — green | ### 14.3 Recipe for `palette: "theme"` (opt-in) Do **not** use `Qt.lighter()` / `Qt.darker()` — they scale HSV value and clip on saturated or near-black accents, producing non-monotone ramps. Build the ramp in HSL with an explicit lightness spine and a chroma floor: ```qml function ramp(accent, isDark) { var h = accent.hslHue; // 0..1 var s = Math.min(0.95, Math.max(accent.hslSaturation, isDark ? 0.55 : 0.45)); // chroma floor var lo = isDark ? 0.18 : 0.78; // level 1 var hi = isDark ? 0.62 : 0.32; // level 4 var out = []; for (var k = 0; k < 4; k++) out.push(Qt.hsla(h, s, lo + (hi - lo) * k / 3, 1)); return out; // levels 1..4 } // level 0 (empty): mix(bg, textPrimary, isDark ? 0.06 : 0.08) ``` - **Monotone lightness by construction**, ΔL ≈ 0.147 per step (dark) / 0.153 (light) — both far above the 0.06 floor, and both better-separated than GitHub's own ramp. - **Level 4 stays "goal met"**: it is the extreme of the spine, so it remains the most salient step regardless of accent. - **Level 1 vs empty**: level 1 sits at L 0.18 against an empty cell at L ≈ 0.09, *and* carries chroma where empty is neutral — a bigger gap than the fixed green ramp achieves. This is the one check theme mode wins. - **The 1px inset stroke mitigation is unchanged.** It derives from text tokens, not the ramp, so it works identically. Two knock-on changes ship **with** theme mode, not as separate options: 1. **Cell keyboard focus ring** switches from `accent` to `textPrimary` — it can no longer be distinguished from cells of the same hue. 2. **+1 button** switches to a neutral fill (`textPrimary` bg, `bg` label) so the panel's primary action doesn't dissolve into 371 cells of its own color. That these are *required* is the clearest evidence green should be the default. ### 14.4 Option surface One key, two values. Explicitly do **not** add: custom hex arrays, per-habit colors, hue offsets, intensity/gamma, or "match wallpaper." If a user wants more than two options they want a fork, not a setting. --- ## 15. Multi-year and multi-source Both fit inside 808 × 264 with **no new config keys**. §14's palette verdict stands: a GitHub source's cells use the same fixed green ramp. ### 15.1 The window model — one rule, all views **A view is always 53 full Sun–Sat weeks ending with the week that contains an anchor date `D`.** | View | `D` | |---|---| | `Past year` (default) | today | | Year `Y` | Dec 31 of `Y` | This replaces GitHub's Jan–Dec calendar-year view, deliberately. Reasons: - **Geometry never varies.** A true calendar year needs 53 *or 54* columns (a leap year starting Saturday — 2028 — needs 54, which is 753px of grid and blows the 808 envelope). Anchoring to Dec 31 keeps 53 columns forever: no second layout, no shrinking the weekday lane, no edge case. - **It covers the whole selected year** in every year but two, plus a few spillover days from the adjacent years. Those spillover days are real data; showing them costs nothing. - **One code path.** Rolling and year views differ only by `D`. Month labels, column count, `TODAY_IDX`, hit testing, and the future-cell rule are unchanged. The future-cell rule generalizes for free: **a cell is undrawn iff its date > today.** In a past-year view nothing is in the future, so all 371 cells render. **The one exception, stated so nobody rediscovers it as a bug**: 53 weeks is 371 day-slots, and a leap year beginning on a Saturday spans 372 — so for those years the Dec-31-anchored window starts Jan 2 and **Jan 1 is not shown**. Verified over 2015–2080, this is exactly two years: **2028 and 2056** — the same years that would need a 54th column. One day, twice a century, is the price of never having a second layout; the alternative rule (anchor to Jan 1's week) just drops Dec 31 instead. Accept it, and assert `windowStart <= Jan 1 || year in {leap && starts Saturday}` in a test so it stays a known quantity. Month labels use the §6.3 algorithm unchanged, with one addition: in a year view, suppress a column-0 label whose month is December of `Y−1`. ### 15.2 Year control **A stepper in the header's right block, left of +1.** Not a list, not a row of chips: it costs zero vertical pixels, and a habit tracker has a handful of years, not a decade. ``` [‹] Past year [›] arrows 14px wide, label 72px fixed & centered, total 100 × 20px ``` Label is `Past year` or the 4-digit year. The label width is **fixed at 72px** so stepping never reflows the header. Arrows are `textSecondary`, `textMuted` and inert at the ends of the list. `‹` steps back in time, `›` forward. **The header's right block is a fixed 220px row** — year stepper pinned left, +1 pinned right. This matters for 15.6: controls can disappear without anything else moving. **Years offered**: `Past year`, then ``` { Y : Dec 31 of Y <= today AND ≥1 entry exists in Y (any habit, any source) } ``` newest first. Two consequences, both wanted: the **current calendar year is never offered separately** (its anchor is in the future, and it would be a half-empty duplicate of `Past year`), and **years with no data are skipped** rather than offered as a wall of empty cells. If the set is empty, **the stepper is not rendered at all** — a new user never sees it. The year set is **global** (union across all habits and sources) and the selection **persists across chip switches**. If the selected habit has no data that year you get an empty grid, which is honest, not broken. ### 15.3 Keys | Key | Action | |---|---| | `H` (shift+h) | Previous year | | `L` (shift+l) | Next year | | `0` | Jump back to `Past year` | `H`/`L` pair with `h`/`l` cell motion — same axis, bigger jump, the vim intuition. No collision with `h` `l` `j` `k` `Enter` `x` `[` `]` `1`–`9` `+` `-` `Esc`. `Esc` always closes the panel; it never first exits a year view. ### 15.4 Past-year view: five redundant "this is the past" signals 1. **The +1 button is replaced by a `Today` button** in the same slot, same size, same accent fill. Not greyed out, not removed — swapped for the action that is actually correct here (return to `Past year`). No dead control anywhere. 2. **The header's today line becomes** `2025 · 287 of 365 days` in `textSecondary` — past tense, and more useful than a disabled counter. The goal meter is hidden. 3. **No cell carries the `today` ring** (today isn't in the window). Automatic. 4. **No cell shows a pointer cursor.** Nothing in the grid is clickable. 5. **Stats drop the current streak**: `Best 31d · Total 1,284`. "Current" is meaningless for a closed year. `Best` and `Total` are computed over the visible window, which §5 already specifies — no new rule. `Enter`, `x`, `+`, `-` and clicking a cell **do nothing**. No toast, no beep, no error. The one concession: a **120ms flash** of the `Today` button's fill, which answers "why didn't that work" and points at the way out. Nothing else. ### 15.5 Multi-source chip row **Order**: markdown habits first in config order, then remaining sources in `sources` order. The things you can log sit left, where the eye and the default selection land. **Separator**: a 1px × 16px vertical rule in `border`, 8px clear either side, between source groups. Rendered only when more than one source is active. No per-chip source glyph — it would be noise on the four chips out of five that share a source. **GitHub chip label**: the handle with an `@` prefix — `@blizl`. Not `github`: `@` universally reads as "an account, not something you do", it disambiguates from habit names (lowercase verbs and nouns) at one character's cost, and it survives a second account. Add a source glyph only if a *second* read-only backend ever ships — that is the trigger, not now. **Status dots work unchanged.** A goal-less read-only source takes the §5 p75 reference, so `@blizl` gets a bright dot on a day you pushed. Zero new machinery. ### 15.6 Read-only sources (`canIncrement: false`) | Element | Behavior | |---|---| | +1 button | **Absent.** The 220px right block holds its position, so the year stepper does not move. | | `+ key` hint | Absent (it belongs to the +1 group) | | Today line | **Shown**: `Today 3`. No goal → no meter, identical to the goal-less `read` habit. Not "3 commits" — the chip already says whose, and every other chip reads the same way. | | Today's cell ring | **Kept.** It is still today; that's useful. | | Pointer cursor on today | Absent | | `Enter` `x` `+` `-`, cell click | Nothing, silently. No flash — unlike 15.4 there is no correct alternative action to point at. | | Cell ramp | Green, per §14 — doubly right for a GitHub source. | ### 15.7 Limits - **Max 4 active sources.** Past that the group-divider pattern stops communicating. Treat a longer `sources` array as a config error, not a silent truncation. - **Max 8 chips rendered.** The row is 776px and chips average ~85px. When more are configured, the 8th slot becomes a non-interactive `+N` chip in `textMuted`. Nothing becomes unreachable: `[` / `]` cycle every chip and `1`–`9` still select directly. Still no wrapping and no scrolling (§12).