diff --git a/docs/templates/public/main.css b/docs/templates/public/main.css
index adabd17..b9baa8d 100644
--- a/docs/templates/public/main.css
+++ b/docs/templates/public/main.css
@@ -25,3 +25,248 @@
margin-top: 0.25rem;
font-size: 0.95rem;
}
+
+.skip-link {
+ position: fixed;
+ top: 0.5rem;
+ left: 0.5rem;
+ z-index: 1100;
+ padding: 0.65rem 1rem;
+ border-radius: 0.375rem;
+ background: var(--bs-body-bg);
+ color: var(--bs-link-color);
+ font-weight: 600;
+ box-shadow: 0 0 0 3px var(--bs-focus-ring-color);
+ transform: translateY(-150%);
+ transition: transform 120ms ease-in-out;
+}
+
+.skip-link:focus {
+ transform: translateY(0);
+}
+
+.home-hero {
+ margin: 0.75rem 0 2rem;
+ padding: clamp(1.25rem, 4vw, 2.5rem);
+ border: 1px solid var(--bs-border-color);
+ border-radius: 1rem;
+ background: linear-gradient(135deg, rgba(var(--bs-primary-rgb), 0.12), transparent 65%);
+}
+
+.home-kicker {
+ margin-bottom: 0.5rem;
+ color: var(--bs-primary-text-emphasis);
+ font-size: 0.875rem;
+ font-weight: 700;
+ letter-spacing: 0.045em;
+ text-transform: uppercase;
+}
+
+.home-summary {
+ max-width: 52rem;
+ margin-bottom: 1.25rem;
+ font-size: clamp(1.1rem, 2.5vw, 1.4rem);
+ line-height: 1.55;
+}
+
+.home-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.home-actions .btn {
+ min-width: 9rem;
+ font-weight: 600;
+}
+
+.home-principle {
+ max-width: 54rem;
+ margin: 1.25rem 0 0;
+ padding-top: 1rem;
+ border-top: 1px solid var(--bs-border-color);
+ font-weight: 600;
+}
+
+.entry-point-grid,
+.learning-path-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ margin: 1rem 0 2rem;
+}
+
+.entry-point {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.75rem;
+ color: var(--bs-body-color);
+ text-decoration: none;
+ transition: border-color 120ms ease-in-out, box-shadow 120ms ease-in-out, transform 120ms ease-in-out;
+}
+
+.entry-point:hover,
+.entry-point:focus-visible {
+ border-color: var(--bs-primary);
+ box-shadow: 0 0.3rem 0.9rem rgba(var(--bs-primary-rgb), 0.12);
+ color: var(--bs-body-color);
+ transform: translateY(-2px);
+}
+
+.entry-point strong {
+ color: var(--bs-link-color);
+}
+
+.entry-point span {
+ color: var(--bs-secondary-color);
+}
+
+.process-flow {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1.5rem 1rem;
+ margin: 1.25rem 0 2rem;
+ padding: 0;
+ list-style-position: inside;
+}
+
+.process-flow li {
+ position: relative;
+ min-height: 5rem;
+ padding: 1.65rem 0.75rem 1rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.75rem;
+ background: var(--bs-tertiary-bg);
+ font-weight: 600;
+ text-align: center;
+}
+
+.process-flow li::marker {
+ color: var(--bs-primary);
+ font-weight: 700;
+}
+
+.process-flow li span {
+ display: block;
+ margin-left: 0.3rem;
+ color: var(--bs-secondary-color);
+ font-size: 0.8rem;
+ font-weight: 400;
+}
+
+.process-flow-short {
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+}
+
+.learning-path-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.learning-path-card {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ padding: 1.25rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.75rem;
+ background: var(--bs-body-bg);
+}
+
+.learning-path-card h3 {
+ margin: 0.35rem 0 0.65rem;
+ font-size: 1.2rem;
+}
+
+.learning-path-card p {
+ flex: 1;
+ color: var(--bs-secondary-color);
+}
+
+.learning-path-card > a {
+ align-self: flex-start;
+ font-weight: 600;
+}
+
+.path-level {
+ color: var(--bs-primary-text-emphasis);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.035em;
+ text-transform: uppercase;
+}
+
+.home-details {
+ margin: 2rem 0;
+ padding: 1rem 1.25rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.75rem;
+ background: var(--bs-tertiary-bg);
+}
+
+.home-details summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.home-details[open] summary {
+ margin-bottom: 1rem;
+}
+
+@media (max-width: 991.98px) {
+ .learning-path-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .process-flow,
+ .process-flow-short {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .process-flow li:nth-child(4)::after {
+ right: -1.15rem;
+ bottom: auto;
+ transform: none;
+ }
+
+ .process-flow li:nth-child(even):not(:last-child)::after {
+ right: 50%;
+ bottom: -1.45rem;
+ transform: translateX(50%) rotate(90deg);
+ }
+}
+
+@media (max-width: 575.98px) {
+ .home-hero {
+ margin-top: 0.25rem;
+ padding: 1.1rem;
+ }
+
+ .home-actions .btn {
+ width: 100%;
+ }
+
+ .entry-point-grid,
+ .learning-path-grid,
+ .process-flow,
+ .process-flow-short {
+ grid-template-columns: 1fr;
+ }
+
+ .process-flow li:not(:last-child)::after,
+ .process-flow li:nth-child(4)::after,
+ .process-flow-short li:nth-child(4)::after {
+ right: 50%;
+ bottom: -1.45rem;
+ transform: translateX(50%) rotate(90deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skip-link,
+ .entry-point {
+ transition: none;
+ }
+}
diff --git a/docs/templates/public/main.js b/docs/templates/public/main.js
new file mode 100644
index 0000000..cba6d7f
--- /dev/null
+++ b/docs/templates/public/main.js
@@ -0,0 +1,110 @@
+// Keep DocFX search available while avoiding a multi-megabyte index download on
+// landing-page visits that never use it. DocFX calls start() before it creates
+// the search worker, which lets this lightweight proxy delay only that worker.
+
+export default {
+ start: function () {
+ var deferSearch = document.querySelector('meta[name="docfx:defersearch"][content="true"]')
+ var searchInput = document.getElementById('search-query')
+
+ if (!deferSearch || !searchInput || !window.Worker) {
+ return
+ }
+
+ var NativeWorker = window.Worker
+ document.documentElement.dataset.searchIndex = 'deferred'
+ searchInput.form.addEventListener('submit', function (event) {
+ event.preventDefault()
+ })
+
+ function DeferredWorker(url, options) {
+ var workerUrl = String(url)
+
+ if (!workerUrl.endsWith('/search-worker.min.js') && workerUrl !== 'public/search-worker.min.js') {
+ return new NativeWorker(url, options)
+ }
+
+ var worker = null
+ var messages = []
+ var proxy = this
+
+ function activate() {
+ if (worker) {
+ return
+ }
+
+ document.documentElement.dataset.searchIndex = 'loading'
+ worker = new NativeWorker(url, options)
+ worker.onerror = function (event) {
+ if (proxy.onerror) {
+ proxy.onerror(event)
+ }
+ }
+ worker.onmessageerror = function (event) {
+ if (proxy.onmessageerror) {
+ proxy.onmessageerror(event)
+ }
+ }
+ worker.onmessage = function (event) {
+ if (proxy.onmessage) {
+ proxy.onmessage(event)
+ }
+
+ if (event.data && event.data.e === 'index-ready') {
+ document.documentElement.dataset.searchIndex = 'ready'
+ }
+
+ if (event.data && event.data.e === 'index-ready' && searchInput.value) {
+ searchInput.dispatchEvent(new Event('input', { bubbles: true }))
+ }
+ }
+
+ messages.forEach(function (message) {
+ if (message.transfer === undefined) {
+ worker.postMessage(message.data)
+ } else {
+ worker.postMessage(message.data, message.transfer)
+ }
+ })
+ messages = []
+ }
+
+ this.postMessage = function (data, transfer) {
+ if (worker) {
+ if (transfer === undefined) {
+ worker.postMessage(data)
+ } else {
+ worker.postMessage(data, transfer)
+ }
+ return
+ }
+
+ messages.push({ data: data, transfer: transfer })
+ }
+
+ this.terminate = function () {
+ messages = []
+ if (worker) {
+ worker.terminate()
+ }
+ }
+
+ this.addEventListener = function (type, listener, eventOptions) {
+ activate()
+ worker.addEventListener(type, listener, eventOptions)
+ }
+
+ this.removeEventListener = function (type, listener, eventOptions) {
+ if (worker) {
+ worker.removeEventListener(type, listener, eventOptions)
+ }
+ }
+
+ searchInput.addEventListener('focus', activate, { once: true })
+ searchInput.addEventListener('input', activate, { once: true })
+ }
+
+ window.Worker = DeferredWorker
+ searchInput.disabled = false
+ }
+}
diff --git a/tools/validate-doc-metadata.cs b/tools/validate-doc-metadata.cs
index 6e9ea49..782772e 100644
--- a/tools/validate-doc-metadata.cs
+++ b/tools/validate-doc-metadata.cs
@@ -11,6 +11,8 @@
static class MetadataValidator
{
private const int MaximumDescriptionLength = 160;
+ private const string SiteDescription = "Practical .NET architecture tutorials, labs, and reference patterns for governed execution, secure applications, AI integration, and policy-driven systems.";
+ private const string LandingPageTitle = "Governed Execution & Secure .NET Architecture Tutorials | ASI Backbone Learning";
private static readonly Uri SiteRoot = new("https://asibackbone.github.io/Learning/");
private static readonly Uri FeedUri = new(SiteRoot, "feed.xml");
@@ -143,6 +145,8 @@ public static int Run()
ValidateRepresentativePage(outputRoot, page, errors);
}
+ ValidateLandingPage(outputRoot, errors);
+
if (pageCount == 0)
{
errors.Add("No generated DocFX pages were found for canonical validation.");
@@ -386,6 +390,13 @@ private static void ValidateRepresentativePage(
string html = File.ReadAllText(path);
ExpectCount(page.Path, "meta description", DescriptionRegex.Matches(html).Count, 1, errors);
+ if (!html.Contains(
+ "
Skip to content",
+ StringComparison.Ordinal))
+ {
+ errors.Add($"{page.Path}: expected a skip link targeting the main content.");
+ }
+
MatchCollection rssLinks = RssAutodiscoveryRegex.Matches(html);
ExpectCount(page.Path, "RSS autodiscovery link", rssLinks.Count, 1, errors);
if (rssLinks.Count == 1)
@@ -451,6 +462,58 @@ private static void ValidateRepresentativePage(
}
}
+ private static void ValidateLandingPage(string outputRoot, ICollection
errors)
+ {
+ const string relativePath = "index.html";
+ string path = Path.Combine(outputRoot, relativePath);
+
+ if (!File.Exists(path))
+ {
+ return;
+ }
+
+ string html = File.ReadAllText(path);
+
+ if (!html.Contains($"{LandingPageTitle}", StringComparison.Ordinal))
+ {
+ errors.Add($"{relativePath}: landing-page title is missing or does not use the descriptive SEO title.");
+ }
+
+ if (!html.Contains("", StringComparison.Ordinal))
+ {
+ errors.Add($"{relativePath}: search-index initialization must be deferred until search is used.");
+ }
+
+ if (html.Contains("", StringComparison.Ordinal);
+ int actionsIndex = html.IndexOf("= 0
+ ? html.IndexOf("
= 0 && actionsIndex > nextSectionIndex))
+ {
+ errors.Add($"{relativePath}: primary learning calls to action must appear directly below the landing-page heading.");
+ }
+
+ if (html.Contains("", StringComparison.Ordinal))
+ {
+ errors.Add($"{relativePath}: visual learning flows must not be exposed as text code blocks.");
+ }
+
+ string runtimeScriptPath = Path.Combine(outputRoot, "public", "main.js");
+ if (!File.Exists(runtimeScriptPath) ||
+ !File.ReadAllText(runtimeScriptPath).Contains("DeferredWorker", StringComparison.Ordinal))
+ {
+ errors.Add("public/main.js: deferred search runtime was not included in the generated site.");
+ }
+ }
+
private static void ValidateStructuredData(
ExpectedPage page,
JsonElement root,
@@ -483,6 +546,7 @@ private static void ValidateStructuredData(
"alternateName",
"Accountable Systems Infrastructure (ASI) Backbone Learning",
errors);
+ ExpectProperty(page.Path, website, "WebSite", "description", SiteDescription, errors);
ExpectProperty(page.Path, publisher, "Organization", "name", "ASI Backbone", errors);
ExpectProperty(page.Path, publisher, "Organization", "url", "https://github.com/AsiBackbone", errors);
diff --git a/tools/validate-docfx-template-baseline.cs b/tools/validate-docfx-template-baseline.cs
index a65d56e..f131d66 100644
--- a/tools/validate-docfx-template-baseline.cs
+++ b/tools/validate-docfx-template-baseline.cs
@@ -13,6 +13,7 @@ static class DocFxTemplateBaselineValidator
private const string BaselineRelativePath = "docs/templates/docfx-template-baseline.json";
private const string DocFxConfigRelativePath = "docs/docfx.json";
private const string CustomStylesheetRelativePath = "docs/templates/public/main.css";
+ private const string CustomRuntimeScriptRelativePath = "docs/templates/public/main.js";
private const string LabAuthoringTemplateRelativePath = "community/lab-acceptance-criteria-template.md";
private const string LocalTemplateDirectoryRelativePath = "docs/templates";
@@ -248,6 +249,14 @@ private static void ValidateLocalTemplateLayout(
$"The custom modern-template stylesheet must exist at '{CustomStylesheetRelativePath}'.");
}
+ if (!File.Exists(Path.Combine(
+ repositoryRoot,
+ CustomRuntimeScriptRelativePath.Replace('/', Path.DirectorySeparatorChar))))
+ {
+ errors.Add(
+ $"The custom modern-template runtime script must exist at '{CustomRuntimeScriptRelativePath}'.");
+ }
+
if (!File.Exists(Path.Combine(
repositoryRoot,
LabAuthoringTemplateRelativePath.Replace('/', Path.DirectorySeparatorChar))))