commit dc01470030d0a6f0a28a75352ed87c4f719d5deb
parent 53fff33034b0417ccad25003d3f5476acb08643a
Author: Damian Myrda <damian@prime8.dev>
Date: Thu, 22 Oct 2026 20:38:39 +0000
web: experience timeline shows start, end, duration
lanes on a shared year axis, rails mark start and end, hover/focus lights a job's lane, rail and card together. tui and vfs use the same range and duration.
Diffstat:
9 files changed, 544 insertions(+), 183 deletions(-)
diff --git a/internal/tui/content.go b/internal/tui/content.go
@@ -197,7 +197,7 @@ func (m portfolioModel) experiencePage() string {
edge = " "
}
body := dimStyle.Render(experienceGraphGutter + edge + " ")
- label := fmt.Sprintf("%-4s %-20s %s%s", utilsYearLabel(e), fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator)
+ label := fmt.Sprintf("%-20s %s%s", fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator)
rangeLabel := dateRangeLabel(e)
if i == m.experienceIndex {
rows = append(rows, selectedRowStyle.Render(gutter+"* "+label))
diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go
@@ -113,16 +113,8 @@ func panelStyleWithoutBottom(style lipgloss.Style) lipgloss.Style {
return style.Border(lipgloss.NormalBorder(), true, true, false, true)
}
-func utilsYearLabel(exp config.Experience) string {
- if exp.Current {
- return "now"
- }
- if exp.EndDate != nil {
- return exp.EndDate.Format("2006")
- }
- return exp.StartDate.Format("2006")
-}
-
+// dateRangeLabel is "oct 2024 – feb 2026 · 1 yr 5 mo".
func dateRangeLabel(exp config.Experience) string {
- return utils.FormatMonthYear(exp.StartDate) + " -> " + utils.FormatEndDate(exp.EndDate, exp.Current)
+ return utils.FormatRange(exp.StartDate, exp.EndDate, exp.Current) + " · " +
+ utils.FormatDurationShort(exp.StartDate, exp.EndDate, exp.Current)
}
diff --git a/internal/utils/period.go b/internal/utils/period.go
@@ -6,45 +6,27 @@ import (
"time"
)
-var monthNames = []string{
- "january",
- "february",
- "march",
- "april",
- "may",
- "june",
- "july",
- "august",
- "september",
- "october",
- "november",
- "december",
-}
-
-func FormatMonthYear(t time.Time) string {
- month := int(t.Month()) - 1
- if month < 0 || month >= len(monthNames) {
- return strings.ToLower(t.Format("Jan 2006"))
- }
-
- return fmt.Sprintf("%s %s", monthNames[month], t.Format("2006"))
-}
+// now is the clock "present" is measured against; tests pin it.
+var now = time.Now
-func FormatEndDate(endDate *time.Time, current bool) string {
- if current || endDate == nil {
- return "present"
+// spanEnd is the last month of a role: its end date, or this month while
+// it is current.
+func spanEnd(endDate *time.Time, current bool) time.Time {
+ if !current && endDate != nil {
+ return *endDate
}
+ return now()
+}
- return FormatMonthYear(*endDate)
+// monthIndex counts months from year 0, so differences are month counts.
+func monthIndex(t time.Time) int {
+ return t.Year()*12 + int(t.Month()) - 1
}
+// MonthsDuration counts the calendar months a role covers, both ends
+// included (feb 2026 -> feb 2026 is 1 month), the way resumes count.
func MonthsDuration(startDate time.Time, endDate *time.Time, current bool) int {
- end := time.Now()
- if !current && endDate != nil {
- end = *endDate
- }
-
- months := (end.Year()-startDate.Year())*12 + int(end.Month()-startDate.Month()) + 1
+ months := monthIndex(spanEnd(endDate, current)) - monthIndex(startDate) + 1
if months < 1 {
return 1
}
@@ -52,46 +34,104 @@ func MonthsDuration(startDate time.Time, endDate *time.Time, current bool) int {
return months
}
-func MonthWord(months int) string {
- if months == 1 {
- return "month"
+// FormatShortMonthYear is "feb 2026".
+func FormatShortMonthYear(t time.Time) string {
+ return strings.ToLower(t.Format("Jan 2006"))
+}
+
+// FormatShortEndDate is "present" for a current role, else "feb 2026".
+func FormatShortEndDate(endDate *time.Time, current bool) string {
+ if current || endDate == nil {
+ return "present"
}
+ return FormatShortMonthYear(*endDate)
+}
- return "months"
+// FormatRange is "oct 2024 – feb 2026" or "feb 2026 – present".
+func FormatRange(startDate time.Time, endDate *time.Time, current bool) string {
+ return FormatShortMonthYear(startDate) + " – " + FormatShortEndDate(endDate, current)
}
-func FormatDuration(startDate time.Time, endDate *time.Time, current bool) string {
+// FormatDurationShort is "1 yr 5 mo", "2 yr" or "9 mo".
+func FormatDurationShort(startDate time.Time, endDate *time.Time, current bool) string {
months := MonthsDuration(startDate, endDate, current)
- years := months / 12
- remainingMonths := months % 12
-
- if years == 0 {
- return fmt.Sprintf("%d %s", months, MonthWord(months))
+ years, rest := months/12, months%12
+ switch {
+ case years == 0:
+ return fmt.Sprintf("%d mo", rest)
+ case rest == 0:
+ return fmt.Sprintf("%d yr", years)
+ default:
+ return fmt.Sprintf("%d yr %d mo", years, rest)
}
+}
- if remainingMonths == 0 {
- return fmt.Sprintf("%d %s", years, yearWord(years))
- }
+// MonthStamp is the machine-readable month for <time datetime>: "2026-02".
+func MonthStamp(t time.Time) string {
+ return t.Format("2006-01")
+}
- return fmt.Sprintf("%d %s %d %s", years, yearWord(years), remainingMonths, MonthWord(remainingMonths))
+// EndStamp is MonthStamp of a role's last month (this month while current).
+func EndStamp(endDate *time.Time, current bool) string {
+ return MonthStamp(spanEnd(endDate, current))
}
-func DurationHasYear(startDate time.Time, endDate *time.Time, current bool) bool {
- return MonthsDuration(startDate, endDate, current) >= 12
+// Span is one role on a timeline.
+type Span struct {
+ Start time.Time
+ End *time.Time
+ Current bool
}
-func yearWord(years int) string {
- if years == 1 {
- return "year"
- }
+// Tick is a year boundary on a timeline axis, At in [0,1].
+type Tick struct {
+ Label string
+ At float64
+}
- return "years"
+// Axis maps months onto [0,1]: from january of the earliest start year to
+// the end of the latest month any role reaches (this month while one is
+// current). A role fills its start month through its end month.
+type Axis struct {
+ first, months int
+ Ticks []Tick
+ // EndLabel marks the right edge: "now" while a role is current, unless
+ // the last year tick sits too close to it to fit both labels.
+ EndLabel string
}
-func TimelineYear(endDate *time.Time, current bool) string {
- if current || endDate == nil {
- return "now"
+func NewAxis(spans []Span) Axis {
+ if len(spans) == 0 {
+ return Axis{months: 1}
+ }
+ first, last := monthIndex(spans[0].Start), monthIndex(spanEnd(spans[0].End, spans[0].Current))
+ current := false
+ for _, s := range spans {
+ first = min(first, monthIndex(s.Start))
+ last = max(last, monthIndex(spanEnd(s.End, s.Current)))
+ current = current || s.Current
}
+ first -= first % 12 // january
+ a := Axis{first: first, months: max(last+1-first, 1)}
+ for m := first; m < first+a.months; m += 12 {
+ a.Ticks = append(a.Ticks, Tick{Label: fmt.Sprint(m / 12), At: a.at(m)})
+ }
+ if current && a.Ticks[len(a.Ticks)-1].At < 0.85 {
+ a.EndLabel = "now"
+ }
+ return a
+}
+
+func (a Axis) at(month int) float64 {
+ return float64(month-a.first) / float64(a.months)
+}
+
+// From is where a role's bar starts.
+func (a Axis) From(startDate time.Time) float64 {
+ return a.at(monthIndex(startDate))
+}
- return endDate.Format("2006")
+// To is where a role's bar ends: the end of its last month.
+func (a Axis) To(endDate *time.Time, current bool) float64 {
+ return a.at(monthIndex(spanEnd(endDate, current)) + 1)
}
diff --git a/internal/utils/period_test.go b/internal/utils/period_test.go
@@ -0,0 +1,78 @@
+package utils
+
+import (
+ "testing"
+ "time"
+)
+
+func month(s string) time.Time {
+ t, err := time.Parse("2006-01", s)
+ if err != nil {
+ panic(err)
+ }
+ return t
+}
+
+func monthPtr(s string) *time.Time {
+ t := month(s)
+ return &t
+}
+
+func pinNow(t *testing.T, s string) {
+ t.Helper()
+ old := now
+ now = func() time.Time { return month(s).AddDate(0, 0, 21) }
+ t.Cleanup(func() { now = old })
+}
+
+func TestRangeAndDuration(t *testing.T) {
+ pinNow(t, "2026-10")
+ for _, c := range []struct {
+ start string
+ end *time.Time
+ current bool
+ rng, dur string
+ }{
+ {"2026-02", nil, true, "feb 2026 – present", "9 mo"},
+ {"2024-10", monthPtr("2026-02"), false, "oct 2024 – feb 2026", "1 yr 5 mo"},
+ {"2024-06", monthPtr("2024-08"), false, "jun 2024 – aug 2024", "3 mo"},
+ {"2024-01", monthPtr("2025-12"), false, "jan 2024 – dec 2025", "2 yr"},
+ {"2026-03", monthPtr("2026-03"), false, "mar 2026 – mar 2026", "1 mo"},
+ } {
+ s := month(c.start)
+ if got := FormatRange(s, c.end, c.current); got != c.rng {
+ t.Errorf("FormatRange(%s) = %q, want %q", c.start, got, c.rng)
+ }
+ if got := FormatDurationShort(s, c.end, c.current); got != c.dur {
+ t.Errorf("FormatDurationShort(%s) = %q, want %q", c.start, got, c.dur)
+ }
+ }
+}
+
+func TestAxis(t *testing.T) {
+ pinNow(t, "2026-10")
+ a := NewAxis([]Span{
+ {Start: month("2026-02"), Current: true},
+ {Start: month("2024-10"), End: monthPtr("2026-02")},
+ {Start: month("2024-06"), End: monthPtr("2024-08")},
+ })
+ // jan 2024 .. oct 2026 inclusive = 34 months
+ if a.months != 34 {
+ t.Fatalf("months = %d, want 34", a.months)
+ }
+ if len(a.Ticks) != 3 || a.Ticks[0].Label != "2024" || a.Ticks[2].Label != "2026" {
+ t.Fatalf("ticks = %+v", a.Ticks)
+ }
+ if got := a.Ticks[1].At; got != 12.0/34 {
+ t.Errorf("2025 tick at %v", got)
+ }
+ if a.EndLabel != "now" {
+ t.Errorf("EndLabel = %q, want now", a.EndLabel)
+ }
+ if got := a.To(nil, true); got != 1 {
+ t.Errorf("current role ends at %v, want 1", got)
+ }
+ if from, to := a.From(month("2024-06")), a.To(monthPtr("2024-08"), false); from != 5.0/34 || to != 8.0/34 {
+ t.Errorf("amplifier spans %v..%v", from, to)
+ }
+}
diff --git a/internal/vfs/vfs.go b/internal/vfs/vfs.go
@@ -215,7 +215,7 @@ func aboutFile(src Source) string {
func experienceFile(e appconfig.Experience) string {
lines := []string{
e.Role + " @ " + e.Company,
- utils.FormatMonthYear(e.StartDate) + " -> " + utils.FormatEndDate(e.EndDate, e.Current),
+ utils.FormatRange(e.StartDate, e.EndDate, e.Current) + " (" + utils.FormatDurationShort(e.StartDate, e.EndDate, e.Current) + ")",
"",
}
lines = append(lines, e.Description...)
diff --git a/internal/web/renderer.go b/internal/web/renderer.go
@@ -90,26 +90,29 @@ func New() (*Renderer, error) {
}
funcs := template.FuncMap{
- "tabPath": utils.TabPath,
- "tabURL": utils.TabURL,
- "tagClass": utils.TagClass,
- "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) },
- "shellPath": ShellPath,
- "windowTitle": func(p string) string { return utils.WindowTitle(ShellPath(p)) },
- "shellUser": func() string { return vfs.FallbackUser },
- "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) },
- "inc": func(i int) int { return i + 1 },
- "techMeta": utils.TechMeta,
- "formatMonthYear": utils.FormatMonthYear,
- "formatEndDate": utils.FormatEndDate,
- "formatDuration": utils.FormatDuration,
- "durationHasYear": utils.DurationHasYear,
- "timelineYear": utils.TimelineYear,
- "toHTML": toHTML,
- "assetVersion": AssetVersion,
- "gitCloneURL": config.GitCloneURL,
- "gitSSHCloneURL": config.GitSSHCloneURL,
- "wakaTimeUser": func() string { return config.WakaTimeUser },
+ "tabPath": utils.TabPath,
+ "tabURL": utils.TabURL,
+ "tagClass": utils.TagClass,
+ "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) },
+ "shellPath": ShellPath,
+ "windowTitle": func(p string) string { return utils.WindowTitle(ShellPath(p)) },
+ "shellUser": func() string { return vfs.FallbackUser },
+ "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) },
+ "inc": func(i int) int { return i + 1 },
+ "techMeta": utils.TechMeta,
+ "shortMonthYear": utils.FormatShortMonthYear,
+ "shortEndDate": utils.FormatShortEndDate,
+ "formatRange": utils.FormatRange,
+ "shortDuration": utils.FormatDurationShort,
+ "monthStamp": utils.MonthStamp,
+ "endStamp": utils.EndStamp,
+ "experienceAxis": experienceAxis,
+ "pct": func(f float64) string { return strconv.FormatFloat(f*100, 'f', 3, 64) + "%" },
+ "toHTML": toHTML,
+ "assetVersion": AssetVersion,
+ "gitCloneURL": config.GitCloneURL,
+ "gitSSHCloneURL": config.GitSSHCloneURL,
+ "wakaTimeUser": func() string { return config.WakaTimeUser },
"inlineCSS": func(name string) template.CSS {
if css, ok := inlineStyles[name]; ok {
return css
@@ -190,3 +193,12 @@ func loadInlineStyles() (map[string]template.CSS, error) {
func ShellPath(path string) string {
return "~" + strings.TrimRight(path, "/")
}
+
+// experienceAxis lays every role on one month axis for the timeline strip.
+func experienceAxis(exps []config.Experience) utils.Axis {
+ spans := make([]utils.Span, len(exps))
+ for i, e := range exps {
+ spans[i] = utils.Span{Start: e.StartDate, End: e.EndDate, Current: e.Current}
+ }
+ return utils.NewAxis(spans)
+}
diff --git a/internal/web/scripts/modules/experience.js b/internal/web/scripts/modules/experience.js
@@ -5,19 +5,41 @@ const experienceHandlers = {
popstate: null,
};
+// One job is lit at a time: its timeline lane, rail and card together.
+// The selected job (url, click/tap, arrow keys) is lit unless a mouse is
+// over, or focus is on, another job, which lights while it stays there.
+// Moving across the gaps between jobs keeps the last one lit, so nothing
+// blinks; leaving the list goes back to the selection.
export function initExperience() {
const section = document.getElementById("experience");
if (!section) return;
- const cards = Array.from(section.querySelectorAll(".exp-card"));
- const timelines = Array.from(section.querySelectorAll(".exp-timeline"));
const rows = Array.from(section.querySelectorAll(".exp-row"));
- if (cards.length === 0) return;
+ const lanes = Array.from(section.querySelectorAll(".exp-lane"));
+ const scroller = section.querySelector(".experience-detail");
+ const chart = section.querySelector(".exp-chart");
+ if (rows.length === 0 || !scroller) return;
let selectedIndex = 0;
+ let previewIndex = -1;
+ let litIndex = -1;
+
+ function paint() {
+ const lit = previewIndex >= 0 ? previewIndex : selectedIndex;
+ if (lit !== litIndex) {
+ litIndex = lit;
+ rows.forEach((row, i) => row.classList.toggle("active", i === lit));
+ lanes.forEach((lane, i) => lane.classList.toggle("active", i === lit));
+ }
+ lanes.forEach((lane, i) =>
+ lane.setAttribute("aria-current", String(i === selectedIndex)),
+ );
+ }
- function experienceIDAt(index) {
- return cards[index]?.dataset.id || rows[index]?.dataset.id || "";
+ function indexOf(target) {
+ const el = target?.closest?.(".exp-row, .exp-lane");
+ if (!el || !section.contains(el)) return -1;
+ return Number(el.dataset.index);
}
function indexFromPath() {
@@ -25,11 +47,11 @@ export function initExperience() {
window.location.pathname.match(/^\/experience\/([^/]+)$/)?.[1] || "",
);
if (!id) return -1;
- return cards.findIndex((card) => card.dataset.id === id);
+ return rows.findIndex((row) => row.dataset.id === id);
}
function updateExperienceURL(index) {
- const id = experienceIDAt(index);
+ const id = rows[index]?.dataset.id;
if (!id) return;
const path = `/experience/${encodeURIComponent(id)}`;
@@ -39,59 +61,71 @@ export function initExperience() {
}
function scrollSelectionIntoView(index) {
- const target = rows[index] || cards[index];
+ const target = rows[index];
if (!target) return;
- const scroller = section.querySelector(".experience-detail");
- if (!scroller || !scroller.contains(target)) {
- target.scrollIntoView({
- behavior: "smooth",
- block: "center",
- inline: "nearest",
- });
- return;
- }
-
+ // the sticky timeline strip covers the top of the list
+ const inset =
+ chart && getComputedStyle(chart).position === "sticky"
+ ? chart.offsetHeight
+ : 0;
const scrollerRect = scroller.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
- const isAbove = targetRect.top < scrollerRect.top + 12;
+ const top = scrollerRect.top + inset;
+ const isAbove = targetRect.top < top + 12;
const isBelow = targetRect.bottom > scrollerRect.bottom - 12;
if (!isAbove && !isBelow) return;
- const targetTop =
- targetRect.top - scrollerRect.top + scroller.scrollTop - 8;
+ const targetTop = targetRect.top - top + scroller.scrollTop - 8;
scroller.scrollTo({ top: Math.max(targetTop, 0), behavior: "smooth" });
}
- function select(index, updateURL = true, scrollIntoView = false) {
+ function select(index, { updateURL = true, scroll = false } = {}) {
+ if (index < 0 || index >= rows.length) return;
selectedIndex = index;
- cards.forEach((card, i) => card.classList.toggle("selected", i === index));
- timelines.forEach((timeline, i) =>
- timeline.classList.toggle("active", i === index),
- );
-
- if (scrollIntoView) {
- scrollSelectionIntoView(index);
- }
+ paint();
+ if (scroll) scrollSelectionIntoView(index);
+ if (updateURL) updateExperienceURL(index);
+ }
- if (updateURL) {
- updateExperienceURL(index);
- }
+ function preview(index) {
+ if (index === previewIndex) return;
+ previewIndex = index;
+ paint();
}
- cards.forEach((card, i) => {
- if (card.dataset.boundHover !== "1") {
- card.dataset.boundHover = "1";
- card.addEventListener("mouseenter", () => select(i));
- }
- });
+ if (section.dataset.boundTimeline !== "1") {
+ section.dataset.boundTimeline = "1";
- timelines.forEach((timeline, i) => {
- if (timeline.dataset.boundHover !== "1") {
- timeline.dataset.boundHover = "1";
- timeline.addEventListener("mouseenter", () => select(i));
- }
- });
+ // mouse/pen only: a tap fires pointerover too, but it should select
+ scroller.addEventListener("pointerover", (e) => {
+ if (e.pointerType === "touch") return;
+ const index = indexOf(e.target);
+ if (index >= 0) preview(index);
+ });
+ scroller.addEventListener("pointerleave", () => preview(-1));
+
+ scroller.addEventListener("focusin", (e) => {
+ const index = indexOf(e.target);
+ if (index >= 0) preview(index);
+ });
+ scroller.addEventListener("focusout", (e) => {
+ if (indexOf(e.relatedTarget) < 0) preview(-1);
+ });
+
+ scroller.addEventListener("click", (e) => {
+ const lane = e.target.closest(".exp-lane");
+ if (lane) {
+ // handled here, so the app-wide link handler leaves it alone
+ e.preventDefault();
+ previewIndex = -1;
+ select(Number(lane.dataset.index), { scroll: true });
+ return;
+ }
+ const index = indexOf(e.target);
+ if (index >= 0 && index !== selectedIndex) select(index);
+ });
+ }
if (experienceHandlers.keydown) {
document.removeEventListener("keydown", experienceHandlers.keydown);
@@ -101,19 +135,16 @@ export function initExperience() {
if (!section.isConnected || window.isLightboxOpen?.()) return;
if (isEditableTarget(e.target)) return;
- switch (e.key) {
- case "ArrowUp":
- e.preventDefault();
- if (selectedIndex > 0) select(selectedIndex - 1, true, true);
- break;
- case "ArrowDown":
- e.preventDefault();
- if (selectedIndex < cards.length - 1)
- select(selectedIndex + 1, true, true);
- break;
- default:
- break;
- }
+ let next = -1;
+ if (e.key === "ArrowUp") next = selectedIndex - 1;
+ else if (e.key === "ArrowDown") next = selectedIndex + 1;
+ else return;
+
+ e.preventDefault();
+ if (next < 0 || next >= rows.length) return;
+ // the keyboard wins over a resting mouse until it moves again
+ previewIndex = -1;
+ select(next, { scroll: true });
};
document.addEventListener("keydown", experienceHandlers.keydown);
@@ -125,9 +156,7 @@ export function initExperience() {
experienceHandlers.popstate = () => {
if (!section.isConnected) return;
const index = indexFromPath();
- if (index >= 0) {
- select(index, false);
- }
+ if (index >= 0) select(index, { updateURL: false });
};
window.addEventListener("popstate", experienceHandlers.popstate);
@@ -148,12 +177,11 @@ export function initExperience() {
});
});
- const selectedCard = section.querySelector(".exp-card.selected") || cards[0];
- const initialIndex = Math.max(cards.indexOf(selectedCard), 0);
const pathIndex = indexFromPath();
if (pathIndex >= 0) {
- select(pathIndex, false);
+ select(pathIndex, { updateURL: false });
} else {
- select(initialIndex);
+ const rendered = rows.findIndex((row) => row.classList.contains("active"));
+ select(Math.max(rendered, 0));
}
}
diff --git a/internal/web/styles/experience.css b/internal/web/styles/experience.css
@@ -35,25 +35,179 @@
min-width: 0;
display: flex;
flex-direction: column;
+ transition: border-color 80ms linear;
}
-.exp-card.selected {
+.exp-row.active .exp-card {
border-color: var(--accent);
}
/*
- * Terminal graph rail:
+ * Timeline strip: one lane per job on a shared month axis.
*
- * @ [now] <- current job (jj's working copy)
+ * hark | | | ======| 9 mo
+ * mandolin | ==|=======|== | 1 yr 5 mo
+ * amplifier | == | | | 3 mo
+ * 2024 2025 2026 now
+ *
+ * A bar fills its start month through its end month. Each lane is a link
+ * to its job and lights up together with the job's card.
+ */
+.exp-chart {
+ --name-w: 18ch;
+ --dur-w: 10ch;
+ position: sticky;
+ top: -4px;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: var(--name-w) minmax(0, 1fr) var(--dur-w);
+ column-gap: 2ch;
+ /* line the lanes up with the card text, past the graph rail; the strip's
+ background still covers the rail as the list scrolls under it */
+ padding: 12px 24px 8px calc(12ch + 24px);
+ margin-top: -4px;
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+ font-size: var(--fs-14);
+ line-height: 1.5;
+ user-select: none;
+}
+
+.chart-grid {
+ grid-column: 2;
+ position: relative;
+ pointer-events: none;
+}
+
+.grid-line {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: var(--at);
+ width: 1px;
+ background: var(--border);
+}
+
+.exp-lane {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: subgrid;
+ align-items: center;
+ color: var(--fg-dim);
+ text-decoration: none;
+ border-radius: 2px;
+ outline-offset: 2px;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.exp-lane:hover {
+ text-decoration: none;
+}
+
+.exp-lane:focus-visible {
+ outline: 1px solid var(--accent);
+}
+
+.lane-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lane-dur {
+ text-align: right;
+ white-space: nowrap;
+}
+
+.lane-track {
+ position: relative;
+ height: 1.5em;
+}
+
+.exp-bar {
+ position: absolute;
+ top: 50%;
+ left: var(--from);
+ width: calc(var(--to) - var(--from));
+ min-width: 4px;
+ height: 6px;
+ transform: translateY(-50%);
+ background: var(--fg-dim);
+ transition: background-color 80ms linear;
+}
+
+/* start and end caps: |=====| */
+.exp-bar::before,
+.exp-bar::after {
+ content: "";
+ position: absolute;
+ top: -4px;
+ bottom: -4px;
+ width: 2px;
+ background: inherit;
+}
+
+.exp-bar::before {
+ left: 0;
+}
+
+.exp-bar::after {
+ right: 0;
+}
+
+/* still running: no end cap, the bar runs into the "now" edge */
+.exp-bar.current::after {
+ display: none;
+}
+
+.exp-lane.active {
+ color: var(--accent);
+}
+
+.exp-lane.active .exp-bar {
+ background: var(--accent);
+}
+
+.chart-axis {
+ grid-column: 2;
+ color: var(--fg-dim);
+ font-size: var(--fs-12);
+}
+
+.axis-labels {
+ position: relative;
+ display: block;
+ height: 1.5em;
+}
+
+.axis-tick {
+ position: absolute;
+ left: var(--at);
+ padding-left: 4px;
+ white-space: nowrap;
+}
+
+.axis-end {
+ position: absolute;
+ right: 0;
+ white-space: nowrap;
+}
+
+/*
+ * Terminal graph rail, one segment per job:
+ *
+ * @ now <- end of the job (jj's working copy for the current one)
* │ <- .graph-rail: 1px line through the node's character cell
- * * [2026] <- past jobs
+ * + feb 2026 <- start of the job
+ * │ <- across the gap to the next (older) job
+ * * feb 2026
*
- * The selected job's node and year go accent, its line accent-dim.
+ * The active job's nodes and dates go accent, its line accent-dim.
*/
.exp-timeline {
--row: 1.5em;
flex-shrink: 0;
- width: 10ch;
+ width: 12ch;
position: relative;
padding-top: 24px;
font-size: var(--fs-14);
@@ -67,22 +221,28 @@
user-select: none;
}
-.graph-head {
+.graph-head,
+.graph-foot {
height: var(--row);
white-space: pre;
}
-.graph-node {
- color: var(--fg-dim);
+.graph-foot {
+ position: absolute;
+ left: 0;
+ bottom: 24px;
}
-.timeline-year {
+.graph-node,
+.graph-date {
color: var(--fg-dim);
+ transition: color 80ms linear;
}
/* hairline through the node column */
.graph-rail,
-.exp-row + .exp-row .exp-timeline::before {
+.exp-row + .exp-row .exp-timeline::before,
+.exp-row:not(:last-child) .exp-timeline::after {
position: absolute;
/* centre of the node's character cell, so the line runs through it */
left: calc(0.5ch - 0.5px);
@@ -90,10 +250,18 @@
background: var(--border);
}
-/* from under this job's node to the bottom of its row */
+/* from under the end node to above the start node */
.graph-rail {
top: calc(24px + var(--row));
+ bottom: calc(24px + var(--row));
+ transition: background-color 80ms linear;
+}
+
+/* from under the start node to the bottom of the row */
+.exp-row:not(:last-child) .exp-timeline::after {
+ content: "";
bottom: 0;
+ height: 24px;
}
/* across the gap between cards, down to the next node */
@@ -103,20 +271,25 @@
height: calc(16px + 24px);
}
-/* oldest job: nothing below it */
-.exp-row:last-child .graph-rail {
- display: none;
-}
-
-.exp-timeline.active .graph-node,
-.exp-timeline.active .timeline-year {
+.exp-row.active .graph-node,
+.exp-row.active .graph-date {
color: var(--accent);
}
-.exp-timeline.active .graph-rail {
+.exp-row.active .graph-rail {
background: var(--accent-dim);
}
+@media (prefers-reduced-motion: reduce) {
+ .exp-card,
+ .exp-bar,
+ .graph-node,
+ .graph-date,
+ .graph-rail {
+ transition: none;
+ }
+}
+
.exp-card-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -282,6 +455,21 @@
display: none;
}
+ .exp-chart {
+ --name-w: 9ch;
+ --dur-w: 9ch;
+ position: static;
+ column-gap: 1ch;
+ margin: 0;
+ padding: 0 0 8px;
+ border-bottom: 0;
+ }
+
+ .lane-track,
+ .exp-lane {
+ min-height: 2em;
+ }
+
.exp-card {
padding: 16px;
}
diff --git a/internal/web/templates/partials/experience.gohtml b/internal/web/templates/partials/experience.gohtml
@@ -2,14 +2,37 @@
<section id="experience" class="page">
<article class="experience-layout">
<div class="experience-detail">
+ {{$axis := experienceAxis .Experience}}
+ <nav class="exp-chart" aria-label="experience timeline">
+ <div class="chart-grid" aria-hidden="true" style="grid-row: 1 / span {{inc (len .Experience)}}">
+ {{range $axis.Ticks}}<span class="grid-line" style="--at: {{pct .At}}"></span>{{end}}
+ </div>
+ {{range $index, $exp := .Experience}}
+ {{$on := or (eq $exp.ID $.SelectedExperienceID) (and (not $.SelectedExperienceID) (eq $index 0))}}
+ <a class="exp-lane{{if $on}} active{{end}}" style="grid-row: {{inc $index}}" href="/experience/{{$exp.ID}}" data-index="{{$index}}" data-id="{{$exp.ID}}" aria-controls="exp-{{$exp.ID}}" aria-current="{{if $on}}true{{else}}false{{end}}" aria-label="{{$exp.Company}}, {{$exp.Role}}: {{formatRange $exp.StartDate $exp.EndDate $exp.Current}}, {{shortDuration $exp.StartDate $exp.EndDate $exp.Current}}" title="{{formatRange $exp.StartDate $exp.EndDate $exp.Current}}">
+ <span class="lane-name">{{$exp.Company}}</span>
+ <span class="lane-track"><span class="exp-bar{{if $exp.Current}} current{{end}}" style="--from: {{pct ($axis.From $exp.StartDate)}}; --to: {{pct ($axis.To $exp.EndDate $exp.Current)}}"></span></span>
+ <span class="lane-dur">{{shortDuration $exp.StartDate $exp.EndDate $exp.Current}}</span>
+ </a>
+ {{end}}
+ <div class="chart-axis" aria-hidden="true" style="grid-row: {{inc (len .Experience)}}">
+ <span class="axis-labels">
+ {{range $axis.Ticks}}<span class="axis-tick" style="--at: {{pct .At}}">{{.Label}}</span>{{end}}
+ {{if $axis.EndLabel}}<span class="axis-end">{{$axis.EndLabel}}</span>{{end}}
+ </span>
+ </div>
+ </nav>
+
{{range $index, $exp := .Experience}}
- <div class="exp-row" data-index="{{$index}}" data-id="{{$exp.ID}}">
- <div class="exp-timeline{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} active{{end}}{{else}}{{if eq $index 0}} active{{end}}{{end}}" data-index="{{$index}}">
- <div class="graph-head"><span class="graph-node" aria-hidden="true">{{if $exp.Current}}@{{else}}*{{end}}</span> <span class="timeline-year">[{{timelineYear $exp.EndDate $exp.Current}}]</span></div>
- <span class="graph-rail" aria-hidden="true"></span>
+ {{$on := or (eq $exp.ID $.SelectedExperienceID) (and (not $.SelectedExperienceID) (eq $index 0))}}
+ <div class="exp-row{{if $on}} active{{end}}" data-index="{{$index}}" data-id="{{$exp.ID}}">
+ <div class="exp-timeline" aria-hidden="true">
+ <div class="graph-head"><span class="graph-node">{{if $exp.Current}}@{{else}}*{{end}}</span> <span class="graph-date">{{if $exp.Current}}now{{else}}{{shortEndDate $exp.EndDate $exp.Current}}{{end}}</span></div>
+ <span class="graph-rail"></span>
+ <div class="graph-foot"><span class="graph-node">+</span> <span class="graph-date">{{shortMonthYear $exp.StartDate}}</span></div>
</div>
- <div class="exp-card{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} selected{{end}}{{else}}{{if eq $index 0}} selected{{end}}{{end}}" data-index="{{$index}}" data-id="{{$exp.ID}}">
+ <div class="exp-card" id="exp-{{$exp.ID}}" data-index="{{$index}}" data-id="{{$exp.ID}}">
<div class="exp-card-header">
<div class="exp-card-title">
{{if $exp.Logo}}
@@ -25,8 +48,8 @@
{{end}}
</div>
<div class="exp-card-meta">
- <span class="exp-dates">{{formatMonthYear $exp.StartDate}} -> {{formatEndDate $exp.EndDate $exp.Current}}</span>
- <span class="exp-duration">{{formatDuration $exp.StartDate $exp.EndDate $exp.Current}}</span>
+ <span class="exp-dates"><time datetime="{{monthStamp $exp.StartDate}}">{{shortMonthYear $exp.StartDate}}</time> – {{if $exp.Current}}<span class="exp-present">present</span>{{else}}<time datetime="{{endStamp $exp.EndDate $exp.Current}}">{{shortEndDate $exp.EndDate $exp.Current}}</time>{{end}}</span>
+ <span class="exp-duration">{{shortDuration $exp.StartDate $exp.EndDate $exp.Current}}</span>
{{if $exp.URL}}<a class="external-link" href="{{$exp.URL}}" target="_blank" rel="noopener noreferrer">{{$exp.URL}} -></a>{{end}}
</div>
</div>