Add app-shell partial (todo §1); sidebar + topbar + content/nav slots, reuses mockup classes + icon sprite

This commit is contained in:
2026-06-15 11:51:44 +02:00
parent 265704a7eb
commit 672b831f8c
4 changed files with 138 additions and 2 deletions

View File

@@ -362,7 +362,7 @@ src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
views/ Core EJS templates (index, 403/404/500, partials/ incl. the icon sprite) views/ Core EJS templates (index, 403/404/500, partials/ incl. the app shell + icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (planned) config/menu.ts Central menu override + branding (planned)
plugins/ Drop-in plugin folders, auto-discovered (planned) plugins/ Drop-in plugin folders, auto-discovered (planned)

44
src/shell.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "shell.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
test("app shell renders sidebar, topbar and the content slot", async () => {
const html = await render({
title: "People",
brand: { name: "Acme Console", sub: "v2" },
nav: '<a id="nav-marker" href="/x">Overview</a>',
body: '<section id="body-marker">page</section>',
actions: '<button id="action-marker">Add</button>',
});
// Three structural landmarks of the shell.
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /<header class="topbar"/);
assert.match(html, /<main class="content"/);
// Slots render their raw HTML where the page injects it.
assert.match(html, /<a id="nav-marker"/); // sidebar nav slot
assert.match(html, /<section id="body-marker">page<\/section>/); // content slot
assert.match(html, /<button id="action-marker"/); // topbar actions slot
// Branding, document title, and the inlined icon sprite (so <use> resolves).
assert.match(html, /Acme Console/);
assert.match(html, /<title>People<\/title>/);
assert.match(html, /<symbol id="i-menu"/);
assert.match(html, /<use href="#i-menu"\s*\/?>/); // hamburger references the menu icon
});
test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => {
const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped
assert.match(escaped, /<p>raw<\/p>/); // slot HTML is not
const bare = await render(); // no locals → defaults, must not throw
assert.match(bare, /<aside class="sidebar"/);
assert.match(bare, /<main class="content"/);
});

View File

@@ -27,7 +27,7 @@ everything via Docker.
## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data) ## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data)
- [x] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`. → `git mv` from `html-css-foundation/` into `public/css/`; dropped the placeholder `style.css`; views + tests now reference `styles.css`; foundation mockups repointed to `../public/css/`. - [x] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`. → `git mv` from `html-css-foundation/` into `public/css/`; dropped the placeholder `style.css`; views + tests now reference `styles.css`; foundation mockups repointed to `../public/css/`.
- [x] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used. → `src/icons.ts` (id→lucide map + `buildIconSprite`) generates a hidden `<symbol>` sprite of the 31 icons the mockups reference, paths sourced from pinned lucide-static; `icons.test.ts` guards provenance + only-used. Stale image rebuilt (lucide-static was missing). Wiring into the app shell is the next item. - [x] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used. → `src/icons.ts` (id→lucide map + `buildIconSprite`) generates a hidden `<symbol>` sprite of the 31 icons the mockups reference, paths sourced from pinned lucide-static; `icons.test.ts` guards provenance + only-used. Stale image rebuilt (lucide-static was missing). Wiring into the app shell is the next item.
- [ ] App-shell partial (sidebar + topbar + content slot). - [x] App-shell partial (sidebar + topbar + content slot).`views/partials/shell.ejs`: full document wrapping `.app` → sidebar (brand + `nav` slot + theme/profile footer) · `.scrim` · `.content` (`.topbar` + `body` slot); reuses the mockup's classes (styled by `styles.css`), inlines the icon sprite. Slots `nav`/`actions`/`body` are HTML locals, `title`/`brand`/`user`/`breadcrumbs` text; defaults render standalone. `shell.test.ts` covers landmarks, slots, escaping, defaults. Not yet routed (that's "replace placeholder index").
- [ ] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`. - [ ] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`.
- [ ] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills). - [ ] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills).
- [ ] Data-table partial — sortable headers, row-select, badges, kebab row actions. - [ ] Data-table partial — sortable headers, row-select, badges, kebab row actions.

92
views/partials/shell.ejs Normal file
View File

@@ -0,0 +1,92 @@
<%#
App shell: sidebar (brand + nav slot + footer) · topbar · content slot.
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
`actions` (topbar buttons), `body` (page content). Text locals: `title`, `brand`,
`user`, `breadcrumbs`. Real `user`/branding arrive with §4 auth / §2 menu config.
%><%
const title = locals.title || "Plainpages";
const brand = locals.brand || { name: "Plainpages" };
const user = locals.user || { name: "Guest", initials: "G", email: "" };
const breadcrumbs = locals.breadcrumbs || [];
const nav = locals.nav || "";
const actions = locals.actions || "";
const body = locals.body || "";
%><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
<link rel="icon" href="/public/favicon.svg" />
</head>
<body>
<%- include("icons") %>
<!-- nav-toggle drives the mobile overlay (pure CSS) -->
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" />
<div class="app">
<aside class="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
<span class="brand-name"><%= brand.name %></span>
<% if (brand.sub) { %><span class="brand-sub"><%= brand.sub %></span><% } %>
</div>
<nav class="nav" aria-label="Main navigation"><%- nav %></nav>
<div class="side-footer">
<div class="theme-switch" role="radiogroup" aria-label="Color theme">
<label><input type="radio" name="theme" id="theme-light" /><span>Light</span></label>
<label><input type="radio" name="theme" id="theme-auto" checked /><span>Auto</span></label>
<label><input type="radio" name="theme" id="theme-dark" /><span>Dark</span></label>
</div>
<div class="footer-actions">
<details class="menu" style="flex:1 1 auto">
<summary class="profile">
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
<span class="profile-meta">
<span class="profile-name"><%= user.name %></span>
<% if (user.email) { %><span class="profile-mail"><%= user.email %></span><% } %>
</span>
</summary>
<div class="menu-pop left" style="bottom:calc(100% + 6px); top:auto; min-width:220px">
<div class="menu-head">Signed in as <%= user.name %></div>
<button class="menu-item"><svg class="ico"><use href="#i-user" /></svg>Profile</button>
<button class="menu-item danger"><svg class="ico"><use href="#i-logout" /></svg>Sign out</button>
</div>
</details>
<details class="menu">
<summary class="btn icon-btn" aria-label="Settings"><svg class="ico"><use href="#i-gear" /></svg></summary>
<div class="menu-pop" style="bottom:calc(100% + 6px); top:auto">
<div class="menu-head">Settings</div>
<button class="menu-item"><svg class="ico"><use href="#i-gear" /></svg>Preferences</button>
</div>
</details>
</div>
</div>
</aside>
<!-- scrim closes the mobile menu (label toggles the checkbox) -->
<label class="scrim" for="nav-toggle" aria-label="Close menu"></label>
<main class="content">
<header class="topbar">
<label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu"><svg class="ico"><use href="#i-menu" /></svg></label>
<div><div class="page-title"><%= title %></div></div>
<% if (breadcrumbs.length) { %>
<nav class="crumbs" aria-label="Breadcrumb">
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>
</nav>
<% } %>
<div class="topbar-spacer"></div>
<%- actions %>
</header>
<%- body %>
</main>
</div>
</body>
</html>