diff --git a/render.go b/render.go index cea4ab6..602001d 100644 --- a/render.go +++ b/render.go @@ -111,6 +111,154 @@ func extractNodeText(n ast.Node, source []byte) string { return strings.TrimSpace(buf.String()) } +const themeStorageKey = "gander-theme" + +type themeToken struct { + Name string + Light string + Dark string +} + +var themeTokenTable = []themeToken{ + {"--gander-fg", "#24292e", "#e6edf3"}, + {"--gander-fg-emphasis", "#1f2328", "#e6edf3"}, + {"--gander-bg", "#ffffff", "#0d1117"}, + {"--gander-canvas", "#fafbfc", "#0d1117"}, + {"--gander-canvas-inset", "#ffffff", "#161b22"}, + {"--gander-muted", "#6a737d", "#8b949e"}, + {"--gander-subtle", "#586069", "#8b949e"}, + {"--gander-control-fg", "#57606a", "#e6edf3"}, + {"--gander-border", "#eaecef", "#30363d"}, + {"--gander-border-muted", "#e1e4e8", "#30363d"}, + {"--gander-border-control", "#d0d7de", "#30363d"}, + {"--gander-border-input", "#d1d5da", "#30363d"}, + {"--gander-blockquote-border", "#dfe2e5", "#3b434b"}, + {"--gander-table-border", "#dfe2e8", "#30363d"}, + {"--gander-link", "#0366d6", "#58a6ff"}, + {"--gander-link-fill", "#0366d6", "#1f6feb"}, + {"--gander-link-hover-bg", "#f0f3f6", "#21262d"}, + {"--gander-link-active-bg", "#e8f0fb", "rgba(56, 139, 253, 0.15)"}, + {"--gander-link-ring", "rgba(3,102,214,0.2)", "rgba(88,166,255,0.35)"}, + {"--gander-accent", "#0969da", "#58a6ff"}, + {"--gander-primary", "#1f6feb", "#1f6feb"}, + {"--gander-on-primary", "#fff", "#fff"}, + {"--gander-primary-hover", "#0258b8", "#388bfd"}, + {"--gander-control-hover-bg", "#f6f8fa", "#21262d"}, + {"--gander-primary-shadow", "rgba(31,111,235,0.28)", "rgba(31,111,235,0.4)"}, + {"--gander-code-bg", "rgba(27,31,35,0.05)", "rgba(110,118,129,0.4)"}, + {"--gander-code-bg-dash", "rgba(27,31,35,0.06)", "rgba(110,118,129,0.4)"}, + {"--gander-pre-bg", "#f6f8fa", "#161b22"}, + {"--gander-logo", "#1b283f", "#e6edf3"}, + {"--gander-success-fg", "#28a745", "#3fb950"}, + {"--gander-success-bg", "#28a745", "#238636"}, + {"--gander-success-hover", "#269c44", "#2ea043"}, + {"--gander-on-success", "#fff", "#fff"}, + {"--gander-danger", "#cb2431", "#f85149"}, + {"--gander-danger-emphasis", "#cf222e", "#ff7b72"}, + {"--gander-danger-bg", "#ffeef0", "rgba(248,81,73,0.15)"}, + {"--gander-danger-border", "#fdb8c0", "rgba(248,81,73,0.4)"}, + {"--gander-highlight", "#fde68a", "rgba(210, 153, 34, 0.45)"}, + {"--gander-highlight-active", "#fbbf24", "rgba(210, 153, 34, 0.7)"}, + {"--gander-added-bg", "#d4f4dd", "rgba(46, 160, 78, 0.22)"}, + {"--gander-added-accent", "#2ea04e", "#3fb950"}, + {"--gander-removed-bg", "#fde2e2", "rgba(248, 81, 73, 0.22)"}, + {"--gander-removed-accent", "#f85149", "#f85149"}, + {"--gander-removed-decoration", "rgba(248, 81, 73, 0.6)", "rgba(248, 81, 73, 0.75)"}, + {"--gander-overlay", "rgba(27,31,36,0.4)", "rgba(1,4,9,0.75)"}, + {"--gander-shadow", "rgba(27,31,35,0.08)", "rgba(1,4,9,0.45)"}, + {"--gander-card-shadow", "rgba(27,31,36,0.04)", "rgba(1,4,9,0.4)"}, +} + +func themeTokensCSS() string { + var light, dark strings.Builder + for _, tok := range themeTokenTable { + fmt.Fprintf(&light, "\t%s: %s;\n", tok.Name, tok.Light) + fmt.Fprintf(&dark, "\t%s: %s;\n", tok.Name, tok.Dark) + } + return `:root, html[data-theme="light"] { + color-scheme: light; +` + light.String() + `} +html[data-theme="dark"] { + color-scheme: dark; +` + dark.String() + `} +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + color-scheme: dark; +` + dark.String() + ` } +} +` +} + +const themeBootScript = `(function(){ + var KEY = 'gander-theme'; + var t = null; + try { t = localStorage.getItem(KEY); } catch (e) {} + if (t !== 'light' && t !== 'dark') { + t = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) + ? 'dark' : 'light'; + } + document.documentElement.setAttribute('data-theme', t); +})();` + +const themeToggleScript = `(function(){ + var KEY = 'gander-theme'; + function current() { + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; + } + function sync(btn) { + btn.setAttribute('aria-pressed', current() === 'dark' ? 'true' : 'false'); + } + function apply(theme, persist) { + document.documentElement.setAttribute('data-theme', theme); + if (persist) { + try { localStorage.setItem(KEY, theme); } catch (e) {} + } + document.querySelectorAll('.gander-theme-toggle').forEach(sync); + if (typeof window.ganderRenderMermaid === 'function') { + window.ganderRenderMermaid(); + } + } + document.querySelectorAll('.gander-theme-toggle').forEach(function(btn) { + sync(btn); + btn.addEventListener('click', function() { + apply(current() === 'dark' ? 'light' : 'dark', true); + }); + }); + var mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)'); + function onScheme(e) { + try { if (localStorage.getItem(KEY)) return; } catch (err) {} + apply(e.matches ? 'dark' : 'light', false); + } + if (mq) { + if (mq.addEventListener) mq.addEventListener('change', onScheme); + else if (mq.addListener) mq.addListener(onScheme); + } +})();` + +const themeSunSVG = `` + +const themeMoonSVG = `` + +func themeToggleButton(extraClass string) string { + class := "gander-theme-toggle" + if extraClass != "" { + class += " " + extraClass + } + return `` +} + +func themeHead(pageCSS string) string { + return ` + +` +} + const cssStyle = ` * { box-sizing: border-box; } html { scroll-behavior: smooth; } @@ -118,8 +266,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.6; - color: #24292e; - background-color: #ffffff; + color: var(--gander-fg); + background-color: var(--gander-bg); margin: 0; padding: 0; } @@ -135,22 +283,22 @@ body { height: 100vh; overflow-y: auto; padding: 2rem 1rem 2rem 1.5rem; - border-right: 1px solid #eaecef; - background: #fafbfc; + border-right: 1px solid var(--gander-border); + background: var(--gander-canvas); } .gander-toc-title { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: #6a737d; + color: var(--gander-muted); margin: 0 0 0.75rem 0; } .gander-toc a.gander-toc-logo { display: inline-flex; align-items: center; gap: 0.5rem; - color: #1b283f; + color: var(--gander-logo); text-decoration: none; margin: 0 0 1.25rem 0; } @@ -171,20 +319,20 @@ body { .gander-toc li.h3 { padding-left: 1.5em; font-size: 0.9em; } .gander-toc li.h4 { padding-left: 2.25em; font-size: 0.85em; } .gander-toc a { - color: #586069; + color: var(--gander-subtle); text-decoration: none; display: block; padding: 0.15em 0.25em; border-radius: 3px; } .gander-toc a:hover { - color: #0366d6; - background: #f0f3f6; + color: var(--gander-link); + background: var(--gander-link-hover-bg); } .gander-toc a.active { - color: #0366d6; + color: var(--gander-link); font-weight: 500; - background: #e8f0fb; + background: var(--gander-link-active-bg); } .gander-content { max-width: 900px; @@ -197,45 +345,45 @@ h1, h2, h3, h4, h5, h6 { line-height: 1.25; scroll-margin-top: 1rem; } -h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; } -h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; } -a { color: #0366d6; text-decoration: none; } +h1 { font-size: 2em; border-bottom: 1px solid var(--gander-border); padding-bottom: 0.3em; } +h2 { font-size: 1.5em; border-bottom: 1px solid var(--gander-border); padding-bottom: 0.3em; } +a { color: var(--gander-link); text-decoration: none; } a:hover { text-decoration: underline; } code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 85%; - background-color: rgba(27,31,35,0.05); + background-color: var(--gander-code-bg); padding: 0.2em 0.4em; border-radius: 3px; } pre { - background-color: #f6f8fa; - border: 1px solid #e1e4e8; + background-color: var(--gander-pre-bg); + border: 1px solid var(--gander-border-muted); border-radius: 6px; padding: 16px; overflow: auto; line-height: 1.45; } pre code { background-color: transparent; padding: 0; } -blockquote { margin: 0; padding: 0 1em; color: #6a737d; border-left: 0.25em solid #dfe2e5; } +blockquote { margin: 0; padding: 0 1em; color: var(--gander-muted); border-left: 0.25em solid var(--gander-blockquote-border); } table { border-collapse: collapse; width: 100%; margin: 1em 0; } -table th, table td { border: 1px solid #dfe2e8; padding: 6px 13px; } -table tr:nth-child(2n) { background-color: #f6f8fa; } +table th, table td { border: 1px solid var(--gander-table-border); padding: 6px 13px; } +table tr:nth-child(2n) { background-color: var(--gander-pre-bg); } .mermaid { text-align: center; margin: 1em 0; overflow-x: auto; } -hr { border: 0; border-top: 1px solid #eaecef; margin: 1.5em 0; } +hr { border: 0; border-top: 1px solid var(--gander-border); margin: 1.5em 0; } img { max-width: 100%; } ul, ol { padding-left: 2em; } li + li { margin-top: 0.25em; } .gander-md-cta { - color: #586069; + color: var(--gander-subtle); font-size: 0.85em; text-align: center; padding: 1.5rem 3rem 2rem; - border-top: 1px solid #eaecef; + border-top: 1px solid var(--gander-border); margin-top: 3rem; } -.gander-md-cta a { color: #0366d6; font-weight: 500; } +.gander-md-cta a { color: var(--gander-link); font-weight: 500; } .gander-md-viewer-logo { display: flex; justify-content: center; @@ -243,6 +391,76 @@ li + li { margin-top: 0.25em; } } .gander-md-viewer-logo svg { display: block; } +.gander-md-meta { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0 0 1.5rem 0; + padding-bottom: 1rem; + border-bottom: 1px solid var(--gander-border); +} +.gander-md-meta--chrome-only { + justify-content: flex-end; + border-bottom: none; + padding-bottom: 0; + margin-bottom: 1rem; +} +.gander-md-chrome { + display: flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; + margin-left: auto; +} +.gander-theme-toggle { + position: relative; + width: 52px; + height: 28px; + padding: 0; + border: 1px solid var(--gander-border-control); + border-radius: 999px; + background: var(--gander-control-hover-bg); + color: var(--gander-muted); + cursor: pointer; + flex-shrink: 0; +} +.gander-theme-toggle:focus-visible { + outline: 2px solid var(--gander-link); + outline-offset: 2px; +} +.gander-theme-toggle-sun, +.gander-theme-toggle-moon { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + transform: translateY(-50%); + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; +} +.gander-theme-toggle-sun { left: 7px; } +.gander-theme-toggle-moon { right: 7px; } +.gander-theme-toggle svg { display: block; } +.gander-theme-toggle-knob { + position: absolute; + top: 3px; + left: 3px; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--gander-on-primary); + box-shadow: 0 1px 2px var(--gander-shadow); + transition: transform 0.18s ease; + pointer-events: none; +} +html[data-theme="dark"] .gander-theme-toggle-knob { + transform: translateX(24px); +} + @media (min-width: 950px) { .gander-layout:not(.gander-layout--no-toc) { grid-template-columns: 250px 1fr; @@ -332,6 +550,10 @@ const tocScript = ` const mermaidInitScript = ` (function() { + var inflight = Promise.resolve(); + function mermaidTheme() { + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default'; + } function transform() { var scope = document.getElementById('content-body') || document.body; var blocks = scope.querySelectorAll('pre > code.language-mermaid'); @@ -340,33 +562,40 @@ const mermaidInitScript = ` if (!pre || pre.parentNode == null) return; var div = document.createElement('div'); div.className = 'mermaid'; - div.textContent = code.textContent; + var src = code.textContent; + div.setAttribute('data-gander-mermaid', src); + div.textContent = src; pre.parentNode.replaceChild(div, pre); }); } - - function render() { - if (!window.mermaid) return; - var nodes = document.querySelectorAll('.mermaid'); - if (nodes.length === 0) return; - try { - window.mermaid.run({ nodes: nodes }); - } catch (err) { - console.error('gander: mermaid render failed', err); - } + function resetProcessed() { + document.querySelectorAll('.mermaid').forEach(function(n) { + var src = n.getAttribute('data-gander-mermaid'); + if (!src) return; + n.removeAttribute('data-processed'); + n.textContent = src; + }); } - function run() { transform(); - render(); + inflight = inflight.catch(function() {}).then(function() { + if (!window.mermaid) return; + resetProcessed(); + window.mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() }); + var nodes = document.querySelectorAll('.mermaid'); + if (nodes.length === 0) return; + return window.mermaid.run({ nodes: nodes }); + }).catch(function(err) { + console.error('gander: mermaid render failed', err); + }); + return inflight; } - window.ganderRenderMermaid = run; run(); })(); ` -const viewerLogoSVG = `` +const viewerLogoSVG = `` const reloadScript = ` (function() { @@ -413,6 +642,7 @@ func buildHTML(content string, headings []Heading, withLiveReload bool) string { script = tocScript + reloadScript } + toolbarHTML := `
` ctaHTML := fmt.Sprintf(`