website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

commit 1c785cc930aca52a8537e37851dccc8b346645f9
parent ff8e7f5c4e7ade715d7d92a1d1c9ba73b7a4a3c4
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Sun, 11 Oct 2026 22:57:11 +0200

rework log section

Diffstat:
Mcss/style.css | 173++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mindex.html | 90++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mjs/computer.js | 6++++--
Ajs/gitlog.js | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mjs/main.js | 2++
Mjs/org.js | 97++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mjs/shell.js | 12++++++------
7 files changed, 460 insertions(+), 146 deletions(-)

diff --git a/css/style.css b/css/style.css @@ -64,6 +64,7 @@ --hl: color-mix(in srgb, var(--fg) 10%, var(--bg)); --wash: color-mix(in srgb, var(--fg) 5%, transparent); --led-off: color-mix(in srgb, var(--fg) 13%, var(--bg)); + --violet: color-mix(in oklch, var(--blue) 50%, var(--magenta)); --c-hw: var(--yellow); --c-sw: var(--blue); @@ -1056,35 +1057,97 @@ input[type="range"]::-moz-range-thumb { padding-right: 3ch; } -.ds-rev { - min-width: 0; +/* experience as git log --graph --color */ + +.gitlog-cmd { + margin-bottom: 12px; + font-weight: 400; + color: var(--fg); + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; } -.ds-rev th:first-child { - width: 16ch; - white-space: nowrap; +.gitlog-cmd .prompt { + color: var(--green); } -.ds-rev td:nth-child(2) { +.gitlog { + padding: 16px 18px; + overflow-x: auto; + border: 1px solid var(--rule); + font-size: 12px; + line-height: 1.7; color: var(--fg); } -.ds-rev td p { - margin: 0; +.gl-line { + width: max-content; + white-space: pre; +} + +/* one colour per branch */ +.gitlog .lm { color: var(--magenta); } /* main */ +.gitlog .lu { color: var(--green); } /* undergrad */ +.gitlog .lt { color: var(--yellow); } /* teaching-assistant */ +.gitlog .lb { color: var(--red); } /* bbd-software */ +.gitlog .li { color: var(--blue); } /* mwr-internship */ + +.gl-tip { + color: var(--fg); + font-weight: 600; } -.ds-rev td ul { - margin: 6px 0 0; - padding-left: 2.5ch; +.gl-refs { + color: var(--yellow); +} + +.gl-head { + color: var(--cyan); + font-weight: 600; } -.ds-rev td li { - margin-bottom: 3px; +.gl-branch { + color: var(--green); + font-weight: 600; +} + +.gl-author { + color: var(--blue); +} + +.gl-subj { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + white-space: pre; + text-decoration: underline dotted; + text-decoration-color: var(--faint); + text-underline-offset: 0.25em; +} + +.gl-subj:hover, +.gl-subj[aria-expanded="true"] { + text-decoration-style: solid; + text-decoration-color: currentColor; +} + +.gl-body, +.gl-desc { color: var(--dim); } -.ds-rev td li::marker { - color: var(--faint); +/* before the script runs (or without it), descriptions show under their commit */ +.gl-desc { + display: block; + padding-left: 4ch; +} + +.gitlog-hint { + margin: 8px 0 0; + font-size: 12px; } /* ───────────────────────────── memory map ───────────────────────────── */ @@ -1400,12 +1463,14 @@ input[type="range"]::-moz-range-thumb { /* ───────────────────────────── posts: an Org buffer ───────────────────────────── */ .org { - max-width: 820px; + max-width: 880px; font-size: 13px; + line-height: 1.75; } .org-kw { margin: 0; + font: inherit; color: var(--dim); } @@ -1413,13 +1478,24 @@ input[type="range"]::-moz-range-thumb { color: var(--fg); } -.org-kw .title { - font: 400 1.6em/1.4 var(--serif); +.org-title { color: var(--fg); + font-weight: 600; } -.org-tree { +.org-p { + max-width: 76ch; margin: 20px 0 0; + font-size: 13px; + line-height: 1.75; +} + +.org > .org-tree { + margin-top: 20px; +} + +.org-tree { + margin: 0; padding: 0; list-style: none; } @@ -1428,7 +1504,6 @@ input[type="range"]::-moz-range-thumb { display: flex; align-items: baseline; gap: 1ch; - padding: 3px 0; } .org-h:focus-visible { @@ -1436,42 +1511,27 @@ input[type="range"]::-moz-range-thumb { background: var(--hl); } -.org-bullet { +.org-stars { flex: none; - width: 2ch; padding: 0; border: 0; background: none; - color: var(--c, var(--blue)); - font: 400 13px/1 var(--mono); - text-align: left; + color: inherit; + font: inherit; } .org-h1 > .org-h { - margin-top: 10px; - font: 400 21px/1.3 var(--serif); -} - -.org-h1 > .org-h .org-bullet { color: var(--blue); -} - -.org-h1 > .org-h .count { - font: 400 12px/1 var(--mono); - color: var(--dim); -} - -.org-h2 { - padding-left: 3ch; + font-weight: 600; } .org-h2 > .org-h { - font: 400 17px/1.4 var(--serif); + color: var(--magenta); } .org-h2 a { - flex: 1; min-width: 0; + color: inherit; text-decoration: none; } @@ -1480,33 +1540,35 @@ input[type="range"]::-moz-range-thumb { text-decoration-thickness: 1px; } +.org-h .count, +.org-ellipsis { + color: var(--dim); + font-weight: 400; +} + .org-tags { flex: none; margin-left: auto; - font: 400 12px/1 var(--mono); + padding-left: 2ch; color: var(--dim); + white-space: nowrap; } .org-tags .t { - color: var(--c, var(--dim)); -} - -.org-ellipsis { - color: var(--dim); - font: 400 12px/1 var(--mono); + color: var(--tag, var(--dim)); } .org-drawer { - margin: 2px 0 6px 3ch; + margin: 0 0 6px 3ch; + overflow-x: auto; color: var(--dim); font-size: 12px; line-height: 1.6; white-space: pre; - overflow-x: auto; } .org-drawer .k { - color: var(--magenta); + color: var(--faint); } .org-drawer .ts { @@ -2220,6 +2282,15 @@ input[type="range"]::-moz-range-thumb { grid-template-columns: minmax(0, 1fr); } + .gitlog { + padding: 12px; + font-size: 11.5px; + } + + .gitlog-cmd { + font-size: 11.5px; + } + .org-tags { display: none; } diff --git a/index.html b/index.html @@ -30,6 +30,7 @@ <span>michael neuper</span> </a> <nav class="nav" id="nav" aria-label="Sections"> + <a href="#top">computer</a> <a href="#about">about</a> <a href="#projects">projects</a> <a href="#posts">posts</a> @@ -119,51 +120,47 @@ </figure> </div> - <h3 class="ds-h">3 Revision history</h3> - <div class="table-scroll"> - <table class="ds-table ds-rev"> - <thead> - <tr><th scope="col">Date</th><th scope="col">Changes</th></tr> - </thead> - <tbody> - <tr> - <th scope="row"><time datetime="2026-06">June 2026</time></th> - <td>Teaching assistant for computer architecture.</td> - </tr> - <tr> - <th scope="row"><time datetime="2026-02">February 2026</time></th> - <td>Teaching assistant for introductory computer science.</td> - </tr> - <tr> - <th scope="row"><time datetime="2025-01">January 2025</time></th> - <td> - <p>Bursar at BBD Software, in its Bursary &amp; Beyond Programme. Each year’s Vac Weeks put bursars in small teams to design, build and present a full-stack web application:</p> - <ul> - <li>2025: an online laser tag game that uses computer vision to detect and tag players as they play.</li> - <li>2026: an online foosball game you steer with your phone’s gyroscope.</li> - </ul> - </td> - </tr> - <tr> - <th scope="row"><time datetime="2024-12">December 2024</time></th> - <td> - <p>Cybersecurity consultant intern.</p> - <ul> - <li>Completed cybersecurity training with labs on TryHackMe.</li> - <li>Assessed the security of a web application from end to end, documenting each vulnerability with its risk and how to fix it.</li> - <li>Wrote a professional report and presented it, client-style, to a panel of industry professionals.</li> - </ul> - </td> - </tr> - <tr> - <th scope="row"><time datetime="2024-01">January 2024</time></th> - <td>Initial release. Started an undergraduate degree in computer science at Stellenbosch University.</td> - </tr> - </tbody> - </table> + <!-- Experience, as the one-line git log that would print it: "<date> - (refs) <subject> <author>". + data-lanes colours each graph character by branch: m main, u undergrad, t teaching-assistant, + b bbd-software, i mwr-internship (the colours are set in css/style.css). data-cont is the graph that continues below a commit, and a + .gl-desc becomes its expandable description. data-branch colours the date; data-tip makes the + * bold for a branch that's still going; data-today swaps in the current month when the page loads. + Without JavaScript this shows as plain text. --> + <h3 class="ds-h gitlog-cmd"><span class="prompt" aria-hidden="true">$ </span>git log --graph --all --color \ + --date=format:'%Y-%m' \ + --format='%ad -%C(auto)%d%C(reset) %s %C(blue)&lt;%an&gt;%C(reset)'</h3> + <div class="gitlog" aria-label="Experience, newest first"> +<div class="gl-line" data-lanes=" b u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="m" data-tip data-today>* | | | 2026-10 - (HEAD -&gt; main)</div> +<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b" data-tip>| * | | 2026-06 - (bbd-software) Vac Week 2026 &lt;BBD Software&gt;<div class="gl-desc">Built an online foosball game: players steer the rods by +tilting their phones, using the gyroscope. Designed and +presented as a full-stack web app with a small team.</div></div> +<div class="gl-line" data-lanes="m b u " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="t" data-tip>| | | * 2026-06 - (teaching-assistant) Teach computer architecture as a TA &lt;Stellenbosch University&gt;</div> +<div class="gl-line" data-lanes="m b u t">| | | |</div> +<div class="gl-line" data-lanes="m b u " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="t">| | | * 2026-02 - Teach introductory computer science as a TA &lt;Stellenbosch University&gt;</div> +<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b">| * | | 2025-06 - Vac Week 2025 &lt;BBD Software&gt;<div class="gl-desc">Built an online laser tag game: computer vision finds players +in the camera feed and tags them. Designed and presented as a +full-stack web app with a small team.</div></div> +<div class="gl-line" data-lanes="m b u t">| | | |</div> +<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b">| * | | 2025-01 - Join BBD Software as a bursar &lt;BBD Software&gt;<div class="gl-desc">Part of the Bursary &amp; Beyond Programme, which includes the +yearly Vac Weeks.</div></div> +<div class="gl-line" data-lanes="mb u t">|/ | |</div> +<div class="gl-line" data-lanes="m u t">| | |</div> +<div class="gl-line" data-lanes="mi u t">|\ | |</div> +<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m i u t " data-branch="i">| * | | 2024-12 - (mwr-internship) Complete a cybersecurity consultant internship &lt;MWR CyberSec&gt;<div class="gl-desc">- Completed cybersecurity training with labs on TryHackMe. +- Assessed a web application end to end, documenting each + vulnerability with its risk and how to fix it. +- Wrote a professional report and presented it, client-style, + to a panel of industry professionals.</div></div> +<div class="gl-line" data-lanes="mi u t">|/ | |</div> +<div class="gl-line" data-lanes="m ut">| |/</div> +<div class="gl-line" data-lanes="m " data-cont="| | " data-cont-lanes="m u " data-branch="u" data-tip>| * 2024-01 - (undergrad) Start an undergraduate degree in computer science &lt;Stellenbosch University&gt;</div> +<div class="gl-line" data-lanes="m u">| /</div> +<div class="gl-line" data-lanes="mu">|/</div> +<div class="gl-line" data-lanes=" " data-cont=" " data-cont-lanes=" " data-branch="m">* 2005-06 - Initial commit &lt;Michael Neuper&gt;</div> </div> + <p class="gitlog-hint dim">Click an underlined commit message to read the rest of it.</p> - <h3 class="ds-h">4 Ordering information</h3> + <h3 class="ds-h">3 Ordering information</h3> <p class="prose">Interested in systems programming, low-level software and security work. If that’s what you do, <a href="#contact">get in touch</a>.</p> </section> @@ -218,10 +215,9 @@ <!-- ────────────────────────────── posts ────────────────────────────── --> <section class="sec" id="posts" data-file="posts.org" data-mode="Org"> <p class="winbar"><span class="file">posts.org</span><span class="rule" aria-hidden="true"></span><span class="meta" data-org="meta"></span></p> - <h2 class="h2">Posts</h2> - <p class="lede">From my blog at michaelneuper.com, shown the way I see them: as an Org buffer. Click a bullet or press <kbd>TAB</kbd> on a heading to fold it, <kbd>S-TAB</kbd> to cycle the whole buffer.</p> + <p class="org-p">From my blog at michaelneuper.com, shown the way I read them. <kbd>TAB</kbd> on a heading folds it, <kbd>S-TAB</kbd> cycles the whole buffer, and clicking a heading’s stars does the same.</p> - <!-- Edit posts here. data-tags are space separated; data-cat colours the bullet. --> + <!-- Edit posts here. data-tags are space separated; each tag gets its own colour (see TAG_COLORS in js/org.js). --> <ol class="posts"> <li data-date="2024-11-22" data-tags="emacs org" data-cat="sw"><a href="https://michaelneuper.com/posts/how-i-use-org-roam-to-takes-notes-for-cs/">How I Use Org-Roam to Take Notes for CS</a></li> <li data-date="2023-11-15" data-tags="emacs latex" data-cat="math"><a href="https://michaelneuper.com/posts/efficient-latex-editing-with-emacs/">Efficient LaTeX Editing With Emacs</a></li> @@ -309,7 +305,7 @@ <span class="ml-flags" aria-hidden="true"></span> <span class="ml-mode">Asm</span> <span class="ml-enc" aria-hidden="true">LF UTF-8</span> - <span class="ml-vc" aria-hidden="true">Git-master</span> + <span class="ml-vc" aria-hidden="true">master</span> <span class="ml-time" aria-hidden="true"></span> </div> <div class="mini" hidden> diff --git a/js/computer.js b/js/computer.js @@ -11,6 +11,8 @@ import { defcommand, message, completingRead, setSegment } from "./shell.js"; // Control lines, in the order the control-word LEDs show them. const LINES = ["HLT", "MI", "RI", "RO", "IO", "II", "AI", "AO", "EO", "SU", "BI", "OI", "CE", "CO", "J", "FI"]; const L = Object.fromEntries(LINES.map((n, i) => [n, 1 << (15 - i)])); +// How the lines are printed on the board: the sum-out line is ΣO, as on the build. +const LABEL = { EO: "ΣO" }; const OPS = { 0: "NOP", 1: "LDA", 2: "ADD", 3: "SUB", 4: "STA", 5: "LDI", 6: "JMP", 7: "JC", 8: "JZ", 14: "OUT", 15: "HLT" }; const CODE = Object.fromEntries(Object.entries(OPS).map(([k, v]) => [v, +k])); @@ -161,7 +163,7 @@ export function initComputer() { const head = el("div", "mod-head"); head.append(el("span", "mod-name", name)); for (const sig of sigs) { - const t = el("span", "sig", sig); + const t = el("span", "sig", LABEL[sig] ?? sig); t.dataset.sig = sig; head.append(t); } @@ -293,7 +295,7 @@ export function initComputer() { const cwBits = LINES.map((n) => { const bit = el("span", "cw-bit"); const l = el("span", "led b"); - bit.append(l, el("span", "", n)); + bit.append(l, el("span", "", LABEL[n] ?? n)); cwWrap.append(bit); return [bit, l]; }); diff --git a/js/gitlog.js b/js/gitlog.js @@ -0,0 +1,226 @@ +// Experience as one-line git log --graph output, the "git lg" way: +// <date> - (refs) <subject> <author> +// with the date standing in for the hash and the company as the author. +// +// The rows are plain text in index.html, so they read fine without JavaScript. +// This colours the graph by branch (data-lanes), colours the date, refs and +// author, and turns subjects that have a .gl-desc into buttons that expand it. +// Lanes: m main, u undergrad, t teaching-assistant, b bbd-software, i mwr-internship. +// +// When the log is wider than its box (on a phone), it wraps the way a narrow +// terminal would: at word boundaries, with each wrapped line starting with the +// commit's lane pipes and a short hanging indent. + +import { $, $$ } from "./util.js"; + +const LANE_CLASS = { m: "lm", u: "lu", t: "lt", b: "lb", i: "li" }; +const ROW = /^([|*/\\ ]*?)(\d{4}-\d{2}(?:-\d{2})?) - (?:\(([^)]*)\) ?)?(.*?)(?: ?<([^>]*)>)?$/; +const HANG = 2; + +function el(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; +} + +// The graph characters, coloured by lane and hidden from screen readers. +// `tip` makes the commit's * bold. +function graph(text, lanes = "", tip = false) { + const g = el("span", "gl-graph"); + g.setAttribute("aria-hidden", "true"); + [...text].forEach((ch, i) => { + const lane = LANE_CLASS[(lanes[i] || " ").trim()]; + if (ch === "*" && tip) g.append(el("span", "gl-tip", ch)); + else g.append(lane && ch.trim() ? el("span", lane, ch) : ch); + }); + return g; +} + +// The HEAD commit is dated with the month the page is viewed in. +function today() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +function parse(div) { + const descEl = $(".gl-desc", div); + const body = descEl ? descEl.textContent.replace(/^\n+|\s+$/g, "").split("\n") : null; + descEl?.remove(); + const text = div.textContent.replace(/\s+$/, ""); + const m = ROW.exec(text); + const row = { + graph: m ? m[1] : text, lanes: div.dataset.lanes, cont: div.dataset.cont ?? "", contLanes: div.dataset.contLanes, + tip: "tip" in div.dataset, branch: div.dataset.branch, + }; + if (!m) return row; + return { + ...row, + date: "today" in div.dataset ? today() : m[2], + refs: m[3] ? m[3].split(/,\s*/).map((r) => (r.startsWith("HEAD -> ") ? { head: true, name: r.slice(8) } : { name: r })) : [], + subject: m[4], + author: m[5], + body, + }; +} + +const refsText = (refs) => `(${refs.map((r) => (r.head ? "HEAD -> " : "") + r.name).join(", ")})`; + +// A commit line as words, so it can be wrapped. Each word knows how to draw itself. +function words(row) { + const w = [{ text: row.date, cls: `gl-date ${LANE_CLASS[row.branch] ?? ""}` }, { text: "-" }]; + if (row.refs.length) w.push({ text: refsText(row.refs), refs: row.refs }); + if (row.subject) for (const s of row.subject.split(" ")) w.push({ text: s, subj: true }); + if (row.author) w.push({ text: `<${row.author}>`, cls: "gl-author" }); // kept on one line + return w; +} + +// Greedy word wrap. Returns lines of words; a word longer than the width gets a line to itself. +function wrap(list, width, hang) { + const lines = [[]]; + let used = 0; + for (const w of list) { + const line = lines[lines.length - 1]; + const need = (line.length ? 1 : 0) + w.text.length; + if (line.length && used + need > width) { + lines.push([w]); + used = hang + w.text.length; + } else { + line.push(w); + used += need; + } + } + return lines; +} + +// Re-flow a description to a width, keeping "- " bullets with a hanging indent. +function reflow(body, width) { + const blocks = []; + for (const line of body) { + if (line.startsWith("- ") || !blocks.length || !line.startsWith(" ")) blocks.push(line.trim()); + else blocks[blocks.length - 1] += ` ${line.trim()}`; + } + // plain paragraphs that were hard-wrapped become one block again + const merged = []; + for (const b of blocks) { + if (merged.length && !b.startsWith("- ") && !merged[merged.length - 1].startsWith("- ")) merged[merged.length - 1] += ` ${b}`; + else merged.push(b); + } + const out = []; + for (const b of merged) { + const bullet = b.startsWith("- "); + const lines = wrap(b.split(" ").map((text) => ({ text })), width, bullet ? 2 : 0); + lines.forEach((l, i) => out.push((i && bullet ? " " : "") + l.map((w) => w.text).join(" "))); + } + return out; +} + +export function initGitlog() { + const host = $(".gitlog"); + if (!host) return; + const rows = $$(":scope > .gl-line", host).map(parse); + const open = new Set(); + let lastCols = 0; + + function measureCols() { + const probe = el("span", "", "0".repeat(20)); + probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre"; + host.append(probe); + const ch = probe.getBoundingClientRect().width / 20 || 7; + probe.remove(); + const cs = getComputedStyle(host); + const inner = host.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + return Math.max(24, Math.floor(inner / ch)); + } + + function render(focusRow = -1) { + const cols = measureCols(); + lastCols = cols; + const lineLen = (r) => r.graph.length + words(r).reduce((n, w, i) => n + (i ? 1 : 0) + w.text.length, 0); + const wide = rows.every((r) => !r.date || lineLen(r) <= cols); + const pipesOnly = (r) => r && !r.date && /^[| ]+$/.test(r.graph); + const frag = document.createDocumentFragment(); + + rows.forEach((row, n) => { + if (!row.date) { + const line = el("div", "gl-line"); + line.append(graph(row.graph, row.lanes)); + frag.append(line); + return; + } + + const isOpen = open.has(n); + const lines = wrap(words(row), cols - row.graph.length, HANG); + let subjSeen = false; + lines.forEach((ws, li) => { + const line = el("div", "gl-line"); + line.append(li ? graph(row.cont, row.contLanes) : graph(row.graph, row.lanes, row.tip)); + if (li) line.append(" ".repeat(HANG)); + // consecutive subject words on a line become one button (or span) so the underline is unbroken + for (let k = 0; k < ws.length; k++) { + if (k) line.append(" "); + const w = ws[k]; + if (w.subj) { + let text = w.text; + while (ws[k + 1]?.subj) text += ` ${ws[++k].text}`; + if (row.body) { + const b = el("button", "gl-subj", text); + b.type = "button"; + b.dataset.row = n; + b.setAttribute("aria-expanded", String(isOpen)); + if (subjSeen) b.tabIndex = -1; // one tab stop per commit, even when its subject wraps + line.append(b); + } else { + line.append(el("span", "gl-text", text)); + } + subjSeen = true; + } else if (w.refs) { + const refs = el("span", "gl-refs"); + refs.append("("); + w.refs.forEach((r, i) => { + if (i) refs.append(", "); + if (r.head) refs.append(el("span", "gl-head", "HEAD -> ")); + refs.append(el("span", "gl-branch", r.name)); + }); + refs.append(")"); + line.append(refs); + } else { + line.append(w.cls ? el("span", w.cls, w.text) : w.text); + } + } + frag.append(line); + }); + + if (row.body && isOpen) { + const refsLen = row.refs.length ? refsText(row.refs).length + 1 : 0; + // under the subject when there's room; otherwise a little deeper than wrapped lines + const indent = wide ? row.date.length + 3 + refsLen : HANG + 2; + const text = wide ? row.body : reflow(row.body, cols - row.cont.length - indent); + const tail = pipesOnly(rows[n + 1]) ? [] : [""]; + for (const t of [...text, ...tail]) { + const b = el("div", "gl-line gl-body"); + b.append(graph(row.cont, row.contLanes), t ? " ".repeat(indent) + t : ""); + frag.append(b); + } + } + }); + + host.replaceChildren(frag); + host.classList.add("is-built"); + if (focusRow >= 0) host.querySelector(`.gl-subj[data-row="${focusRow}"]`)?.focus({ preventScroll: true }); + } + + host.addEventListener("click", (e) => { + const b = e.target.closest(".gl-subj"); + if (!b) return; + const n = +b.dataset.row; + if (open.has(n)) open.delete(n); + else open.add(n); + render(n); + }); + + render(); + new ResizeObserver(() => { + if (measureCols() !== lastCols) render(); + }).observe(host); +} diff --git a/js/main.js b/js/main.js @@ -2,6 +2,7 @@ import { fontsReady } from "./util.js"; import "./shell.js"; import { initComputer } from "./computer.js"; import { initDatasheet } from "./datasheet.js"; +import { initGitlog } from "./gitlog.js"; import { initMemmap } from "./memmap.js"; import { initOrg } from "./org.js"; import { initUart } from "./uart.js"; @@ -14,6 +15,7 @@ function safely(fn) { safely(initComputer); safely(initDatasheet); +safely(initGitlog); safely(initOrg); safely(initUart); safely(initCtf); diff --git a/js/org.js b/js/org.js @@ -1,20 +1,34 @@ -// Renders the posts list as an Org buffer: years as top-level headings, posts -// under them with tags and a property drawer. TAB folds the heading at point, -// S-TAB cycles the whole buffer through OVERVIEW, CONTENTS and SHOW ALL. +// Renders the posts section as an Org buffer: keywords, a paragraph, years as +// "*" headings and posts as "**" headings with tags and a property drawer. +// TAB folds the heading at point; S-TAB cycles OVERVIEW, CONTENTS and SHOW ALL. import { $, $$ } from "./util.js"; import { message } from "./shell.js"; const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +// One colour per tag, like org-tag-faces. Add an entry when you add a tag. +const TAG_COLORS = { + emacs: "var(--magenta)", + org: "var(--green)", + latex: "var(--cyan)", + python: "var(--yellow)", + maths: "var(--blue)", + linux: "var(--orange)", + security: "var(--red)", + c: "var(--violet)", + web: "var(--fg)", +}; + export function initOrg() { const sec = $("#posts"); const src = sec && $(".posts", sec); if (!src) return; + const intro = $(".org-p", sec); const posts = $$("li", src).map((li) => { const a = $("a", li); - return { date: li.dataset.date, tags: (li.dataset.tags || "").split(/\s+/).filter(Boolean), cat: li.dataset.cat, title: a.textContent.trim(), href: a.href }; + return { date: li.dataset.date, tags: (li.dataset.tags || "").split(/\s+/).filter(Boolean), title: a.textContent.trim(), href: a.href }; }); const years = [...new Set(posts.map((p) => p.date.slice(0, 4)))].sort().reverse(); @@ -26,49 +40,70 @@ export function initOrg() { }; const org = el("div", "org"); - const kw = (k, v, cls = "v") => { - const p = el("p", "org-kw", `#+${k}: `); - p.append(el("span", cls, v)); + const kw = (tag, key, value, cls) => { + const p = el(tag, "org-kw"); + const k = el("span", "org-key", `#+${key}: `); + if (tag === "h2") k.setAttribute("aria-hidden", "true"); + p.append(k, el("span", cls, value)); return p; }; - org.append(kw("title", "Posts", "title"), kw("author", "Michael Neuper"), kw("startup", "content")); + org.append(kw("h2", "title", "Posts", "org-title"), kw("p", "author", "Michael Neuper", "v"), kw("p", "startup", "content", "v")); + if (intro) org.append(intro); const tree = el("ul", "org-tree"); tree.setAttribute("role", "tree"); tree.setAttribute("aria-label", "Posts by year"); const headings = []; + function heading(li, level) { + const h = el("div", "org-h"); + h.tabIndex = headings.length === 0 ? 0 : -1; + const stars = el("button", "org-stars", "*".repeat(level)); + stars.type = "button"; + stars.tabIndex = -1; + stars.setAttribute("aria-label", "Fold or unfold"); + stars.addEventListener("click", () => { li._fold(!li._open()); h.focus({ preventScroll: true }); }); + h.append(stars); + li.prepend(h); + headings.push(h); + return h; + } + for (const y of years) { const items = posts.filter((p) => p.date.startsWith(y)); const li = el("li", "org-h1"); li.setAttribute("role", "treeitem"); const h = heading(li, 1); - h.append(el("span", "", y), el("span", "count", `[${items.length}]`)); - const ell = el("span", "org-ellipsis", "…"); - ell.hidden = true; + h.append(el("span", "org-text", y), el("span", "count", `[${items.length}]`)); + const ell = el("span", "org-ellipsis", "..."); h.append(ell); + const kids = el("ul", "org-tree"); kids.setAttribute("role", "group"); - kids.style.margin = "0"; for (const p of items) { const pl = el("li", "org-h2"); pl.setAttribute("role", "treeitem"); - if (p.cat) pl.dataset.cat = p.cat; const ph = heading(pl, 2); - const a = el("a", "", p.title); + const a = el("a", "org-text", p.title); a.href = p.href; a.tabIndex = -1; ph.append(a); - const tags = el("span", "org-tags"); - tags.append(":"); - for (const t of p.tags) tags.append(el("span", "t", t), ":"); - ph.append(tags); - const pell = el("span", "org-ellipsis", "…"); + if (p.tags.length) { + const tags = el("span", "org-tags"); + tags.append(":"); + for (const t of p.tags) { + const tg = el("span", "t", t); + tg.style.setProperty("--tag", TAG_COLORS[t] ?? "var(--dim)"); + tags.append(tg, ":"); + } + ph.append(tags); + } + const pell = el("span", "org-ellipsis", "..."); ph.append(pell); + const d = new Date(`${p.date}T12:00:00Z`); const drawer = el("div", "org-drawer"); drawer.innerHTML = `<span class="k">:PROPERTIES:</span>\n<span class="k">:DATE:</span> <span class="ts">&lt;${p.date} ${DAYS[d.getUTCDay()]}&gt;</span>\n<span class="k">:URL:</span> <a href="${p.href}" tabindex="-1">${p.href.replace(/^https?:\/\//, "")}</a>\n<span class="k">:END:</span>`; - drawer.hidden = true; pl.append(drawer); pl._fold = (open) => { drawer.hidden = !open; @@ -95,24 +130,6 @@ export function initOrg() { const meta = $('[data-org="meta"]', sec); if (meta) meta.textContent = `${posts.length} posts`; - function heading(li, level) { - const h = el("div", "org-h"); - h.tabIndex = headings.length === 0 ? 0 : -1; - const bullet = el("button", "org-bullet", level === 1 ? "◉" : "○"); - bullet.type = "button"; - bullet.tabIndex = -1; - bullet.setAttribute("aria-label", "Fold or unfold"); - bullet.addEventListener("click", () => { cycle(li); h.focus({ preventScroll: true }); }); - h.append(bullet); - li.prepend(h); - headings.push(h); - return h; - } - - function cycle(li) { - li._fold(!li._open()); - } - // Global visibility cycling, like S-TAB in Org. const STATES = ["OVERVIEW", "CONTENTS", "SHOW ALL"]; let global = 1; @@ -139,13 +156,13 @@ export function initOrg() { t.focus(); }; if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); globalCycle(); } - else if (e.key === "Tab") { e.preventDefault(); cycle(li); } + else if (e.key === "Tab") { e.preventDefault(); li._fold(!li._open()); } else if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); move(i + 1); } else if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); move(i - 1); } else if (e.key === "Enter") { const a = h.querySelector("a"); if (a) a.click(); - else cycle(li); + else li._fold(!li._open()); } }); } diff --git a/js/shell.js b/js/shell.js @@ -363,18 +363,18 @@ help.addEventListener("keydown", (e) => { if (e.key === "q") help.close(); }); const KEYMAP = [ { key: "SPC", desc: "find file", cmd: "find-file" }, { key: ":", desc: "M-x", cmd: "execute-extended-command" }, - { key: "a", desc: "about", cmd: "goto-about" }, - { key: "p", desc: "projects", cmd: "goto-projects" }, - { key: "n", desc: "posts", cmd: "goto-posts" }, - { key: "m", desc: "mail me", cmd: "goto-contact" }, - { key: "r", desc: "refill coffee", cmd: "coffee-refill" }, - { key: "b", desc: "buy me a coffee", cmd: "buy-me-a-coffee" }, { key: "c", desc: "+computer", map: [ { key: "c", desc: "run or pause", cmd: "computer-run" }, { key: "n", desc: "step one cycle", cmd: "computer-step" }, { key: "r", desc: "reset", cmd: "computer-reset" }, { key: "l", desc: "load program", cmd: "computer-load-program" }, ] }, + { key: "a", desc: "about", cmd: "goto-about" }, + { key: "p", desc: "projects", cmd: "goto-projects" }, + { key: "n", desc: "posts", cmd: "goto-posts" }, + { key: "m", desc: "mail me", cmd: "goto-contact" }, + { key: "r", desc: "refill coffee", cmd: "coffee-refill" }, + { key: "b", desc: "buy me a coffee", cmd: "buy-me-a-coffee" }, { key: "h", desc: "+help", map: [ { key: "t", desc: "load-theme", cmd: "load-theme" }, { key: "h", desc: "keys and commands", cmd: "help" },