| phase |
slug |
status |
shadcn_initialized |
preset |
created |
| 4 |
heatmap-interaction |
draft |
false |
none |
2026-04-08 |
Phase 4 — UI Design Contract
Visual and interaction contract for Heatmap Interaction phase. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
Design System
| Property |
Value |
| Tool |
none (Kimai plugin — not a standalone frontend app) |
| Preset |
not applicable |
| Component library |
Tabler UI (via Kimai's tabler-bundle) |
| Icon library |
Tabler Icons (available via Kimai host) |
| Font |
var(--tblr-font-sans-serif) — inherited from Kimai/Tabler theme |
Spacing Scale
Declared values (must be multiples of 4):
| Token |
Value |
Usage |
| xs |
4px |
Cell gap between heatmap rects |
| sm |
8px |
Padding within filter dropdown area |
| md |
16px |
Gap between heatmap SVG and filter dropdown |
| lg |
24px |
Card body padding (inherited from Tabler card) |
Exceptions: Heatmap cell size is dynamic (computed to fill width, capped at 18px) — not a spacing token. Cell gap is 2px (established in Phase 3, maintained for continuity).
Typography
| Role |
Size |
Weight |
Line Height |
| Body |
13px (0.8125rem) |
400 |
1.5 |
| Label |
10px |
400 |
1.2 |
| Dropdown |
13px (0.8125rem) |
400 |
1.5 |
Source: Phase 3 established 13px for tooltip text and 10px for SVG labels. This phase inherits those values. Dropdown text uses Tabler's form-select-sm which renders at 0.8125rem.
Color
| Role |
Value |
Usage |
| Dominant (60%) |
var(--tblr-bg-surface) |
Card background, tooltip background |
| Secondary (30%) |
var(--tblr-bg-surface-secondary) |
Empty heatmap cells, dropdown background |
| Accent (10%) |
GitHub green scale: #9be9a8, #40c463, #30a14e, #216e39 |
Heatmap cells with data only |
| Hover highlight |
rgba(255,255,255,0.15) (dark) / rgba(0,0,0,0.08) (light) |
Cell hover state for click affordance |
| Border |
var(--tblr-border-color) |
Tooltip border, dropdown border |
Accent reserved for: heatmap data cells only. The hover highlight is an overlay opacity shift on all cells (both data and empty) to signal clickability.
No destructive color needed in this phase.
Interaction Contract
Cell Click Navigation
| Property |
Value |
| Cursor |
pointer on all .heatmap-cell rects |
| Hover effect |
Opacity shift: cell gets opacity: 0.75 on hover (from default 1.0 for data cells, visually shifts empty cells too) |
| Click target |
Full cell rect area (cellSize x cellSize) |
| Navigation URL |
/en/timesheet/?daterange={YYYY-MM-DD} |
| With project filter |
/en/timesheet/?daterange={YYYY-MM-DD}&projects[]={projectId} |
| Empty cell behavior |
Clickable — navigates to timesheet for that date (user may want to add time) |
| Navigation method |
window.location.href assignment (full page navigation, not SPA) |
Filter Dropdown
| Property |
Value |
| Position |
Right side of heatmap SVG, using spare horizontal space in the card |
| Layout |
Flexbox row: [heatmap SVG flex:1] [filter area width:auto] |
| Element |
<select> with Tabler class form-select form-select-sm |
| Default option |
"All Projects" (value: empty string, no filter applied) |
| Option format |
Project name as text, project ID as value |
| Width |
auto with min-width: 140px, max-width: 200px |
| Vertical alignment |
Top-aligned with the heatmap SVG |
| Label |
No visible label — the "All Projects" default option serves as the label. Add aria-label="Filter by project" for accessibility. |
Filter Behavior
| Property |
Value |
| Trigger |
change event on the <select> element |
| API call |
GET {data-url}?project={selectedValue} (omit param when "All Projects") |
| Loading state |
No spinner — the re-render is fast enough on local data. If fetch takes >0ms, existing heatmap stays visible until new data arrives. |
| Re-render |
Call renderHeatmap(container, newData) — this clears and redraws (established pattern) |
| Color scale |
Recalculates on filtered data (max hours changes per project) |
| Error handling |
On fetch failure, keep current heatmap visible. Log error to console. |
| URL state |
Filter selection is NOT persisted in browser URL (widget is embedded in dashboard) |
Project List Population
| Property |
Value |
| Source |
New data attribute data-projects on the container div, JSON-encoded array of {id, name} |
| Populated by |
Twig template, from a controller/service that queries the user's projects |
| Format |
[{"id": 1, "name": "Project A"}, {"id": 2, "name": "Project B"}] |
| Sort order |
Alphabetical by name |
Copywriting Contract
| Element |
Copy |
| Filter default option |
"All Projects" |
| Filter aria-label |
"Filter by project" |
| Empty state (no data) |
"No tracking data available" (established in Phase 3, unchanged) |
| Empty state (filtered, no data) |
"No tracking data for this project" |
| Error state (fetch failure) |
"Failed to load heatmap data" (established in Phase 3, unchanged) |
| Tooltip format |
{Day, Mon D, YYYY} + newline + {N.N}h ({N} entries) (established in Phase 3) |
No destructive actions in this phase.
CSS Additions
New rules to add to Resources/public/heatmap.css:
/* Click affordance */
.heatmap-cell {
cursor: pointer;
transition: opacity 0.1s ease;
}
.heatmap-cell:hover {
opacity: 0.75;
}
/* Layout: heatmap + filter side by side */
.heatmap-wrapper {
display: flex;
align-items: flex-start;
gap: 16px;
}
.heatmap-wrapper .heatmap-svg-area {
flex: 1;
min-width: 0;
overflow-x: auto;
}
.heatmap-wrapper .heatmap-filter {
flex-shrink: 0;
padding-top: 20px; /* align with heatmap grid, below month labels */
}
.heatmap-filter select {
min-width: 140px;
max-width: 200px;
}
Registry Safety
| Registry |
Blocks Used |
Safety Gate |
| Not applicable |
N/A |
N/A |
This project uses no component registry. All UI is hand-built SVG (d3.js) and native HTML with Tabler CSS classes.
Checker Sign-Off
Approval: pending