From 4f0b5db62b55be62549378924f8e538da7b99128 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 14 Jun 2026 00:09:24 -0500 Subject: [PATCH] feat: add docs viewer route and UI --- assets/css/app.css | 1 + assets/css/components/doc-viewer.css | 513 ++++++++++++++++++ assets/css/components/shell.css | 10 +- docs/tasks/17-doc-viewer.md | 18 +- lib/tempest/docs.ex | 15 + lib/tempest_web/components/layouts.ex | 1 + .../controllers/page_html/home.html.heex | 8 +- lib/tempest_web/live/doc_live.ex | 261 +++++++++ lib/tempest_web/live/home_live.ex | 10 +- lib/tempest_web/router.ex | 2 + priv/static/images/icons/arrow-left.svg | 4 + priv/static/images/icons/arrow-right.svg | 4 + priv/static/images/icons/doc.svg | 9 + priv/static/images/icons/home.svg | 11 + priv/static/images/icons/print.svg | 8 + priv/static/images/icons/search.svg | 8 + priv/static/images/icons/stop.svg | 5 + 17 files changed, 873 insertions(+), 15 deletions(-) create mode 100644 assets/css/components/doc-viewer.css create mode 100644 lib/tempest_web/live/doc_live.ex create mode 100644 priv/static/images/icons/arrow-left.svg create mode 100644 priv/static/images/icons/arrow-right.svg create mode 100644 priv/static/images/icons/doc.svg create mode 100644 priv/static/images/icons/home.svg create mode 100644 priv/static/images/icons/print.svg create mode 100644 priv/static/images/icons/search.svg create mode 100644 priv/static/images/icons/stop.svg diff --git a/assets/css/app.css b/assets/css/app.css index 778681d..193b97f 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -4,3 +4,4 @@ @import "utilities.css"; @import "components/shell.css"; @import "components/core.css"; +@import "components/doc-viewer.css"; diff --git a/assets/css/components/doc-viewer.css b/assets/css/components/doc-viewer.css new file mode 100644 index 0000000..a4a4b1c --- /dev/null +++ b/assets/css/components/doc-viewer.css @@ -0,0 +1,513 @@ +.doc-viewer, +.doc-viewer-page { + --doc-page-pad: clamp(0.75rem, 2vw, 1.35rem); + --doc-taskbar-height: 2.7rem; + + min-height: 100vh; + padding: var(--doc-page-pad); + background-color: var(--win-desktop); + background-image: + linear-gradient(45deg, rgb(255 255 255 / 0.07) 25%, transparent 25%), + linear-gradient(-45deg, rgb(0 0 0 / 0.08) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgb(0 0 0 / 0.08) 75%), + linear-gradient(-45deg, transparent 75%, rgb(255 255 255 / 0.07) 75%); + background-position: + 0 0, + 0 0.25rem, + 0.25rem -0.25rem, + -0.25rem 0; + background-size: 0.5rem 0.5rem; +} + +.doc-viewer-page { + height: 100vh; + overflow: hidden; + padding-block-start: calc(var(--doc-taskbar-height) + var(--doc-page-pad)); +} + +.doc-viewer-page .tempest-home__desktop, +.doc-viewer-page .tempest-home__workarea, +.doc-viewer-page__windows { + min-height: 0; +} + +.doc-viewer-page .tempest-home__desktop { + height: calc(100vh - var(--doc-taskbar-height) - var(--doc-page-pad) - var(--doc-page-pad)); + padding-bottom: 0; +} + +.doc-viewer-page .tempest-home__workarea { + height: 100%; +} + +.doc-viewer-page__windows { + height: 100%; + margin-inline-end: 0; +} + +.doc-browser { + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + width: min(86rem, 100%); + height: 100%; + min-height: 0; + margin-inline: auto; + overflow: hidden; + background: var(--surface); + box-shadow: var(--border-out), var(--shadow-window); +} + +.doc-browser__titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: 1.95rem; + padding: 0.2rem var(--space-2); + color: white; + background: linear-gradient(90deg, var(--win-title), var(--win-title-soft)); + font-size: var(--step--1); + font-weight: 700; +} + +.doc-browser__titlebar-label { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-2); +} + +.doc-browser__titlebar-label span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-browser__titlebar img, +.doc-browser__bookmark img { + flex: none; + image-rendering: pixelated; +} + +.doc-browser__toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; + padding: var(--space-2); + background: var(--surface); + box-shadow: inset 0 -1px 0 var(--win-shadow), inset 0 1px 0 var(--win-light); +} + +.doc-browser__tool, +.doc-browser__tool:visited { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + min-width: 4.5rem; + min-height: 2.5rem; + padding: 0.2rem var(--space-3); + color: var(--text); + background: var(--surface); + box-shadow: var(--border-out); + font-size: var(--step--1); + font-weight: 700; + text-decoration: none; + transition-property: background-color, color, transform; + transition-duration: 120ms; + transition-timing-function: ease-out; +} + +.doc-browser__tool img { + flex: none; + width: 1.1rem; + height: 1.1rem; + image-rendering: pixelated; +} + +.doc-browser__tool:hover { + color: var(--text); + background: var(--surface-raised); + transform: translate(-1px, -1px); +} + +.doc-browser__tool:active { + transform: scale(0.96); + box-shadow: var(--border-in); +} + +.doc-browser__tool--disabled, +.doc-browser__tool--disabled:hover { + color: var(--win-shadow); + background: var(--surface); + transform: none; +} + +.doc-browser__location { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-2); + align-items: center; + padding: var(--space-2); + font-size: var(--step--1); + font-weight: 700; +} + +.doc-browser__location a { + min-height: 2rem; + padding: 0.25rem var(--space-2); + overflow: hidden; + color: var(--text); + background: var(--surface-sunken); + box-shadow: var(--border-in); + font-family: var(--font-mono); + font-weight: 500; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-browser__workspace { + display: grid; + grid-template-columns: minmax(14rem, 19rem) minmax(0, 1fr); + min-height: 0; + overflow: hidden; + border-block: 1px solid var(--win-shadow); +} + +.doc-browser__bookmarks { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + min-height: 0; + padding: var(--space-2); + background: var(--surface-raised); + box-shadow: inset -1px 0 0 var(--win-shadow), inset 1px 0 0 var(--win-light); +} + +.doc-browser__bookmarks nav { + min-height: 0; +} + +.doc-browser__pane-title { + margin-block-end: var(--space-2); + padding: 0.25rem var(--space-2); + color: white; + background: var(--win-title); + font-size: var(--step--1); + font-weight: 700; +} + +.doc-browser__bookmark-list { + display: grid; + gap: 0.12rem; + max-height: none; + height: 100%; + margin: 0; + padding: var(--space-1); + overflow: auto; + list-style: none; + background: var(--surface-sunken); + box-shadow: var(--border-in); +} + +.doc-browser__bookmark { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-2); + align-items: center; + min-height: 2.5rem; + padding: 0.24rem var(--space-2); + color: var(--text); + font-size: 0.78rem; + line-height: var(--leading-tight); + text-decoration: none; + transition-property: background-color, color; + transition-duration: 120ms; + transition-timing-function: ease-out; +} + +.doc-browser__bookmark:hover, +.doc-browser__bookmark:focus-visible, +.doc-browser__bookmark.is-active { + color: white; + background: var(--win-title); +} + +.doc-browser__bookmark span { + overflow-wrap: anywhere; +} + +.doc-browser__document { + min-width: 0; + min-height: 0; + padding: var(--space-4); + overflow: auto; + background: #f7f7ef; +} + +.doc-browser__document-header { + padding: var(--space-4); + background: var(--web-yellow); + box-shadow: var(--border-in); +} + +.doc-browser__document-kicker { + margin-block-end: var(--space-2); + font-family: var(--font-mono); + font-size: var(--step--1); + overflow-wrap: anywhere; +} + +.doc-browser__document h1 { + max-width: var(--measure-text); + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: var(--step-2); + line-height: var(--leading-tight); + text-wrap: balance; +} + +.doc-browser__metadata { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-block-start: var(--space-3); + font-size: var(--step--1); +} + +.doc-browser__metadata div { + display: flex; + gap: var(--space-2); + align-items: baseline; + padding: 0.2rem var(--space-2); + background: var(--surface-raised); + box-shadow: var(--border-out); +} + +.doc-browser__metadata dt { + font-weight: 700; +} + +.doc-browser__metadata dd { + margin: 0; + font-family: var(--font-mono); +} + +.doc-browser__article { + margin-block-start: var(--space-4); + padding: clamp(1rem, 3vw, 2rem); + background: white; + box-shadow: var(--border-in); +} + +.doc-browser__article > * + * { + margin-block-start: var(--space-3); +} + +.doc-browser__article h1, +.doc-browser__article h2, +.doc-browser__article h3 { + max-width: var(--measure-text); + color: #111; + font-family: Georgia, "Times New Roman", serif; + line-height: var(--leading-tight); + text-wrap: balance; +} + +.doc-browser__article h1 { + font-size: var(--step-2); +} + +.doc-browser__article h2 { + margin-block-start: var(--space-6); + padding-block-end: var(--space-1); + border-bottom: 2px dotted var(--win-shadow); + font-size: var(--step-1); +} + +.doc-browser__article h3 { + margin-block-start: var(--space-5); + font-size: 1.08rem; +} + +.doc-browser__article p, +.doc-browser__article li, +.doc-browser__article blockquote { + max-width: var(--measure-text); + text-wrap: pretty; +} + +.doc-browser__article a { + color: #0000ee; + font-weight: 700; + text-decoration: underline; +} + +.doc-browser__article a:visited { + color: #551a8b; +} + +.doc-browser__article pre { + max-width: 100%; + padding: var(--space-3); + overflow: auto; + color: #f5f5f5; + background: #101018; + box-shadow: var(--border-in); + font-size: 0.82rem; + line-height: 1.45; +} + +.doc-browser__article code { + padding: 0.05rem 0.2rem; + background: #f1f1df; +} + +.doc-browser__article pre code { + padding: 0; + background: transparent; +} + +.doc-browser__article blockquote { + margin-inline: 0; + padding: var(--space-2) var(--space-3); + border-inline-start: 0.35rem solid var(--win-title); + background: #efefdf; +} + +.doc-browser__article table { + display: block; + max-width: 100%; + overflow: auto; + border-collapse: collapse; + font-size: var(--step--1); +} + +.doc-browser__article th, +.doc-browser__article td { + padding: var(--space-2); + border: 1px solid var(--win-shadow); + vertical-align: top; +} + +.doc-browser__article th { + background: var(--web-yellow); +} + +.doc-browser__pager { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + margin-block-start: var(--space-4); +} + +.doc-browser__pager-link, +.doc-browser__pager-link:visited { + display: grid; + gap: var(--space-1); + min-height: 4rem; + padding: var(--space-3); + color: var(--text); + background: var(--surface); + box-shadow: var(--border-out); + text-decoration: none; + transition-property: background-color, transform; + transition-duration: 120ms; + transition-timing-function: ease-out; +} + +.doc-browser__pager-link:hover { + color: var(--text); + background: var(--surface-raised); + transform: translate(-1px, -1px); +} + +.doc-browser__pager-link:active { + transform: scale(0.96); +} + +.doc-browser__pager-link span { + font-size: var(--step--1); + text-transform: uppercase; +} + +.doc-browser__pager-link strong { + overflow-wrap: anywhere; +} + +.doc-browser__pager-link--next { + text-align: right; +} + +.doc-browser__pager-link--disabled, +.doc-browser__pager-link--disabled:hover { + color: var(--win-shadow); + background: var(--surface); + transform: none; +} + +.doc-browser__footer { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-3); + font-size: var(--step--1); + box-shadow: inset 0 1px 0 var(--win-light); +} + +.doc-browser__footer span:first-child { + font-weight: 700; +} + +@media (max-width: 55rem) { + .doc-browser__workspace { + grid-template-columns: 1fr; + grid-template-rows: minmax(9rem, 28vh) minmax(0, 1fr); + } + + .doc-browser__bookmarks { + box-shadow: inset 0 -1px 0 var(--win-shadow), inset 0 1px 0 var(--win-light); + } + + .doc-browser__bookmark-list { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + max-height: none; + overflow: auto; + } + + .doc-browser__pager { + grid-template-columns: 1fr; + } + + .doc-browser__pager-link--next { + text-align: left; + } +} + +@media (max-width: 38rem) { + .doc-viewer { + padding: var(--space-2); + } + + .doc-browser__toolbar { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .doc-browser__tool { + min-width: 0; + width: 100%; + } + + .doc-browser__location { + grid-template-columns: 1fr; + } + + .doc-browser__document { + padding: var(--space-2); + } +} diff --git a/assets/css/components/shell.css b/assets/css/components/shell.css index 0731604..61a39e8 100644 --- a/assets/css/components/shell.css +++ b/assets/css/components/shell.css @@ -458,7 +458,8 @@ inset 0 1px 0 var(--win-light); } -.taskbar__start { +.taskbar__start, +.taskbar__start:visited { display: inline-flex; place-items: center; gap: var(--space-1); @@ -470,6 +471,13 @@ padding-inline: var(--space-3); line-height: 1; font-weight: 700; + color: var(--text); + text-decoration: none; +} + +.taskbar__start:hover { + color: var(--text); + background: var(--surface-raised); } .taskbar__start img { diff --git a/docs/tasks/17-doc-viewer.md b/docs/tasks/17-doc-viewer.md index e8b119f..1cfae0d 100644 --- a/docs/tasks/17-doc-viewer.md +++ b/docs/tasks/17-doc-viewer.md @@ -20,18 +20,18 @@ Netscape Navigator-inspired shell and a web 1.0 design (with a sidebar & search) - [x] T17-04: Add server-side Markdown rendering for trusted local reference docs with `MDEx` - [x] T17-05: Add relative-link rewriting for links between known reference docs. -- [ ] T17-06: Add `TempestWeb.DocController` with `index` and `show` actions. -- [ ] T17-07: Add public routes `GET /docs` and `GET /docs/:slug` under the +- [x] T17-06: Add `TempestWeb.DocController` with `index` and `show` actions. +- [x] T17-07: Add public routes `GET /docs` and `GET /docs/:slug` under the browser pipeline. -- [ ] T17-08: Add `TempestWeb.DocHTML` templates for the doc viewer. -- [ ] T17-09: Build the Netscape-style chrome: title bar, toolbar buttons, +- [x] T17-08: Add `TempestWeb.DocHTML` templates for the doc viewer. +- [x] T17-09: Build the Netscape-style chrome: title bar, toolbar buttons, location bar, bookmarks pane, document pane, and footer. -- [ ] T17-10: Add responsive CSS through the existing vanilla CSS structure +- [x] T17-10: Add responsive CSS through the existing vanilla CSS structure (`assets/css/app.css` plus component files such as - `assets/css/components/docs.css`). -- [ ] T17-11: Add accessible focus, contrast, heading, and navigation behavior. -- [ ] T17-12: Add previous/next document links based on manifest order. -- [ ] T17-13: Link the docs viewer from the home page and any relevant public + `assets/css/components/doc-viewer.css`). +- [x] T17-11: Add accessible focus, contrast, heading, and navigation behavior. +- [x] T17-12: Add previous/next document links based on manifest order. +- [x] T17-13: Link the docs viewer from the home page and any relevant public navigation. - [ ] T17-14: Add ConnCase tests for `/docs`, `/docs/architecture`, unknown slugs, sidebar navigation, relative-link rewriting, and path traversal rejection. diff --git a/lib/tempest/docs.ex b/lib/tempest/docs.ex index 8df373a..c487158 100644 --- a/lib/tempest/docs.ex +++ b/lib/tempest/docs.ex @@ -98,6 +98,21 @@ defmodule Tempest.Docs do def document_path("reference"), do: "/docs" def document_path(slug) when is_binary(slug), do: "/docs/" <> slug + @doc "Returns previous and next manifest documents for a known slug." + @spec adjacent_documents(String.t()) :: {document() | nil, document() | nil} + def adjacent_documents(slug) when is_binary(slug) do + documents = list_documents() + index = Enum.find_index(documents, &(&1.slug == slug)) + + if index do + previous_document = if index > 0, do: Enum.at(documents, index - 1) + + {previous_document, Enum.at(documents, index + 1)} + else + {nil, nil} + end + end + defp lookup_manifest(slug) do if valid_slug?(slug) do case Enum.find(@documents, &(&1.slug == slug)) do diff --git a/lib/tempest_web/components/layouts.ex b/lib/tempest_web/components/layouts.ex index 59d911c..0ebbc20 100644 --- a/lib/tempest_web/components/layouts.ex +++ b/lib/tempest_web/components/layouts.ex @@ -25,6 +25,7 @@ defmodule TempestWeb.Layouts do