clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Times in a column are compact and line up; times in a sentence say ago

cat committed a3445fc
commit
a3445fcbe17d2f9bce5a917b1a348b144c80d485
parent
f5a25f8a943708dc2fb436e252f2bb31c2625d16
author
cat <admin@dev.site>
ci
✓ passed in 124s, signed by cat
kind
feat risk score 2.0 of 3, Jev's estimate
review
#24 Compact times that line up in lists
ci attestation
forge-attestation 1
type ci
commit a3445fcbe17d2f9bce5a917b1a348b144c80d485
tree 9dec192d9b8836d35e37a865f9451b2ebf61f2d4
script .forge/ci 91d640bd19a231e82d21cc0a10d907253d9b0f41
result pass
exit 0
started 2026-10-19T05:12:01Z
duration 124s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:9f035db44204afaf1ef3d1961074956ea5130cb659df4fb77ba993086cc7de6f 285 lines
|pushed wip/i18n
|83065fa running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|83065fa pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|6c62716 running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|6c62716 fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|aba21ca running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|aba21ca pass in 0s, signed
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|warning: You appear to have cloned an empty repository.
|site home: deployed 271e589 -> /sites/ana/dotfiles/home/
|created ana/notes
|remote: review #1: /ana/notes/reviews/1/
|pushed ana/todo
|landing #1 A todo list: ana/todo -> main
|4f40a12 running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|4f40a12 pass in 0s, signed
|pushed main
|b0a2373 running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|b0a2373 pass in 0s, signed
|remote: review #7: /dami/greet/reviews/7/
|pushed dami/regions
|created dami/forge
|warning: You appear to have cloned an empty repository.
|site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/
|site guide: deployed 7290c0c -> /sites/dami/forge/guide/
|(node:42001) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.q1dB3X/wt/demo/root/.forge/www
|(node:42172) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/notes in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.q1dB3X/wt/demo/root/.forge/www
|(node:42354) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/forge in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.q1dB3X/wt/demo/root/.forge/www
|(node:42528) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/greet in 3s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.q1dB3X/wt/demo/root/.forge/www
|(node:43267) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.q1dB3X/wt/demo/root/.forge/www
|235 of 235 pages render the same without 11ty
|guide: 8 files, links ok
|ok

Signature: good, cat

16 files changed, +185 -45
M.forge/ci6+++-
MDESIGN.md22++++++++++++++++
Mlib/forge.ts7+----
Mlib/render.ts29+++++++++++++++++----
Msite/_includes/macros.njk20+++++++++-----
Msite/_includes/private.njk2+-
Msite/assets/style.css11++++++++
Msite/assets/ui.ts31++++++++++++++--------
Msite/design.njk2+-
Msite/index.njk11+++++---
Msite/repo/commit.njk2+-
Msite/repo/review.njk4+-
Mtest/scenarios/home-profiles.sh4+-
Mtest/scenarios/home-recent.sh8+++---
Atest/scenarios/times-columns.sh29+++++++++++++++++++++
Atest/scenarios/times-relative.sh42++++++++++++++++++++++++++++++
M .forge/ci +4 -2
M DESIGN.md +22 -0
@@ -98,6 +98,28 @@ Self-hosted variable woff2 in `site/assets/fonts/` (how they were cut:
9898 - Inline code never breaks at a hyphen (`lib/markdown.ts` keeps a9999 hyphenated word whole and lets a path break after its slashes).100100 101+## Times102+103+A time in its own column is compact; a time in a sentence reads as a104+sentence. Every time is a `<time>` with the moment, in UTC, in its105+`datetime` and `title`; `site/assets/ui.ts` says it relative to now, every106+minute, with the reader's full local date and time on hover.107+108+- In a column, a head strip or a list's facts (the recent feed, shelves,109+ a repo's head strip, commits, reviews, revisions, branches, tags,110+ releases, sites) it is `| date`: "now", "5m", "40m", "5h", "1d", "3w",111+ then "Sep 24", or "2025-03-01" from an earlier year.112+- In a sentence (a byline, "opened by cat, updated 5m ago", "active 2h113+ ago", a comment's header) it is `| ago`: "just now", "5m ago", then114+ "Sep 24, 2025". Its `<time>` has `data-ago`.115+- A time ending a row is at least `--col-time` wide (the widest compact116+ one, "Sep 24"), right-aligned, in tabular figures, unwrapped; and a117+ list's rows share their columns (a table, or a grid whose rows are118+ subgrids), so every row's text cuts at the same edge. A kind column is a119+ fixed width, a count column tabular and right-aligned, the same way.120+- The page has the day before the script runs, no wider than what replaces121+ it, so nothing moves on load.122+101123 ## Controls102124 103125 Words for where you are, symbols for how you look at it.
M lib/forge.ts +2 -5
M lib/render.ts +24 -5
@@ -13,15 +13,34 @@ import { highlight, highlightLines, language, splitLines } from './highlight.ts'
1313 const ENTITIES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };1414 export const esc = (s: unknown): string => String(s ?? '').replace(/[&<>"']/g, (c) => ENTITIES[c]);1515 16-// A timestamp as <time>, absolute UTC in the page. Static pages go stale, so17-// site/assets/ui.ts rewrites it as relative time in the reader's zone.18-export function time(t: Date): string {16+// A timestamp as <time>, absolute UTC in its datetime and title. Static17+// pages go stale, so site/assets/ui.ts says it relative to now ("5m"), in18+// the reader's zone with their full local time on hover. A time in its own19+// column is compact; one in a sentence (`sentence`, marked data-ago) reads20+// as one ("5m ago"). The page itself has the day, as short as what ui.ts21+// puts there ("Sep 24", "2025-03-01" from an earlier year; in a sentence22+// "Sep 24, 2025"), so a column doesn't change width when the script runs.23+// FORGE_NOW pins the year, as it does the clock for the demo and tests.24+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];25+export function time(t: Date, sentence = false): string {1926 if (isNaN(t.getTime())) return '';2027 const iso = t.toISOString().replace(/\.\d+Z$/, 'Z');21- return `<time datetime="${iso}">${iso.slice(0, 16).replace('T', ' ')} UTC</time>`;28+ const year = new Date(Number(process.env.FORGE_NOW) * 1000 || Date.now()).getUTCFullYear();29+ const day = `${MONTHS[t.getUTCMonth()]} ${t.getUTCDate()}`;30+ const text = t.getUTCFullYear() === year ? day : sentence ? `${day}, ${t.getUTCFullYear()}` : iso.slice(0, 10);31+ return `<time datetime="${iso}" title="${iso.slice(0, 16).replace('T', ' ')} UTC"${sentence ? ' data-ago' : ''}>${text}</time>`;2232 }2333 24-export const date = (iso: string | null | undefined): string => (iso ? time(new Date(iso)) : '');34+// Unix seconds (review logs) or an ISO date: in a column, and in a sentence.35+const at = (ts: number | string | null | undefined): Date | null => (ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null);36+export const date = (ts: number | string | null | undefined): string => {37+ const t = at(ts);38+ return t ? time(t) : '';39+};40+export const ago = (ts: number | string | null | undefined): string => {41+ const t = at(ts);42+ return t ? time(t, true) : '';43+};2544 2645 // A page renders at most this many diff lines; the rest of the files are listed.2746 const MAX_PAGE_LINES = 6000;
M site/_includes/macros.njk +13 -7
@@ -42,7 +42,7 @@
4242 {% macro log(commits, repo) %}4343 {% set jev = commits | selectattr('jev') | length %}4444 <table class="list stack nav log">45-<thead><tr>{% if jev %}<th class="kind">type</th>{% endif %}<th>commit</th><th>author</th><th>date</th><th class="num">+</th><th class="num">-</th>{% if jev %}<th class="num" title="Jev's estimate of how likely a change is to break something, 0 to 3">risk</th>{% endif %}<th>ci</th></tr></thead>45+<thead><tr>{% if jev %}<th class="kind">type</th>{% endif %}<th>commit</th><th>author</th><th class="date">date</th><th class="num">+</th><th class="num">-</th>{% if jev %}<th class="num" title="Jev's estimate of how likely a change is to break something, 0 to 3">risk</th>{% endif %}<th>ci</th></tr></thead>4646 <tbody>4747 {% for c in commits %}4848 <tr>{% if jev %}<td class="kind">{{ jevKind(c) }}</td>{% endif %}<td class="main"><a href="{{ repo.urls.commit(c.sha).href }}">{{ c.subject }}</a>{{ jevMismatch(c) }}</td><td class="nowrap">{{ c.author.name }}</td><td class="date">{{ c.author.date | date }}</td><td class="num i">+{{ c.stats.add }}</td><td class="num d">-{{ c.stats.del }}</td>{% if jev %}<td class="num risk">{{ jevRisk(c) }}</td>{% endif %}<td>{{ c.ci | ciHtml | safe }}</td></tr>
@@ -115,7 +115,7 @@
115115 {{ status(r.status) }}116116 <span class="rv-title"><a href="{{ repo.urls.review(r.id).href }}">{{ r.title }}</a></span>117117 <span class="rv-side">{{ reviewci(r.ci) }}{% if r.approvals.length %}<span>{{ approvals(r) }}</span>{% endif %}</span>118-<span class="rv-meta"><span class="num">#{{ r.id }}</span><span class="mono">{{ r.branch }} &rarr; {{ r.target }}</span>{% if r.author %}<span>{{ r.author }}</span>{% endif %}{% if r.commits.length %}<span>{{ r.commits.length | plural('commit') }}</span>{% endif %}{% if r.combinedStats and (r.combinedStats.add or r.combinedStats.del) %}<span class="num"><span class="add">+{{ r.combinedStats.add }}</span> <span class="del">&minus;{{ r.combinedStats.del }}</span></span>{% endif %}<span class="date">{{ r.updated | ago }}</span>{% if last and last.view %}<a href="{{ repo.urls.revision(r.id, last.n).href }}" title="what changed since r{{ last.prev }}">r{{ last.n }}</a>{% endif %}</span>118+<span class="rv-meta"><span class="num">#{{ r.id }}</span><span class="mono">{{ r.branch }} &rarr; {{ r.target }}</span>{% if r.author %}<span>{{ r.author }}</span>{% endif %}{% if r.commits.length %}<span>{{ r.commits.length | plural('commit') }}</span>{% endif %}{% if r.combinedStats and (r.combinedStats.add or r.combinedStats.del) %}<span class="num"><span class="add">+{{ r.combinedStats.add }}</span> <span class="del">&minus;{{ r.combinedStats.del }}</span></span>{% endif %}<span class="date">{{ r.updated | date }}</span>{% if last and last.view %}<a href="{{ repo.urls.revision(r.id, last.n).href }}" title="what changed since r{{ last.prev }}">r{{ last.n }}</a>{% endif %}</span>119119 </li>120120 {% endmacro %}121121
@@ -154,8 +154,11 @@
154154 .shelf-tools[hidden] { display: none; }155155 .shelf { scroll-margin-top: var(--space-3); }156156 /* Repos as rows in their shelf's panel: name, what it is, then its sites,157- reviews and last push. */158-.card { position: relative; display: grid; grid-template-columns: minmax(8em, 15em) 1fr auto; column-gap: var(--space-4); align-items: baseline; min-width: 0; padding: var(--space-2) var(--space-3); }157+ reviews and last push. The rows share the panel's columns (subgrid), so158+ names, descriptions and times line up; the time ends each row, as wide159+ as "Sep 24" (--col-time). */160+.cards { display: grid; grid-template-columns: minmax(8em, 15em) minmax(0, 1fr) auto; column-gap: var(--space-4); }161+.card { position: relative; grid-column: 1 / -1; display: grid; grid-template-columns: minmax(8em, 15em) minmax(0, 1fr) auto; grid-template-columns: subgrid; column-gap: var(--space-4); align-items: baseline; min-width: 0; padding: var(--space-2) var(--space-3); }159162 .card:hover, .card:focus-within, .card.sel { background: var(--hover); }160163 .card.sel { box-shadow: inset 2px 0 0 var(--accent); }161164 .card-top { min-width: 0; }
@@ -164,14 +167,17 @@
164167 .card-name::after { content: ''; position: absolute; inset: 0; }165168 .card-name .desc { font-weight: var(--fw-regular); }166169 .card-desc { grid-column: 2; margin: 0; color: var(--muted); font-size: var(--text-sm); overflow-wrap: anywhere; }167-.card-foot { grid-column: 3; display: flex; align-items: baseline; gap: var(--space-4); font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); white-space: nowrap; }170+.card-foot { grid-column: 3; display: flex; align-items: baseline; justify-content: flex-end; gap: var(--space-4); font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); white-space: nowrap; }168171 .card-foot a { position: relative; z-index: 1; }169172 .card-foot .site { display: inline-flex; align-items: center; gap: var(--space-1); align-self: center; }170173 .card-foot .grow { display: none; }171-.card-foot time { font-size: inherit; }174+.card-foot time { min-width: var(--col-time); font-size: inherit; text-align: right; font-variant-numeric: tabular-nums; }172175 @media (max-width: 720px) {173- .card { grid-template-columns: 1fr; row-gap: var(--space-1); }176+ .cards { grid-template-columns: minmax(0, 1fr); }177+ .card { grid-template-columns: minmax(0, 1fr); grid-template-columns: subgrid; row-gap: var(--space-1); }174178 .card-desc, .card-foot { grid-column: 1; }179+ .card-foot { justify-content: flex-start; }180+ .card-foot time { min-width: 0; text-align: left; }175181 }176182 177183 {% endcss %}
M site/_includes/private.njk +1 -1
M site/assets/style.css +11 -0
@@ -116,6 +116,9 @@
116116 --bar: 3px;117117 --radius: 0;118118 --control-h: 30px;119+ /* columns: a time in its own column is as wide as the widest compact120+ one, "Sep 24" or "40m", in the mono */121+ --col-time: 6ch;119122 color-scheme: light dark;120123 }121124 @media (prefers-color-scheme: dark) {
@@ -328,6 +331,12 @@ table.list .num { text-align: right; white-space: nowrap; font-variant-numeric:
328331 table.list td.num, table.list td.date { font: var(--fw-regular) var(--text-xs)/1.6 var(--mono); }329332 table.list td.date { color: var(--faint); }330333 table.list td.date time { font-size: inherit; }334+/* A time in its own column (DESIGN.md, Times) is compact, and as wide as335+ "Sep 24" whatever it says, right-aligned in figures of one width: every336+ row's text ends at the same edge, and nothing moves when ui.ts rewrites337+ it. A time in a sentence (data-ago) is a word in it. */338+table.list th.date { text-align: right; }339+:is(table.list td.date, .panel-head) time:not([data-ago]) { display: inline-block; min-width: var(--col-time); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }331340 table.list .nowrap, table.list .date, table.list .sha, table.list td.mono { white-space: nowrap; }332341 table.list td.mono { font-size: var(--text-sm); }333342 table.list td.main { width: 100%; overflow-wrap: anywhere; }
@@ -353,6 +362,8 @@ table.files td.size { color: var(--muted); }
353362 table.list.stack td { order: 2; }354363 table.list.stack td.main { order: 0; }355364 table.list.stack td.id { order: 1; }365+ /* Stacked, the date is one of the facts under the subject, not a column. */366+ table.list.stack td.date time { min-width: 0; }356367 .hide-sm { display: none; }357368 }358369
M site/assets/ui.ts +20 -11
@@ -41,25 +41,34 @@
4141 return v ? v === '1' : matchMedia('(max-width: 720px)').matches;4242 };4343 44- // <time> carries absolute UTC; say it relative to now ("3h ago", then45- // "Sep 1"), with the full local time on hover. Static pages go stale, so46- // this runs in the browser, and again every minute.44+ // <time> carries absolute UTC (lib/render.ts); say it relative to now, with45+ // the full local time on hover. Static pages go stale, so this runs in the46+ // browser, and again every minute. A time in its own column is compact47+ // ("now", "5m", "5h", "1d", "3w", then "Sep 24", or "2025-03-01" from an48+ // earlier year); one in a sentence (data-ago) reads as one ("just now",49+ // "5m ago", then "Sep 24, 2025").4750 const full = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });4851 const day = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });4952 const dayYear = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric' });50- const ago = (t: Date): string => {51- const s = (Date.now() - t.getTime()) / 1000;52- if (s > -300 && s < 60) return 'just now';53- if (s > 0 && s < 3600) return `${Math.floor(s / 60)}m ago`;54- if (s > 0 && s < 86400) return `${Math.floor(s / 3600)}h ago`;55- if (s > 0 && s < 7 * 86400) return `${Math.floor(s / 86400)}d ago`;56- return (t.getFullYear() === new Date().getFullYear() ? day : dayYear).format(t);53+ const two = (n: number): string => String(n).padStart(2, '0');54+ const ago = (t: Date, sentence: boolean): string => {55+ const now = Date.now();56+ const s = (now - t.getTime()) / 1000;57+ if (s > -300 && s < 60) return sentence ? 'just now' : 'now';58+ let n = '';59+ if (s > 0 && s < 3600) n = `${Math.floor(s / 60)}m`;60+ else if (s > 0 && s < 86400) n = `${Math.floor(s / 3600)}h`;61+ else if (s > 0 && s < 7 * 86400) n = `${Math.floor(s / 86400)}d`;62+ else if (s > 0 && s < 28 * 86400) n = `${Math.floor(s / (7 * 86400))}w`;63+ if (n) return sentence ? `${n} ago` : n;64+ if (t.getFullYear() === new Date(now).getFullYear()) return day.format(t);65+ return sentence ? dayYear.format(t) : `${t.getFullYear()}-${two(t.getMonth() + 1)}-${two(t.getDate())}`;5766 };5867 const times = (): void => {5968 for (const el of document.querySelectorAll<HTMLTimeElement>('time[datetime]')) {6069 const t = new Date(el.dateTime);6170 if (isNaN(t.getTime())) continue;62- el.textContent = ago(t);71+ el.textContent = ago(t, el.hasAttribute('data-ago'));6372 el.title = full.format(t);6473 }6574 };
M site/design.njk +1 -1
M site/index.njk +7 -4
@@ -51,8 +51,11 @@ main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space
5151 @media (max-width: 720px) {5252 .topic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }5353 }54-/* What happened: its kind, the repo and what, when. */55-.ev { position: relative; display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; column-gap: var(--space-3); align-items: baseline; padding: var(--space-2) var(--space-3); }54+/* What happened: its kind, the repo and what, when. The rows share the55+ list's columns (subgrid), so every title ends at the same edge; the56+ kind's is as wide as "release", the time's as "Sep 24" (--col-time). */57+.recent .rows { display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; column-gap: var(--space-3); }58+.ev { position: relative; grid-column: 1 / -1; display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; grid-template-columns: subgrid; column-gap: var(--space-3); align-items: baseline; padding: var(--space-2) var(--space-3); }5659 .ev:hover, .ev:focus-within { background: var(--hover); }5760 .ev-kind { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }5861 .ev-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }
@@ -60,7 +63,7 @@ main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space
6063 .ev-what::after { content: ''; position: absolute; inset: 0; }6164 .ev-what b { font-weight: var(--fw-strong); }6265 .ev-what .tag { font-family: var(--mono); font-size: var(--text-inline-mono); color: var(--link); }63-.ev time { font-size: var(--text-xs); color: var(--faint); white-space: nowrap; }66+.ev time { min-width: var(--col-time); font-size: var(--text-xs); color: var(--faint); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }6467 {% endcss %}6568 {% if forge.motd %}<p class="motd">{{ forge.motd }}</p>6669 {% endif %}
@@ -70,7 +73,7 @@ main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space
7073 {% if forge.profiles.length %}7174 <ul class="rows">7275 {% for pr in forge.profiles %}73-<li class="profile"><span class="avatar {{ pr.colour }}" aria-hidden="true">{{ pr.owner | first }}</span><span class="profile-text"><a class="profile-name" href="/{{ pr.owner }}/">{{ pr.owner }}</a><span class="profile-meta">{{ pr.repos | plural("repo") }}{% if pr.active %}, active {{ pr.active | date }}{% endif %}</span></span></li>76+<li class="profile"><span class="avatar {{ pr.colour }}" aria-hidden="true">{{ pr.owner | first }}</span><span class="profile-text"><a class="profile-name" href="/{{ pr.owner }}/">{{ pr.owner }}</a><span class="profile-meta">{{ pr.repos | plural("repo") }}{% if pr.active %}, active {{ pr.active | ago }}{% endif %}</span></span></li>7477 {% endfor %}7578 </ul>7679 {% else %}<p class="empty">No public repos here yet.</p>
M site/repo/commit.njk +1 -1
M site/repo/review.njk +2 -2
@@ -83,7 +83,7 @@ pagination:
8383 <table class="list stack">8484 <tbody>8585 {% for v in rv.revisions | reverse %}86-<tr><td class="num">r{{ v.n }}</td><td class="date">{{ v.ts | ago }}</td><td>{{ v.user or '' }}</td><td class="sha">{{ v.sha | commitLink(p.repo) | safe }}</td><td class="main">{% if v.view %}<a href="{{ u.revision(rv.id, v.n).href }}">what changed since r{{ v.prev }}</a>{% endif %}{% if v.summary %} <span class="jev-sum {{ v.summary.kind }}" title="What this revision changed, according to Jev">{{ v.summary.text }}</span>{% endif %}</td></tr>86+<tr><td class="num">r{{ v.n }}</td><td class="date">{{ v.ts | date }}</td><td>{{ v.user or '' }}</td><td class="sha">{{ v.sha | commitLink(p.repo) | safe }}</td><td class="main">{% if v.view %}<a href="{{ u.revision(rv.id, v.n).href }}">what changed since r{{ v.prev }}</a>{% endif %}{% if v.summary %} <span class="jev-sum {{ v.summary.kind }}" title="What this revision changed, according to Jev">{{ v.summary.text }}</span>{% endif %}</td></tr>8787 {% endfor %}8888 </tbody>8989 </table>
@@ -130,7 +130,7 @@ ssh {{ forge.config.ssh }} land {{ p.repo.slug }} {{ rv.id }} # the forge land
130130 {% for c in rv.layer %}131131 <section class="stacked jump" id="c-{{ c.sha }}">132132 <h3><span class="step">{{ loop.index }}/{{ rv.layer.length }}</span> <a href="{{ u.commit(c.sha).href }}">{{ c.subject }}</a></h3>133-<p class="byline desc">{{ c.author.name }}, {{ c.author.date | date }}, <span class="sha">{{ c.short }}</span>{% if c.ci %}. CI {{ c.ci | ciHtml(true) | safe }}{% endif %}</p>133+<p class="byline desc">{{ c.author.name }}, {{ c.author.date | ago }}, <span class="sha">{{ c.short }}</span>{% if c.ci %}. CI {{ c.ci | ciHtml(true) | safe }}{% endif %}</p>134134 {% if c.body %}<pre class="panel msg">{{ c.body }}</pre>{% endif %}135135 {{ c.files | diffstatHtml('c' ~ loop.index ~ '-') | safe }}136136 {{ c.files | diffHtml(p.repo, 'c' ~ loop.index ~ '-', rv.branch if rv.live else null, null, true) | safe }}
M test/scenarios/home-profiles.sh +2 -2
M test/scenarios/home-recent.sh +4 -4
@@ -40,10 +40,10 @@ build
4040 i=$FORGE_WWW/index.html4141 has "$i" '<section class="panel recent"><header class="panel-head"><h2>recent</h2><span class="end"><a href="/insights/">insights</a></span></header>'4242 eq "$(grep -o 'class="ev-kind">[a-z]*' "$i" | sed 's/.*>//' | tr '\n' ' ')" "pushed site release landed pushed "43-has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$last/\"><b>greet</b> polish &lt;b&gt;the&lt;/b&gt; greeting</a><time datetime=\"2026-09-01T00:05:00Z\">"44-has "$i" '<span class="ev-kind">site</span><a class="ev-what" href="/sites/dami/greet/docs/"><b>greet</b> deployed its site</a><time datetime="2026-09-01T00:04:00Z">'45-has "$i" '<span class="ev-kind">release</span><a class="ev-what" href="/dami/greet/releases/#v1"><b>greet</b> <span class="tag">v1</span></a><time datetime="2026-09-01T00:03:00Z">'46-has "$i" '<span class="ev-kind">landed</span><a class="ev-what" href="/dami/greet/reviews/1/"><b>greet</b> #1 Say it louder</a><time datetime="2026-09-01T00:02:00Z">'43+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$last/\"><b>greet</b> polish &lt;b&gt;the&lt;/b&gt; greeting</a><time datetime=\"2026-09-01T00:05:00Z\" "44+has "$i" '<span class="ev-kind">site</span><a class="ev-what" href="/sites/dami/greet/docs/"><b>greet</b> deployed its site</a><time datetime="2026-09-01T00:04:00Z" '45+has "$i" '<span class="ev-kind">release</span><a class="ev-what" href="/dami/greet/releases/#v1"><b>greet</b> <span class="tag">v1</span></a><time datetime="2026-09-01T00:03:00Z" '46+has "$i" '<span class="ev-kind">landed</span><a class="ev-what" href="/dami/greet/reviews/1/"><b>greet</b> #1 Say it louder</a><time datetime="2026-09-01T00:02:00Z" '4747 has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$first/\"><b>greet</b> first</a>"4848 lacks "$i" 'secret|hidden thing|v9'4949 # The links go somewhere.
A test/scenarios/times-columns.sh +29 -0
@@ -0,0 +1,29 @@
1+# A time in its own column is compact; a time in a sentence reads as one2+# (DESIGN.md, Times). Both are a <time> with the moment, in UTC, in its3+# datetime and title, and the page has the day, as short as what ui.ts4+# puts there: "Sep 1", or "2025-03-01" from an earlier year. In the home5+# page's recent feed, the commits log and the reviews list it is compact;6+# in the profiles' "active ..." and a commit page's byline it is marked7+# data-ago, for ui.ts to say "5m ago".8+. "$(dirname "$0")/../lib.sh"9+user dami10+repo dami/greet11+clone dami dami/greet "$T/w/d"12+at "$T/w/d" sh -c 'echo old >old.txt && git add old.txt && GIT_AUTHOR_DATE="@1740830400 +0000" git commit -q -m "long ago"'13+old=$(git -C "$T/w/d" rev-parse HEAD)14+at "$T/w/d" commit "first"15+push_as dami "$T/w/d" origin main16+tick17+at "$T/w/d" commit "a change"18+push_as dami "$T/w/d" -o 'title=Say it louder' origin HEAD:dami/change19+export FORGE_STRICT=120+build21+col='<time datetime="2026-09-01T00:00:00Z" title="2026-09-01 00:00 UTC">Sep 1</time>'22+i=$FORGE_WWW/index.html23+has "$i" "<b>greet</b> first</a>$col</li>"24+has "$i" 'active <time datetime="2026-09-01T00:0[0-9]:00Z" title="2026-09-01 00:0[0-9] UTC" data-ago>Sep 1</time>'25+p=$FORGE_WWW/dami/greet/log/main/index.html26+has "$p" '>first</a></td><td class="nowrap">[^<]*</td><td class="date"><time datetime="2026-09-01T00:0[0-9]:00Z" title="2026-09-01 00:0[0-9] UTC">Sep 1</time></td>'27+has "$p" '>long ago</a></td><td class="nowrap">[^<]*</td><td class="date"><time datetime="2025-03-01T12:00:00Z" title="2025-03-01 12:00 UTC">2025-03-01</time></td>'28+has "$FORGE_WWW/dami/greet/commit/$old/index.html" 'committed <time datetime="2025-03-01T12:00:00Z" title="2025-03-01 12:00 UTC" data-ago>Mar 1, 2025</time>'29+has "$FORGE_WWW/dami/greet/reviews/index.html" '<span class="date"><time datetime="2026-09-01T00:01:00Z" title="2026-09-01 00:01 UTC">Sep 1</time></span>'
A test/scenarios/times-relative.sh +42 -0
@@ -0,0 +1,42 @@
1+# ui.ts says each <time> relative to now: compact in a column ("now", "5m",2+# "40m", "5h", "1d", "3w", then "Aug 24", or "2025-03-01" from an earlier3+# year), as a sentence where it has data-ago ("just now", "5m ago", then4+# "Mar 1, 2025"), with the full local date and time as its title. Run here5+# in node against a stand-in page, at a fixed now, in UTC and en-US.6+. "$(dirname "$0")/../lib.sh"7+got=$(cd "$T_HOME" && TZ=UTC LC_ALL=en_US.UTF-8 node -e '8+ const fs = require("node:fs"), vm = require("node:vm"), { stripTypeScriptTypes } = require("node:module");9+ const now = Date.parse("2026-10-18T12:00:00Z");10+ const ages = [["10s", 10], ["in 2m", -120], ["5m", 300], ["40m", 2400], ["5h", 5 * 3600], ["26h", 26 * 3600], ["6d", 6 * 86400], ["21d", 21 * 86400]];11+ const cases = [...ages.map(([n, s]) => [n, new Date(now - s * 1000).toISOString()]), ["this year", "2026-08-24T09:00:00Z"], ["last year", "2025-03-01T12:00:00Z"]];12+ const els = [];13+ for (const [name, iso] of cases) for (const ago of [false, true]) els.push({ name: name + (ago ? ", in a sentence" : ""), dateTime: iso, textContent: "", title: "", hasAttribute: (a) => ago && a === "data-ago" });14+ const noop = () => {};15+ const document = { addEventListener: noop, querySelector: () => null, querySelectorAll: (s) => (s === "time[datetime]" ? els : []), body: { classList: { toggle: noop } } };16+ const page = vm.createContext({ document, localStorage: { getItem: () => null, setItem: noop }, matchMedia: () => ({ matches: false }), location: { hash: "" }, setInterval: noop, setTimeout: noop, navigator: {} });17+ vm.runInContext("Date.now = () => " + now, page);18+ vm.runInContext(stripTypeScriptTypes(fs.readFileSync("site/assets/ui.ts", "utf8")), page);19+ for (const e of els) console.log(e.name + ": " + e.textContent);20+ console.log("title: " + els.find((e) => e.dateTime.startsWith("2025")).title);21+')22+eq "$got" "10s: now23+10s, in a sentence: just now24+in 2m: now25+in 2m, in a sentence: just now26+5m: 5m27+5m, in a sentence: 5m ago28+40m: 40m29+40m, in a sentence: 40m ago30+5h: 5h31+5h, in a sentence: 5h ago32+26h: 1d33+26h, in a sentence: 1d ago34+6d: 6d35+6d, in a sentence: 6d ago36+21d: 3w37+21d, in a sentence: 3w ago38+this year: Aug 2439+this year, in a sentence: Aug 2440+last year: 2025-03-0141+last year, in a sentence: Mar 1, 202542+title: Mar 1, 2025, 12:00 PM UTC"