Skip to content

Commit afd1b9d

Browse files
committed
fix(viewer): polish article chrome
1 parent 40cfde9 commit afd1b9d

5 files changed

Lines changed: 148 additions & 44 deletions

File tree

test/viewer-ui-assets.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ describe("viewer UI assets", () => {
1919
expect(indexHtml).toContain('src="/almanac-mark.png"');
2020
expect(indexHtml).toContain('href="/jobs.css"');
2121
expect(indexHtml).toContain("Your code wiki");
22+
expect(indexHtml).toContain('class="ca-brand ca-brand-route"');
23+
expect(indexHtml).toContain('data-route="/"');
2224
expect(indexHtml).not.toContain("Local wiki viewer");
25+
expect(indexHtml).toContain("Linked from");
26+
expect(indexHtml).not.toContain("Backlinks");
2327
expect(indexHtml).toContain('data-route="/"');
2428
expect(indexHtml).toContain('data-route="/getting-started"');
2529
expect(indexHtml).toContain('data-route="/jobs"');
@@ -47,6 +51,19 @@ describe("viewer UI assets", () => {
4751
expect(appJs).toContain("state.overview.featuredPages");
4852
expect(appJs).toContain('wikiPath === "/getting-started"');
4953
expect(appJs).toContain("renderGettingStarted");
54+
expect(appJs).toContain("renderPageActions");
55+
expect(appJs).toContain("goBack");
56+
expect(appJs).toContain("almanacHistoryIndex");
57+
expect(appJs).toContain("history.replaceState");
58+
expect(appJs).toContain("event.state");
59+
expect(appJs).toContain("Back");
60+
expect(appJs).not.toContain("ca-page-header");
61+
expect(appJs).toContain("renderHeading");
62+
expect(appJs).toContain("renderMarkdown(page.body, { decorateTitle: true, summary: page.summary })");
63+
expect(appJs).toContain("function renderMarkdown(source, options = {})");
64+
expect(appJs).toContain("renderArticleSummary");
65+
expect(appJs).toContain('const level = decorated ? "h1" : "h2";');
66+
expect(appJs).toContain("ca-page-ornament");
5067
expect(appJs).toContain('wikiPath === "/jobs"');
5168
expect(appJs).toContain('wikiPath.startsWith("/jobs/")');
5269
expect(appJs).toContain('import { createJobsView } from "./jobs-view.js"');
@@ -78,6 +95,7 @@ describe("viewer UI assets", () => {
7895
expect(jobsJs).toContain("deps.jobPath(runId)");
7996
expect(jobsJs).toContain("deps.jobRoute(run.id)");
8097
expect(jobsJs).toContain("deps.isCurrentJobRoute(runId)");
98+
expect(jobsJs).toContain("deps.pageActions()");
8199
expect(jobsJs).toContain("schedulePoll");
82100
expect(appJs).not.toContain("Start with the map");
83101
expect(appJs).toContain("setRailVisible(isWikiPageRoute(pathname))");
@@ -91,8 +109,15 @@ describe("viewer UI assets", () => {
91109

92110
expect(appCss).toContain(".ca-topic-link");
93111
expect(appCss).toContain(".ca-brand-mark-image");
112+
expect(appCss).toContain(".ca-brand-route");
113+
expect(appCss).toContain(".ca-page-actions");
114+
expect(appCss).toContain(".ca-back-button");
115+
expect(appCss).toContain(".ca-article-summary");
116+
expect(appCss).toContain(".ca-page-ornament");
117+
expect(appCss).toContain(".ca-link-button");
94118
expect(appCss).toContain("brightness(0.68)");
95119
expect(appCss).toContain(".ca-left .ca-link-button.is-active");
120+
expect(appCss).not.toContain(".ca-left .ca-link-button {\n color: var(--ca-text);");
96121
expect(appCss).toContain(".ca-shell.is-rail-hidden");
97122
expect(appCss).not.toContain(".ca-log-entry");
98123
expect(jobsCss).toContain(".ca-log-entry");

viewer/app.css

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,18 @@ input {
8282
display: flex;
8383
align-items: center;
8484
gap: 12px;
85+
width: 100%;
86+
border: 0;
87+
background: transparent;
88+
color: inherit;
8589
padding-bottom: 22px;
8690
border-bottom: 1px solid var(--ca-border);
91+
cursor: pointer;
92+
text-align: left;
93+
}
94+
95+
.ca-brand-route:hover .ca-brand-title {
96+
color: var(--ca-accent);
8797
}
8898

8999
.ca-brand-mark {
@@ -297,14 +307,6 @@ input {
297307
line-height: 1.35;
298308
}
299309

300-
.ca-left .ca-link-button {
301-
color: var(--ca-text);
302-
}
303-
304-
.ca-left .ca-link-button:hover {
305-
color: var(--ca-accent);
306-
}
307-
308310
.ca-topic-link {
309311
grid-template-columns: minmax(0, 1fr) auto;
310312
align-items: center;
@@ -501,43 +503,57 @@ input {
501503
background: transparent;
502504
}
503505

504-
.ca-page-header {
505-
margin-bottom: 34px;
506-
text-align: center;
506+
.ca-article-summary {
507+
margin: 0 0 18px;
508+
color: var(--ca-muted);
509+
font-size: 18px;
510+
line-height: 1.55;
507511
}
508512

509-
.ca-page-header h1 {
510-
margin: 0;
511-
font-size: 44px;
512-
font-weight: 400;
513-
line-height: 1.15;
513+
.ca-page-actions {
514+
max-width: 760px;
515+
margin: 0 auto 24px;
514516
}
515517

516-
.ca-page-header-meta {
517-
margin-top: 12px;
518-
color: var(--ca-muted);
519-
font-size: 13px;
518+
.ca-back-button {
519+
border: 1px solid var(--ca-border);
520+
border-radius: 5px;
521+
background: var(--ca-paper);
522+
color: var(--ca-accent);
523+
cursor: pointer;
524+
padding: 7px 11px;
525+
font-size: 14px;
526+
}
527+
528+
.ca-back-button:hover {
529+
border-color: var(--ca-accent);
530+
background: var(--ca-accent-soft);
531+
}
532+
533+
.ca-prose h1 {
534+
margin: 0 0 18px;
535+
font-size: 42px;
536+
font-weight: 400;
537+
line-height: 1.12;
520538
}
521539

522540
.ca-page-ornament {
523541
display: grid;
524-
grid-template-columns: 1fr auto 1fr;
542+
grid-template-columns: minmax(48px, 1fr) auto minmax(48px, 1fr);
525543
align-items: center;
526-
gap: 12px;
527-
margin: 24px auto 0;
528-
max-width: 520px;
544+
gap: 22px;
545+
margin: 8px 0 44px;
529546
color: var(--ca-accent);
530547
}
531548

532549
.ca-page-ornament::before,
533550
.ca-page-ornament::after {
534551
content: "";
535-
height: 1px;
536-
background: var(--ca-border);
552+
border-top: 1px solid var(--ca-border);
537553
}
538554

539555
.ca-page-ornament span {
540-
font-size: 16px;
556+
font-size: 20px;
541557
line-height: 1;
542558
}
543559

viewer/app.js

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const state = {
1919
currentPage: null,
2020
pageTitles: new Map(),
2121
showAllTopics: false,
22+
historyIndex: 0,
2223
};
2324

2425
const els = {
@@ -39,6 +40,7 @@ const jobsView = createJobsView({
3940
jobPath: (runId) => wikiApi(`/jobs/${encodeURIComponent(runId)}`),
4041
jobRoute: (runId) => wikiRoute(`/jobs/${runId}`),
4142
isCurrentJobRoute: (runId) => location.pathname === wikiRoute(`/jobs/${runId}`),
43+
pageActions: () => renderPageActions(wikiRoute("/")),
4244
renderError,
4345
renderMarkdown,
4446
escapeHtml,
@@ -63,6 +65,7 @@ boot().catch((error) => renderError(error));
6365

6466
async function boot() {
6567
wireEvents();
68+
initializeHistoryState();
6669
const result = await api("/api/wikis");
6770
state.wikis = result.wikis ?? [];
6871
renderChrome();
@@ -82,6 +85,13 @@ function wireEvents() {
8285
return;
8386
}
8487

88+
const back = event.target.closest("[data-back]");
89+
if (back !== null) {
90+
event.preventDefault();
91+
goBack();
92+
return;
93+
}
94+
8595
const target = event.target.closest("[data-route]");
8696
if (target === null || target.disabled === true) return;
8797
event.preventDefault();
@@ -97,13 +107,18 @@ function wireEvents() {
97107
: wikiRoute("/search"));
98108
});
99109

100-
window.addEventListener("popstate", () => {
110+
window.addEventListener("popstate", (event) => {
111+
state.historyIndex = historyIndexFromState(event.state);
101112
route(location.pathname, location.search, false).catch((error) => renderError(error));
102113
});
103114
}
104115

105116
async function route(pathname, search = "", push = true) {
106-
if (push) history.pushState(null, "", pathname + search);
117+
if (push) {
118+
const nextIndex = state.historyIndex + 1;
119+
state.historyIndex = nextIndex;
120+
history.pushState(historyState(nextIndex), "", pathname + search);
121+
}
107122
jobsView.clearPoll();
108123

109124
if (pathname === "/" || !pathname.startsWith("/w/")) {
@@ -338,6 +353,7 @@ async function renderOverview() {
338353
const overview = state.overview;
339354
document.title = `${state.currentWiki} - Almanac`;
340355
els.reader.innerHTML = `
356+
${renderPageActions("/")}
341357
<section class="ca-hero">
342358
<div class="ca-kicker">Project overview</div>
343359
<h1 class="ca-title">${escapeHtml(projectName(overview.repoRoot))}</h1>
@@ -387,6 +403,7 @@ async function renderGettingStarted() {
387403

388404
document.title = `Getting started - ${state.currentWiki}`;
389405
els.reader.innerHTML = `
406+
${renderPageActions(wikiRoute("/"))}
390407
<section class="ca-hero">
391408
<div class="ca-kicker">Getting started</div>
392409
<h1 class="ca-title">No getting started page</h1>
@@ -409,25 +426,24 @@ async function renderPage(slug) {
409426
function renderPageArticle(page) {
410427
document.title = `${page.title ?? page.slug} - Almanac`;
411428
els.reader.innerHTML = `
429+
${renderPageActions(wikiRoute("/"))}
412430
<article class="ca-article">
413-
<header class="ca-page-header">
414-
<h1>${escapeHtml(pageTitle(page))}</h1>
415-
<div class="ca-page-header-meta">Last revised ${escapeHtml(formatDate(page.updated_at))}</div>
416-
<div class="ca-chip-row" style="justify-content: center;">
417-
${page.topics.map((topic) => `<button class="ca-chip" data-route="${escapeAttr(wikiRoute(`/topic/${topic}`))}">${escapeHtml(topic)}</button>`).join("")}
418-
</div>
419-
<div class="ca-page-ornament"><span>✥</span></div>
420-
</header>
421-
<div class="ca-prose">${renderMarkdown(page.body)}</div>
431+
<div class="ca-prose">${renderMarkdown(page.body, { decorateTitle: true, summary: page.summary })}</div>
422432
</article>
423433
`;
424434
}
425435

436+
function renderArticleSummary(summary) {
437+
const text = summary?.trim();
438+
return text ? `<p class="ca-article-summary">${escapeHtml(text)}</p>` : "";
439+
}
440+
426441
async function renderTopic(slug) {
427442
const topic = await api(wikiApi(`/topic/${encodeURIComponent(slug)}`));
428443
rememberPages(topic.pages);
429444
document.title = `${topic.title ?? topic.slug} - Almanac`;
430445
els.reader.innerHTML = `
446+
${renderPageActions(wikiRoute("/"))}
431447
<section class="ca-hero">
432448
<div class="ca-kicker">Topic</div>
433449
<h1 class="ca-title">${escapeHtml(topic.title ?? topic.slug)}</h1>
@@ -462,6 +478,7 @@ async function renderSearch(query) {
462478
rememberPages(result.pages);
463479
document.title = `${query ? `Search: ${query}` : "Recent pages"} - Almanac`;
464480
els.reader.innerHTML = `
481+
${renderPageActions(wikiRoute("/"))}
465482
<section class="ca-hero">
466483
<div class="ca-kicker">${query ? "Search" : "Recent"}</div>
467484
<h1 class="ca-title">${query ? escapeHtml(query) : "Recent pages"}</h1>
@@ -475,6 +492,7 @@ async function renderFile(path) {
475492
const result = await api(wikiApi(`/file?path=${encodeURIComponent(path)}`));
476493
rememberPages(result.pages);
477494
els.reader.innerHTML = `
495+
${renderPageActions(wikiRoute("/"))}
478496
<section class="ca-hero">
479497
<div class="ca-kicker">File reference</div>
480498
<h1 class="ca-title">${escapeHtml(path || "File references")}</h1>
@@ -500,7 +518,7 @@ function renderPageRail(page) {
500518
`;
501519
els.backlinks.innerHTML = page.wikilinks_in.length > 0
502520
? page.wikilinks_in.map((slug) => linkButton(pageLabel(slug), wikiRoute(`/page/${slug}`))).join("")
503-
: `<div class="ca-meta-empty">No backlinks.</div>`;
521+
: `<div class="ca-meta-empty">No pages link here.</div>`;
504522
els.fileRefs.innerHTML = page.file_refs.length > 0
505523
? page.file_refs.map((ref) => linkButton(ref.path, wikiRoute(`/file?path=${encodeURIComponent(ref.path)}`), "", "ca-file-link")).join("")
506524
: `<div class="ca-meta-empty">No file refs.</div>`;
@@ -531,10 +549,20 @@ function linkButton(label, route, detail = "", extraClass = "") {
531549
`;
532550
}
533551

534-
function renderMarkdown(source) {
552+
function renderPageActions(fallbackRoute) {
553+
return `
554+
<div class="ca-page-actions">
555+
<button class="ca-back-button" type="button" data-back data-fallback-route="${escapeAttr(fallbackRoute)}">Back</button>
556+
</div>
557+
`;
558+
}
559+
560+
function renderMarkdown(source, options = {}) {
535561
const blocks = [];
536562
let inCode = false;
537563
let code = [];
564+
let decoratedHeading = false;
565+
const decorateTitle = options.decorateTitle === true;
538566

539567
for (const line of source.split(/\r?\n/)) {
540568
if (line.startsWith("```")) {
@@ -557,14 +585,24 @@ function renderMarkdown(source) {
557585
}
558586
if (line.startsWith("### ")) blocks.push(`<h3>${inline(line.slice(4))}</h3>`);
559587
else if (line.startsWith("## ")) blocks.push(`<h2>${inline(line.slice(3))}</h2>`);
560-
else if (line.startsWith("# ")) blocks.push(`<h2>${inline(line.slice(2))}</h2>`);
588+
else if (line.startsWith("# ")) {
589+
blocks.push(renderHeading(line.slice(2), decorateTitle && !decoratedHeading, options.summary));
590+
decoratedHeading = true;
591+
}
561592
else if (line.startsWith("- ")) blocks.push(`<p>• ${inline(line.slice(2))}</p>`);
562593
else blocks.push(`<p>${inline(line)}</p>`);
563594
}
564595
if (inCode) blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
565596
return blocks.filter(Boolean).join("\n");
566597
}
567598

599+
function renderHeading(text, decorated, summary = null) {
600+
const level = decorated ? "h1" : "h2";
601+
const heading = `<${level}>${inline(text)}</${level}>`;
602+
if (!decorated) return heading;
603+
return `${heading}\n${renderArticleSummary(summary)}\n<div class="ca-page-ornament" aria-hidden="true"><span>✥</span></div>`;
604+
}
605+
568606
function inline(text) {
569607
return escapeHtml(text)
570608
.replace(/`([^`]+)`/g, "<code>$1</code>")
@@ -633,6 +671,29 @@ function setRailVisible(visible) {
633671
els.shell.classList.toggle("is-rail-hidden", !visible);
634672
}
635673

674+
function goBack() {
675+
const active = document.querySelector("[data-back]");
676+
const fallbackRoute = active?.dataset?.fallbackRoute ?? "/";
677+
if (state.historyIndex > 0) {
678+
history.back();
679+
return;
680+
}
681+
navigate(fallbackRoute);
682+
}
683+
684+
function initializeHistoryState() {
685+
state.historyIndex = historyIndexFromState(history.state);
686+
history.replaceState(historyState(state.historyIndex), "", location.href);
687+
}
688+
689+
function historyState(index) {
690+
return { ...(history.state ?? {}), almanacHistoryIndex: index };
691+
}
692+
693+
function historyIndexFromState(value) {
694+
return typeof value?.almanacHistoryIndex === "number" ? value.almanacHistoryIndex : 0;
695+
}
696+
636697
function projectName(repoRoot) {
637698
return repoRoot.split("/").filter(Boolean).at(-1) ?? "Project";
638699
}

0 commit comments

Comments
 (0)