Add STCourses design, research report and approved mockup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucasw89andClaude Opus 5.5 committed 2026-10-30 02:37:38 +01:00
commit ab96addbab
8 files changed
+2166

No files matched your search

+959
View File
@@ -0,0 +1,959 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab Academy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;1,7..72,400&display=swap" rel="stylesheet">
<style>
:root{
--ground:#12141a; --side:#181b22; --raised:#232733; --line:#262a33; --line2:#343947;
--text:#d9dce3; --read:#d9d4c8; --head:#f2efe8; --muted:#a4abb8; --faint:#8c93a0;
--accent:#f1b44c; --accent-dim:#8c7440; --on-accent:#12141a;
--ok:#86c79f; --bad:#ee8f8f;
--sans:"IBM Plex Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
--serif:"Literata",Georgia,"Times New Roman",serif;
--mono:"IBM Plex Mono",ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
--top:52px; --sidew:296px; --fast:140ms;
color-scheme:dark;
}
/* a course may set its own accent through its manifest; nothing else is themed */
body[data-course="docker"]{--accent:#6ea8fe; --accent-dim:#3f5f8e}
body[data-look="reading"]{--pf:var(--serif); --ps:19px; --pl:1.75; --pc:var(--read); --col:660px; --wide:900px; --tf:var(--serif); --h1:40px}
body[data-look="work"]{--pf:var(--sans); --ps:15.5px; --pl:1.6; --pc:var(--text); --col:760px; --wide:1180px; --tf:var(--sans); --h1:28px}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0; background:var(--ground); color:var(--text); font:400 14px/1.5 var(--sans); -webkit-font-smoothing:antialiased}
a{color:inherit}
button,input,textarea{font:inherit; color:inherit}
button{cursor:pointer}
[hidden]{display:none !important}
.tnum{font-variant-numeric:tabular-nums}
:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
::selection{background:color-mix(in srgb,var(--accent) 35%,transparent)}
kbd{font:500 11px/1 var(--mono); display:inline-grid; place-items:center; min-width:18px; height:18px; padding:0 4px; border:1px solid color-mix(in srgb,currentColor 38%,transparent); border-radius:4px}
code,pre{font-family:var(--mono)}
.sr{position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap}
/* reviewer strip: not product UI */
.review{display:flex; align-items:center; gap:4px; padding:0 16px; height:34px; background:#0d0f13; border-bottom:1px dashed var(--line2); font-size:12px; color:var(--faint); overflow-x:auto; white-space:nowrap}
.review b{font-weight:500; color:var(--muted); margin-right:8px}
.review a{text-decoration:none; padding:3px 10px; border-radius:14px; color:var(--muted)}
.review a:hover{color:var(--text)}
.review a[aria-current]{background:var(--raised); color:var(--head)}
.review .keys{margin-left:auto; padding-left:16px; display:flex; gap:6px; align-items:center}
/* header */
.top{position:sticky; top:0; z-index:30; height:var(--top); display:flex; align-items:center; gap:14px; padding:0 16px; background:var(--ground); border-bottom:1px solid var(--line)}
.org{flex:0 0 calc(var(--sidew) - 30px); font-weight:600; color:var(--head); text-decoration:none; font-size:14px; letter-spacing:.005em}
.icon-btn{display:none; width:34px; height:34px; border-radius:18px; border:1px solid var(--line2); background:none; place-items:center; color:var(--muted)}
.switch{position:relative}
.switch-btn{display:flex; align-items:center; gap:10px; height:34px; padding:0 12px 0 5px; border:1px solid var(--line2); border-radius:18px; background:none; font-weight:500; font-size:13px; max-width:300px}
.switch-btn:hover,.btn:hover,.icon-btn:hover{background:var(--raised)}
.switch-btn .t{overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
.switch-btn svg{color:var(--faint); flex:none}
.menu{position:absolute; top:42px; left:0; width:330px; margin:0; padding:6px; list-style:none; background:var(--side); border:1px solid var(--line2); border-radius:14px}
.menu a{display:flex; gap:12px; align-items:center; padding:8px 10px; border-radius:10px; text-decoration:none}
.menu a:hover,.menu a:focus-visible{background:var(--raised)}
.menu a[aria-current] .mt{color:var(--head)}
.menu .mt{font-weight:500; font-size:13.5px}
.menu .ms{display:block; font-size:12px; color:var(--faint)}
.search{flex:1; max-width:380px; display:flex; align-items:center; gap:8px; height:34px; padding:0 8px 0 12px; border:1px solid var(--line2); border-radius:18px; color:var(--faint); margin-left:auto}
.search:focus-within{border-color:var(--faint)}
.search input{flex:1; min-width:0; border:0; background:none; outline:0; color:var(--text); font-size:13px}
.search input::placeholder{color:var(--faint)}
.seg{display:inline-flex; padding:2px; border:1px solid var(--line2); border-radius:20px; flex:none}
.seg button{border:0; background:none; height:28px; padding:0 13px; border-radius:16px; font-size:12.5px; font-weight:500; color:var(--faint); transition:background var(--fast),color var(--fast)}
.seg button:hover{color:var(--text)}
.seg button[aria-pressed="true"]{background:var(--raised); color:var(--head)}
/* shell */
.shell{display:flex; align-items:flex-start}
.side{position:sticky; top:var(--top); flex:none; width:var(--sidew); height:calc(100vh - var(--top)); overflow-y:auto; background:var(--side); border-right:1px solid var(--line); overscroll-behavior:contain}
body.no-side .side{display:none}
.side-head{display:flex; gap:12px; padding:18px 16px 16px; border-bottom:1px solid var(--line)}
.side-head h2{margin:0; font-size:14.5px; font-weight:600; color:var(--head); line-height:1.3}
.side-head .src{font-size:12px; color:var(--faint); margin-top:2px}
.prog{display:flex; align-items:center; gap:10px; margin-top:10px; font-size:12px; color:var(--muted)}
.bar{display:block; flex:1; height:4px; border-radius:2px; background:var(--line); overflow:hidden}
.bar i{display:block; height:100%; background:var(--accent); border-radius:2px}
.bar.dim i{background:var(--accent-dim)}
.mod{border-bottom:1px solid var(--line)}
.mod-head{width:100%; display:grid; grid-template-columns:22px 1fr auto 12px; gap:6px; align-items:baseline; padding:11px 16px 7px; border:0; background:none; text-align:left; font-size:13.5px; color:var(--text)}
.mod-head:hover{color:var(--head)}
.mod-head .n{color:var(--faint); font-size:12px}
.mod-head .c{color:var(--faint); font-size:12px}
.mod-head svg{color:var(--faint); transition:transform var(--fast); align-self:center}
.mod-head[aria-expanded="true"] svg{transform:rotate(90deg)}
.mod.is-current .mod-head{color:var(--head); font-weight:500}
.mod .bar{margin:0 16px 11px 44px; flex:none}
.lessons{list-style:none; margin:-3px 0 0; padding:0 0 8px}
.les{display:grid; grid-template-columns:22px 1fr auto; gap:6px; align-items:center; padding:6px 16px; font-size:13px; line-height:1.35; color:var(--muted); text-decoration:none}
.les:hover{background:color-mix(in srgb,var(--raised) 60%,transparent); color:var(--text)}
.les .m{width:8px; height:8px; border-radius:50%; border:1.5px solid var(--line2); justify-self:center}
.les.done .m{border:0; width:12px; height:12px; color:var(--faint)}
.les .d{font-size:12px; color:var(--faint)}
.les[aria-current="page"]{background:var(--raised); color:var(--head); box-shadow:inset 2px 0 0 var(--accent)}
.les[aria-current="page"] .m{border:0; background:var(--accent)}
.nohit{padding:16px; color:var(--faint); font-size:13px}
/* main column */
main{flex:1; min-width:0; padding:44px 40px 0}
.col{max-width:var(--col); margin:0 auto}
.wide{max-width:var(--wide); margin:0 auto}
.chap{font-size:13px; font-weight:500; color:var(--muted); margin:0 0 10px}
h1.title{font:600 var(--h1)/1.12 var(--tf); color:var(--head); margin:0; letter-spacing:-.012em; text-wrap:balance}
.meta{display:flex; flex-wrap:wrap; gap:14px 30px; margin:22px 0 0; padding-bottom:22px; border-bottom:1px solid var(--line); align-items:end}
.meta div{min-width:0}
.meta dt{font-size:12px; color:var(--faint); margin-bottom:2px}
.meta dd{margin:0; font-size:13.5px; color:var(--text)}
.meta dd code{font-size:12.5px}
.source{font-size:12.5px; color:var(--faint); margin:14px 0 0}
.source strong{font-weight:500; color:var(--muted)}
.prose{font-family:var(--pf); font-size:var(--ps); line-height:var(--pl); color:var(--pc)}
.prose p,.prose ul,.prose ol{margin:0 0 1em}
.prose h2{font:600 1.32em/1.25 var(--tf); color:var(--head); margin:2em 0 .55em; letter-spacing:-.005em}
.prose h3{font:600 1.08em/1.3 var(--tf); color:var(--head); margin:1.7em 0 .45em}
.prose strong{font-weight:600; color:var(--head)}
.prose li{margin-bottom:.35em; padding-left:.2em}
.prose :not(pre)>code{font-size:.78em; padding:.08em .32em; background:color-mix(in srgb,var(--raised) 75%,transparent); border-radius:4px; color:var(--text)}
pre.code{font-size:13.5px; line-height:1.6; background:var(--side); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:0 0 1.2em; overflow-x:auto; color:var(--text); tab-size:2}
.prose pre.code{font-size:13.5px}
.lede{color:var(--head)}
.aside{border-left:2px solid var(--line2); padding-left:18px; color:var(--muted); font-size:.92em; margin:0 0 1.2em}
.aside strong{color:var(--text)}
.c{color:var(--faint); font-style:italic}
.k{color:var(--head); font-weight:500}
.trk{display:none}
body[data-track="js"] .trk[data-t="js"],body[data-track="scala"] .trk[data-t="scala"],body[data-track="fs"] .trk[data-t="fs"]{display:block}
body[data-track="js"] span.trk[data-t="js"],body[data-track="scala"] span.trk[data-t="scala"],body[data-track="fs"] span.trk[data-t="fs"]{display:inline}
details.fold{border-top:1px solid var(--line); border-bottom:1px solid var(--line); margin:0 0 1.4em}
details.fold+details.fold{margin-top:-1.4em; border-top:0}
details.fold>summary{list-style:none; display:flex; align-items:center; gap:10px; padding:12px 2px; font:500 14px/1.4 var(--sans); color:var(--text); cursor:pointer}
details.fold>summary::-webkit-details-marker{display:none}
details.fold>summary svg{color:var(--faint); transition:transform var(--fast); flex:none}
details.fold[open]>summary svg{transform:rotate(90deg)}
details.fold>summary:hover{color:var(--head)}
details.fold>summary .tier{margin-left:auto; font-size:12px; color:var(--faint); font-weight:400}
details.fold>.in{padding:2px 0 14px 24px}
.qs{counter-reset:q; list-style:none; padding:0}
.qs li{counter-increment:q; position:relative; padding:.55em 0 .55em 2.2em; border-top:1px solid var(--line); margin:0}
.qs li::before{content:counter(q); position:absolute; left:0; top:.55em; font:500 13px/2.1 var(--sans); color:var(--faint); font-variant-numeric:tabular-nums}
/* pills */
.btn{display:inline-flex; align-items:center; gap:8px; height:34px; padding:0 15px; border:1px solid var(--line2); border-radius:18px; background:none; font-size:13px; font-weight:500; text-decoration:none; white-space:nowrap; transition:background var(--fast),color var(--fast),border-color var(--fast)}
.btn.sm{height:28px; padding:0 11px; font-size:12.5px}
.btn.primary{background:var(--accent); border-color:var(--accent); color:var(--on-accent)}
.btn.primary:hover{background:color-mix(in srgb,var(--accent) 86%,#fff)}
.btn.quiet{border-color:transparent; color:var(--muted)}
.btn.quiet:hover{color:var(--text)}
.btn:disabled{opacity:.45; cursor:default}
/* floating action pill */
.dock{position:sticky; bottom:18px; z-index:10; display:flex; width:max-content; max-width:100%; margin:56px auto 18px; gap:4px; padding:5px; background:var(--raised); border:1px solid var(--line2); border-radius:24px; box-shadow:0 0 0 10px var(--ground)}
.dock .btn{height:36px; border-color:transparent}
.dock .btn:hover{background:var(--line2)}
.dock .btn.primary:hover{background:color-mix(in srgb,var(--accent) 86%,#fff)}
.dock .btn kbd{border-color:color-mix(in srgb,currentColor 45%,transparent)}
.dock #done[aria-pressed="true"]{color:var(--accent)}
.dock .sep{width:1px; background:var(--line2); margin:7px 2px}
/* video */
.player{aspect-ratio:16/9; background:#000; border-radius:10px; overflow:hidden}
.player video{display:block; width:100%; height:100%}
.split{display:grid; grid-template-columns:minmax(0,1fr) 300px; gap:40px; margin-top:28px}
.blockhead{display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin:0 0 10px}
.blockhead h2{margin:0; font:600 15px/1.3 var(--sans); color:var(--head)}
.blockhead span{font-size:12px; color:var(--faint)}
.notes{width:100%; min-height:140px; resize:vertical; background:var(--side); border:1px solid var(--line); border-radius:10px; padding:12px 14px; color:var(--text); font-size:14px; line-height:1.55}
.notes:focus{outline:0; border-color:var(--faint)}
.rows{list-style:none; margin:0; padding:0; border-top:1px solid var(--line)}
.rows li{display:grid; grid-template-columns:20px minmax(0,1fr) auto; gap:10px; align-items:center; padding:10px 2px; border-bottom:1px solid var(--line); font-size:13.5px}
.rows li svg{color:var(--faint)}
.rows a{overflow-wrap:anywhere}
.rows .sz{white-space:nowrap; font-size:12px; color:var(--faint)}
.rows a{text-decoration:none}
.rows a:hover{text-decoration:underline; text-underline-offset:3px}
/* slides */
.stage{position:relative; aspect-ratio:16/9; background:#fff; border-radius:8px; overflow:hidden}
.stage img{width:100%; height:100%; object-fit:cover; display:block}
.stage:fullscreen{border-radius:0; background:#000; display:grid; place-items:center}
.stage:fullscreen img{object-fit:contain}
.deckbar{display:flex; align-items:center; gap:8px; margin:12px 0 0}
.deckbar .count{font-size:13px; color:var(--muted); margin:0 6px; min-width:58px; text-align:center}
.deckbar .gap{flex:1}
.thumbs{display:flex; gap:8px; margin:16px 0 0; overflow-x:auto; padding:3px 3px 6px}
.thumbs button{flex:none; width:104px; aspect-ratio:16/9; padding:0; border:1px solid var(--line2); border-radius:5px; overflow:hidden; background:#fff; opacity:.6; transition:opacity var(--fast)}
.thumbs button:hover{opacity:.9}
.thumbs button[aria-current="true"]{opacity:1; outline:2px solid var(--accent); outline-offset:2px}
.thumbs img{width:100%; height:100%; object-fit:cover; display:block}
.thumbs .more{flex:none; width:128px; aspect-ratio:16/9; border:1px dashed var(--line2); border-radius:5px; display:grid; place-items:center; text-align:center; font-size:11.5px; line-height:1.35; color:var(--faint); padding:6px}
/* code files */
.files{display:grid; grid-template-columns:220px minmax(0,1fr); border:1px solid var(--line); border-radius:10px; overflow:hidden; margin:0 0 1.4em; font-family:var(--sans)}
.tree{background:var(--side); border-right:1px solid var(--line); padding:8px 0; font-size:13px}
.tree .dir{padding:5px 12px; color:var(--muted); display:flex; gap:7px; align-items:center}
.tree .dir.sub{padding-left:26px}
.tree button{display:flex; gap:7px; align-items:center; width:100%; border:0; background:none; text-align:left; padding:5px 12px 5px 26px; color:var(--muted); font-size:13px}
.tree button.l2{padding-left:40px}
.tree button:hover{color:var(--text)}
.tree button[aria-current="true"]{background:var(--raised); color:var(--head)}
.tree span.f{display:flex; gap:7px; align-items:center; padding:5px 12px 5px 26px; color:var(--faint)}
.tree svg{flex:none; color:var(--faint)}
.viewer{min-width:0; display:flex; flex-direction:column; background:var(--ground)}
.tabs{display:flex; align-items:center; border-bottom:1px solid var(--line); padding:0 6px 0 0; overflow-x:auto}
.tabs button{border:0; background:none; padding:10px 14px; font-size:12.5px; color:var(--faint); border-bottom:2px solid transparent; margin-bottom:-1px; white-space:nowrap; font-family:var(--mono)}
.tabs button[aria-selected="true"]{color:var(--head); border-bottom-color:var(--accent)}
.tabs .btn{margin-left:auto}
.path{font-size:12px; color:var(--faint); padding:8px 16px 0}
.viewer pre.code{border:0; border-radius:0; background:none; margin:0; counter-reset:ln}
.ln{display:block; counter-increment:ln}
.ln::before{content:counter(ln); display:inline-block; width:2.2em; margin-right:1em; text-align:right; color:#5b6170}
.cmd{position:relative}
.cmd .btn{position:absolute; top:8px; right:8px}
/* exercise */
.ex{display:grid; grid-template-columns:minmax(0,.9fr) minmax(0,1.1fr); gap:36px; align-items:start}
.ex .prose{font-size:15px}
.edit{border:1px solid var(--line); border-radius:10px; overflow:hidden; position:sticky; top:calc(var(--top) + 20px)}
.edit-bar{display:flex; align-items:center; gap:6px; padding:6px 6px 6px 14px; background:var(--side); border-bottom:1px solid var(--line)}
.edit-bar .f{font:500 12.5px var(--mono); color:var(--muted); margin-right:auto}
.editor{display:grid; grid-template-columns:44px 1fr; background:var(--ground); height:270px}
.gutter{margin:0; padding:12px 10px 12px 0; text-align:right; color:#5b6170; font:13.5px/1.6 var(--mono); overflow:hidden; user-select:none; background:var(--ground)}
.editor textarea{border:0; resize:none; outline:0; background:none; padding:12px 14px 12px 6px; font:13.5px/1.6 var(--mono); color:var(--text); white-space:pre; overflow:auto; tab-size:2}
.editor:focus-within{box-shadow:inset 0 0 0 1px var(--faint)}
.results{border-top:1px solid var(--line); background:var(--side)}
.res-sum{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 14px; font-size:13px; color:var(--muted); border-bottom:1px solid var(--line)}
.res-sum b{font-weight:600; color:var(--head)}
.res-list{list-style:none; margin:0; padding:0; max-height:300px; overflow:auto}
.res-list li{padding:8px 14px; border-bottom:1px solid var(--line); font-size:13px}
.res-list li:last-child{border-bottom:0}
.res-row{display:grid; grid-template-columns:40px 1fr; gap:8px}
.res-row .s{font-weight:600; font-size:12px}
.pass .s{color:var(--ok)}
.fail .s{color:var(--bad)}
.res-row code{font-size:12.5px; color:var(--text)}
.diff{display:grid; grid-template-columns:64px 1fr; gap:2px 8px; margin:6px 0 2px 48px; font-size:12.5px}
.diff dt{color:var(--faint)}
.diff dd{margin:0; font-family:var(--mono); color:var(--text)}
.local{margin:20px 0 0; padding:12px 0 0; border-top:1px solid var(--line); font-size:13px; color:var(--muted)}
.local strong{color:var(--text); font-weight:500}
.local code{display:block; margin-top:6px; font-size:12.5px; color:var(--text)}
/* library */
.lib-h{font:600 24px/1.2 var(--sans); color:var(--head); margin:0 0 4px}
.lib-sub{color:var(--faint); margin:0 0 32px; font-size:13.5px}
.sec-h{font:600 14px var(--sans); color:var(--muted); margin:0 0 10px}
.cover{flex:none; align-self:flex-start; position:relative; width:var(--cw,44px); aspect-ratio:3/4; border-radius:6px; background:var(--cbg); color:var(--cfg); border:1px solid color-mix(in srgb,var(--cfg) 18%,transparent); overflow:hidden; font:600 calc(var(--cw,44px)*.34)/1 var(--sans); letter-spacing:-.03em; display:flex; align-items:flex-end; padding:calc(var(--cw,44px)*.12)}
.cover.tiny{--cw:24px; border-radius:4px; font-size:9px}
.continue{display:grid; grid-template-columns:auto 1fr auto; gap:20px; align-items:center; padding:18px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); margin-bottom:44px}
.continue .t{font-size:16px; font-weight:600; color:var(--head)}
.continue .s{font-size:13.5px; color:var(--muted); margin:2px 0 10px}
.continue .prog{max-width:360px; margin:0}
.lib{border-top:1px solid var(--line)}
.lib-row,.lib-cols{display:grid; grid-template-columns:minmax(0,1fr) 90px 210px 180px; gap:24px; align-items:center}
.lib-cols{padding:0 0 8px; font-size:12px; color:var(--faint)}
.lib-row{padding:16px 0; border-bottom:1px solid var(--line); text-decoration:none}
.lib-row:hover .lt{color:var(--head); text-decoration:underline; text-underline-offset:3px; text-decoration-color:var(--line2)}
.lib-main{display:flex; gap:16px; align-items:center; min-width:0}
.lt{font-size:15px; font-weight:600; color:var(--text)}
.ls{font-size:13px; color:var(--faint); margin-top:2px}
.lnum{font-size:13.5px; color:var(--muted)}
.tags{display:flex; flex-wrap:wrap; gap:5px}
.tag{font-size:11.5px; color:var(--muted); border:1px solid var(--line2); border-radius:10px; padding:1px 8px}
.lib-row .prog{margin:0}
.toast{position:fixed; left:50%; bottom:24px; transform:translateX(-50%); z-index:50; background:var(--raised); border:1px solid var(--line2); border-radius:18px; padding:9px 16px; font-size:13px; color:var(--text)}
.scrim{display:none}
@media (max-width:1100px){
.ex{grid-template-columns:minmax(0,1fr)}
.edit{position:static}
.split{grid-template-columns:1fr; gap:28px}
.lib-row,.lib-cols{grid-template-columns:minmax(0,1fr) 70px 150px}
.lib-row>.tags,.lib-cols>:nth-child(3){display:none}
}
@media (max-width:960px){
.org{flex:none}
.icon-btn{display:inline-grid}
.side{position:fixed; top:var(--top); left:0; bottom:0; height:auto; z-index:40; transform:translateX(-100%); transition:transform 160ms ease-out}
body.side-open .side{transform:none}
body.side-open .scrim{display:block; position:fixed; left:0; right:0; bottom:0; z-index:35; background:rgb(10 11 15 / .6)}
body.no-side .icon-btn{visibility:hidden; width:0; margin-right:-14px; border:0}
main{padding:32px 24px 0}
}
@media (max-width:680px){
:root{--top:52px}
.top{gap:8px; padding:0 10px}
.org{display:none}
.search{display:none}
.switch-btn{max-width:170px}
.switch-btn .t{font-size:12.5px}
.seg button{padding:0 10px}
.review .keys{display:none}
main{padding:26px 18px 0}
body[data-look="reading"]{--ps:18px; --h1:31px}
.meta{gap:12px 22px}
.files{grid-template-columns:1fr}
.tree{border-right:0; border-bottom:1px solid var(--line)}
.continue{grid-template-columns:auto 1fr}
.continue .btn{grid-column:1/-1; justify-self:start}
.lib-row,.lib-cols{grid-template-columns:minmax(0,1fr) 92px; gap:14px}
.lib-row>.lnum,.lib-cols>:nth-child(2){display:none}
.lib-cols>:last-child{text-align:left}
.menu{width:min(330px,calc(100vw - 20px)); left:-48px}
.dock{bottom:12px}
.dock .lbl-long{display:none}
.thumbs button{width:84px}
}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{transition:none !important; animation:none !important; scroll-behavior:auto !important}}
</style>
</head>
<body data-look="reading" data-track="js" data-course="snake">
<nav class="review" aria-label="Mockup: lesson types">
<b>Mockup: lesson types</b>
<a href="#text">Text</a><a href="#video">Text + video</a><a href="#slides">Slides</a><a href="#code">Text + code files</a><a href="#exercise">Exercise</a><a href="#home">Library</a>
<span class="keys"><kbd>[</kbd><kbd>]</kbd> lessons <kbd>/</kbd> search</span>
</nav>
<header class="top">
<button class="icon-btn" id="outlineBtn" aria-label="Course outline" aria-expanded="false" aria-controls="side"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h12M2 12h8"/></svg></button>
<a class="org" href="#home">Homelab Academy</a>
<div class="switch">
<button class="switch-btn" id="courseBtn" aria-haspopup="true" aria-expanded="false" aria-controls="courseMenu"><span id="courseTile"></span><span class="t" id="courseName"></span><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3.5l3 3 3-3"/></svg></button>
<ul class="menu" id="courseMenu" hidden></ul>
</div>
<label class="search"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></svg><span class="sr">Search</span><input id="search" type="search" placeholder="Search this course" autocomplete="off"><kbd>/</kbd></label>
<div class="seg" role="group" aria-label="Look"><button id="lookReading" aria-pressed="true">Reading</button><button id="lookWork" aria-pressed="false">Work</button></div>
</header>
<div class="shell">
<aside class="side" id="side" aria-label="Course outline"></aside>
<div class="scrim" id="scrim"></div>
<main id="main">
<div id="view"></div>
<div class="dock" id="dock" role="toolbar" aria-label="Lesson actions">
<button class="btn quiet" id="prev" title="Previous lesson"><span class="sr">Previous lesson</span><kbd>[</kbd></button>
<button class="btn quiet" id="done" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 8.5l3.2 3L13 4.5"/></svg><span id="doneLbl">Mark done</span></button>
<span class="sep" aria-hidden="true"></span>
<button class="btn primary" id="next"><span>Next<span class="lbl-long"> lesson</span></span><kbd>]</kbd></button>
</div>
</main>
</div>
<div class="toast" id="toast" role="status" hidden></div>
<!-- 1. Text only -->
<template id="tpl-text">
<article class="col">
<p class="chap">Module 3</p>
<h1 class="title">Pure game rules</h1>
<dl class="meta">
<div><dt>Time</dt><dd>About 3–4 hours</dd></div>
<div><dt>You edit</dt><dd><code class="trk" data-t="js">js/src/rules.js</code><code class="trk" data-t="scala">scala/src/Rules.scala</code><code class="trk" data-t="fs">fsharp/src/Rules.fs</code></dd></div>
<div><dt>Needs</dt><dd>Modules 1 and 2</dd></div>
<div><dt>Track</dt><dd><div class="seg" role="group" aria-label="Track" data-trackseg><button data-tr="js">JavaScript</button><button data-tr="scala">Scala 3</button><button data-tr="fs">F#</button></div></dd></div>
</dl>
<div class="prose" style="margin-top:30px">
<h2 style="margin-top:0">Why this module</h2>
<p>In a stateful Snake, the rules live in methods like <code>snake.move()</code>, <code>snake.changeDirection(d)</code> and <code>game.checkCollision()</code>. They are called in some order from a game loop, and each one reads and writes shared fields. The <em>order</em> of those calls is part of the rules, but it is written nowhere. Is the collision checked before or after the tail moves? Before or after the snake grows? You find out by reading the loop, or by watching bugs.</p>
<p>In this module, every rule is a <strong>pure function from a state to a new state</strong>. <code>turn(state, dir)</code> returns the state after the player asks to turn. <code>advance(state)</code> returns the state one step later. Each function says, in one place, everything that happens in that step and in which order. You test it by handing it a state and comparing the result with the expected state; no timers, no setup, no mocks.</p>
<p>Two of the rules hide real subtleties that many Snake implementations get wrong. You will meet them as puzzles, and reason them through before you read the explanations.</p>
<h2>Learning objectives</h2>
<p>After this module you can:</p>
<ul>
<li><strong>implement</strong> game rules as total functions <code>State → State</code> using copy-with-changes;</li>
<li><strong>explain</strong> why a rule returns the state unchanged instead of throwing when it does not apply;</li>
<li><strong>reason</strong> about the order of events inside one step (move, grow, collide) and predict the result for a given board;</li>
<li><strong>explain</strong> why <code>turn</code> must compare against <code>dir</code> (last moved) and not <code>nextDir</code>;</li>
<li><strong>read</strong> a fixture case, draw it, and use it to debug.</li>
</ul>
<h2>Rules as functions State → State</h2>
<p>Each rule has the same shape: it takes the whole game state (plus maybe some input) and returns a whole new game state.</p>
<pre class="code">turn : (GameState, Direction) -&gt; GameState
advance : GameState -&gt; GameState</pre>
<p>Because the result is a full state, rules can be chained: <code>advance(turn(s, 'Up'))</code> is the state after turning up and moving. Because they are pure, a rule called with a frozen state from a fixture gives exactly the fixture’s expected state, every time.</p>
<h3>Total functions: “unchanged” is a valid answer</h3>
<p>A function is <strong>total</strong> if it returns a result for every possible input. It never throws, never loops forever, never returns <code>undefined</code> by accident. Our rules follow one simple convention: <strong>when a rule does not apply, it returns the state unchanged.</strong> <code>turn</code> while paused returns the same state.</p>
<p class="aside"><strong>From everyday programming:</strong> the usual alternative is a method that throws an <code>IllegalStateException</code> when it is called at the wrong time. A total rule behaves more like a disabled button in a user interface: pressing it is allowed, and nothing happens.</p>
<p>A typical rule therefore starts with a <strong>guard</strong>:</p>
<pre class="code trk" data-t="js"><span class="c">// (illustration on a different record)</span>
const heal = (hero, amount) =&gt; {
if (hero.hp &lt;= 0) return hero <span class="c">// dead heroes cannot be healed: unchanged</span>
return { ...hero, hp: Math.min(hero.maxHp, hero.hp + amount) }
}</pre>
<pre class="code trk" data-t="scala">def heal(hero: Hero, amount: Int): Hero =
if hero.hp &lt;= 0 then hero
else hero.copy(hp = math.min(hero.maxHp, hero.hp + amount))</pre>
<pre class="code trk" data-t="fs">let heal (hero: Hero) (amount: int) : Hero =
if hero.Hp &lt;= 0 then hero
else { hero with Hp = min hero.MaxHp (hero.Hp + amount) }</pre>
<h2>Puzzle 1: can the snake bite its own tail?</h2>
<p>Here is a 5 x 5 board. The snake has four segments, in a tight square. Head <code>H</code> at (1,1), then (1,2), (2,2), and the tail <code>T</code> at (2,1). It is about to move <code>Right</code>, onto the tail’s cell. There is no apple.</p>
<pre class="code"> x=0 x=1 x=2 x=3 x=4
y=0 . . . . .
y=1 . H T . . snake (head first): (1,1) (1,2) (2,2) (2,1)
y=2 . b b . . nextDir: Right
y=3 . . . . .
y=4 . . . . .</pre>
<p><strong>Before reading on, decide:</strong> is this move a collision (game over), or legal? Draw what the board looks like after the move in each case. Then consider a second board: identical, except that there is an apple on (2,1), the cell where the tail is.</p>
<details class="fold">
<summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>Think about it first, then open the explanation</summary>
<div class="in">
<p>All segments of the snake move <strong>at the same time</strong>. In one step, the head enters a new cell and, if the snake is not eating, the tail leaves its cell. So in the first board, by the time the head arrives at (2,1), the tail has moved on. The move is <strong>legal</strong>, and the snake chases its own tail around the square.</p>
<p>In the second board, the head lands on the apple, so the snake <strong>grows</strong>: the tail stays where it is. Now the head and the tail would occupy the same cell, which is a collision: game over.</p>
<pre class="code">eating? yes -&gt; body = the whole old snake (tail stays, the snake grows)
no -&gt; body = the old snake minus its last cell (tail leaves)
collision if the new head is off the board, or is contained in body</pre>
<p>This is why <code>advance</code> must first decide whether it eats, then compute the body, and only then check for collisions. The order is the rule.</p>
</div>
</details>
<h2>Check your understanding</h2>
<ol class="qs">
<li>Why does <code>turn</code> return the state unchanged when paused, instead of throwing? Who would have to deal with the exception, and how would that change the rest of the program?</li>
<li>Without looking: on a 5 x 5 board, the snake is (1,1) (1,2) (2,2) (2,1) heading Up with <code>nextDir = Right</code> and an apple at (2,1). What does <code>advance</code> return? Now the same with no apple.</li>
<li>Explain to a friend, with a drawing, why <code>turn</code> must compare against <code>dir</code> and not <code>nextDir</code>. What sequence of key presses kills the snake in the wrong version?</li>
<li>After <code>advance</code> crashes into a wall, the snake is unchanged but <code>dir</code> is updated. What invariant does that keep?</li>
<li><code>advance</code> does not place a new apple after eating. Why is it a good idea to keep that out of <code>advance</code>?</li>
<li>Why are the fixture boards 5 x 5 rather than the real 20 x 20? What does that tell you about storing <code>width</code> and <code>height</code> in the state?</li>
</ol>
</div>
</article>
</template>
<!-- 2. Text + video -->
<template id="tpl-video">
<article class="wide">
<p class="chap">Section 1: Introduction to Docker</p>
<h1 class="title">Getting Started with Containerization</h1>
<p class="source"><strong>Video 1.2</strong>, 0:48. Imported from a Packt video package on 28 October 2026; titles, order and files come from the package.</p>
<div class="player" style="margin-top:22px"><video id="vid" controls preload="metadata" poster="video1_2-poster.jpg" src="docker-1-2.mp4"></video></div>
<div class="split">
<section>
<div class="blockhead"><h2>Your notes</h2><button class="btn sm" id="stamp">Insert time <span class="tnum" id="stampT">0:00</span></button></div>
<textarea class="notes" id="notesTa" aria-label="Your notes for this clip" placeholder="Only you see these. Insert time adds a link back to this point in the clip."></textarea>
<div class="prose" style="margin-top:22px; font-size:calc(var(--ps) * .9)">
<p>This clip sets up the question the rest of the section answers: why run an application in a container at all, rather than straight on a server or in a virtual machine. Clip 1.3 then opens the box and shows the two kernel features that make a container possible.</p>
</div>
</section>
<section>
<div class="blockhead"><h2>Files for this section</h2><span>Section 1</span></div>
<ul class="rows" id="attach1"></ul>
<p class="source">Section 1 has no code in the companion repo.</p>
</section>
</div>
</article>
</template>
<!-- 3. Slides -->
<template id="tpl-slides">
<article class="wide">
<p class="chap">Module 2</p>
<h1 class="title">Best Practices for Writing Functions</h1>
<p class="source"><strong>Elle Krout</strong>, Pluralsight. Slide deck, 59 pages, imported from PDF.</p>
<figure class="stage" id="stage" style="margin:22px 0 0"><img id="slideImg" alt=""></figure>
<div class="deckbar">
<button class="btn sm" id="sPrev" aria-label="Previous slide"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M7.5 2.5L4 6l3.5 3.5"/></svg></button>
<span class="count tnum" id="sCount" aria-live="polite">1 / 59</span>
<button class="btn sm" id="sNext" aria-label="Next slide"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4.5 2.5L8 6 4.5 9.5"/></svg></button>
<span class="gap"></span>
<span class="source" style="margin:0">Arrow keys change slides</span>
<button class="btn sm" id="sFull"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 4.5v-3h3M7.5 1.5h3v3M10.5 7.5v3h-3M4.5 10.5h-3v-3"/></svg>Fullscreen</button>
</div>
<div class="thumbs" id="thumbs" role="group" aria-label="Slides"></div>
<div class="col" style="margin-top:40px">
<div class="prose">
<h2 style="margin-top:0">Notes for this deck</h2>
<p>The deck opens the module with one rule, <strong>keep code small</strong>, and then takes it apart three ways: a function should serve a single purpose, it should be idempotent, and it should depend on as little as possible.</p>
<p>Slides 5 to 8 return to the same three boxes and fill one at a time. The point on slide 5 is the easiest to miss in practice: each bit of pathing (one route, one event source) should be its own function, so that a change to one path can never break another.</p>
<p>Idempotent is the one to check in your own code. Serverless platforms retry: a function that runs twice for the same event must leave the world as if it had run once.</p>
</div>
</div>
</article>
</template>
<!-- 4. Text + code files -->
<template id="tpl-code">
<article class="wide"><div class="col">
<p class="chap">Section 8: Containerizing Applications (Sample Project Using Python)</p>
<h1 class="title">Writing the Dockerfile</h1>
<p class="source"><strong>Text lesson</strong> between clips 8.2 and 8.4. Files come from the course’s companion repo.</p>
<div class="prose" style="margin-top:28px">
<p>NyxAcademy is the small Django site this section uses as its sample project. Started with <code>python manage.py runserver</code>, it works on your laptop because your laptop already has the right Python, the right Django and a database file next to the code. A container has none of that until the Dockerfile says so.</p>
<p>The Dockerfile in the sample repo does three things in order: start from a slim Python image, install Django, copy the project in. Its last line hands over to <code>startup.sh</code>, which prepares the database and the static files every time the container starts, and then runs the server.</p>
<p>Read both files side by side before you build. Notice that the server listens on <code>0.0.0.0</code>, not <code>127.0.0.1</code>: inside a container, localhost is the container itself, and the published port could never reach it.</p>
</div></div>
<div class="blockhead" style="margin-top:34px"><h2>Files for this section</h2><button class="btn sm" id="zip"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 1.5v6.5M3.2 5.5L6 8.2l2.8-2.7M1.5 10.5h9"/></svg>Download files for this section (zip)</button></div>
<div class="files">
<div class="tree" id="tree"></div>
<div class="viewer">
<div class="tabs" role="tablist" id="tabs"></div>
<div class="path" id="fpath"></div>
<pre class="code" id="fcode"></pre>
</div>
</div>
<div class="col"><div class="prose">
<h2>Run it on your machine</h2>
<p>From the folder you unzipped, build the image and start a container. Then open <code>localhost:8000</code>.</p>
</div>
<div class="cmd"><pre class="code" id="cmds">cd "S08 - Containerizing Applications/NyxAcademy-main"
docker build -f Dockerfile.example -t nyxacademy .
docker run --rm -p 8000:8000 nyxacademy</pre><button class="btn sm" data-copy="cmds">Copy</button></div>
<div class="blockhead" style="margin-top:30px"><h2>Attachments</h2><span>Whole course</span></div>
<ul class="rows" id="attach8"></ul>
</div></article>
</template>
<!-- 5. Exercise -->
<template id="tpl-exercise">
<article class="wide">
<div class="ex">
<div>
<p class="chap">Module 3: Pure game rules</p>
<h1 class="title">Exercise 3.1: <code style="font-size:.9em">move</code></h1>
<div class="prose" style="margin-top:20px">
<p>Return the cell one step from <code>p</code> in direction <code>dir</code>, where <code>dir</code> is <code>'Up'</code>, <code>'Down'</code>, <code>'Left'</code> or <code>'Right'</code>. The board’s <code>y</code> grows downward, so <code>Up</code> is <code>y - 1</code>.</p>
<pre class="code">move({x: 2, y: 2}, 'Up') -&gt; {x: 2, y: 1}
move({x: 4, y: 2}, 'Right') -&gt; {x: 5, y: 2}</pre>
<p>It does <strong>not</strong> wrap around the edges: deciding what “off the board” means is <code>inBounds</code>’s job. And it is a rule, so it builds a new point; the point you were given must come back untouched.</p>
<p><code>delta(dir)</code> and <code>point(x, y)</code> from Module 2 are already imported.</p>
</div>
<h2 class="sec-h" style="margin-top:26px">Hints</h2>
<details class="fold"><summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>Nudge<span class="tier">1 of 3</span></summary><div class="in prose"><p><code>delta(dir)</code> gives you a point to add.</p></div></details>
<details class="fold"><summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>Stronger hint<span class="tier">2 of 3</span></summary><div class="in prose"><p>Build a new point whose <code>x</code> is <code>p.x + d.x</code> and whose <code>y</code> is <code>p.y + d.y</code>, where <code>d = delta(dir)</code>.</p></div></details>
<details class="fold"><summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>Near-solution<span class="tier">try the others first</span></summary><div class="in prose"><pre class="code">export const move = (p, dir) =&gt; { const d = delta(dir); return point(___, ___) }</pre></div></details>
<p class="local"><strong>Runs in your browser — the full project runs on your machine.</strong> This page checks <code style="display:inline">move</code> against the five fixture cases plus a purity check. The whole module, in any track:<code>cd js &amp;&amp; node --test "test/m03-*.test.js"</code></p>
</div>
<div class="edit">
<div class="edit-bar"><span class="f">rules.js</span><button class="btn sm quiet" id="reset">Reset</button><button class="btn sm primary" id="run">Run tests <kbd>Ctrl ↵</kbd></button></div>
<div class="editor"><pre class="gutter" id="gutter" aria-hidden="true"></pre><textarea id="code" spellcheck="false" aria-label="Code editor, rules.js"></textarea></div>
<div class="results" id="results"></div>
</div>
</div>
</article>
</template>
<!-- 6. Library -->
<template id="tpl-home">
<article class="wide" style="max-width:1080px">
<h1 class="lib-h">Library</h1>
<p class="lib-sub">Four courses on this server.</p>
<h2 class="sec-h">Continue</h2>
<div class="continue">
<span class="cover" style="--cw:56px;--cbg:#272319;--cfg:#f1b44c">PS</span>
<div><div class="t">Pure Snake</div><div class="s" id="contS"></div>
<div class="prog"><span class="bar"><i id="contBar"></i></span><span class="tnum" id="contN"></span></div></div>
<a class="btn primary" id="contBtn">Continue <kbd>]</kbd></a>
</div>
<h2 class="sec-h">Courses</h2>
<div class="lib-cols"><span>Course</span><span>Lessons</span><span>Contains</span><span>Progress</span></div>
<div class="lib" id="lib"></div>
</article>
</template>
<template id="tpl-generic">
<article class="col">
<p class="chap" id="gChap"></p>
<h1 class="title" id="gTitle"></h1>
<div class="prose" style="margin-top:24px"><p style="color:var(--faint)">This lesson is not drawn in the mockup. The six lesson types are in the strip at the top; <kbd>[</kbd> and <kbd>]</kbd> keep moving through the course.</p></div>
</article>
</template>
<script>
const ICON = {
chev:'<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>',
check:'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 8.5l3.2 3L13 4.5"/></svg>',
file:'<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M4 1.5h5.5L13 5v9.5H4z"/><path d="M9.5 1.5V5H13"/></svg>',
dir:'<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1.5 3.5h5l1.5 1.5h6.5v8.5h-13z"/></svg>',
pdf:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 1.5h6L13 5v9.5H3.5z"/><path d="M5.5 9h5M5.5 11.5h3.5"/></svg>'
};
const $ = (s, r = document) => r.querySelector(s);
const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
/* ---------- course manifests (what an import or a repo would provide) ---------- */
const SNAKE_MODS = ['Setup and how to use this course','Purity and immutability','The game as data','Pure game rules','Randomness without side effects','Folding time','Rendering as data','The IO type','Event streams','Wiring it together','Extensions, all pure','Capstone: Pong, by the method'];
const SNAKE_EX = [0,4,4,4,3,3,3,4,3,4,3,1];
const M3_EX = ['move','inBounds','turn','advance'];
const snakeOutline = SNAKE_MODS.map((t, n) => {
const ls = [{id:`s${n}-r`, t, d:'Text', view: n === 3 ? 'text' : null}];
for (let k = 1; k <= SNAKE_EX[n]; k++) ls.push({id:`s${n}-e${k}`, t:`Exercise ${n}.${k}` + (n === 3 ? `: ${M3_EX[k-1]}` : ''), d:'Exercise', view: n === 3 && k === 1 ? 'exercise' : null});
if (n > 0) ls.push({id:`s${n}-q`, t:'Check your understanding', d:'Text'});
return {n, t, lessons: ls};
});
const clips = (s, list) => list.map(([t, d, extra], i) => Object.assign({id:`d${s}-${i+1}`, t:`${s}.${i+1} ${t}`, d}, extra || {}));
const STD = (s, mid) => clips(s, [['Topic Introduction','1:08'], ...mid, ['Summary','1:22']]);
const dockerOutline = [
{n:1, t:'Introduction to Docker', lessons: clips(1, [['Course Introduction','2:41'],['Getting Started with Containerization','0:48',{view:'video'}],['Containerization Core Concepts (Namespaces and Cgroups)','6:12'],['Available Container Toolsets','4:05'],['Docker at a Glance','3:37'],['Docker Architecture','5:20'],['Core Docker Objects (Images, Containers, Networks and Volumes)','7:46'],['Demonstration – Official Docker Documentation Walkthrough','6:58'],['Summary','1:52']])},
{n:2, t:'Getting Started with Docker', lessons: clips(2, [['Topic Introduction','1:10'],['Docker Installation Methods','4:22'],['Demonstration – Installing Docker Engine on Linux','8:15'],['Docker Playground','3:40'],['Introduction to Docker CLI','6:05'],['Summary','1:20']])},
{n:3, t:'Docker Images', lessons: STD(3, [['Image Layers','5:12'],['Demonstration – Pulling and Inspecting Images','7:30']])},
{n:4, t:'Docker Containers', lessons: STD(4, [['Container Lifecycle','4:48'],['Demonstration – Running and Managing Containers','9:02']])},
{n:5, t:'Building an Image with a Dockerfile', lessons: STD(5, [['Dockerfile Instructions','6:40'],['Demonstration – Building Your First Image','8:11']])},
{n:6, t:'Docker Networking', lessons: STD(6, [['Network Drivers','5:55'],['Demonstration – Connecting Containers','7:18']])},
{n:7, t:'Docker Volumes', lessons: STD(7, [['Volumes and Bind Mounts','5:03'],['Demonstration – Persisting Data','6:47']])},
{n:8, t:'Containerizing Applications (Sample Project Using Python)', lessons: clips(8, [['Topic Introduction','1:05'],['The Sample Project: NyxAcademy','3:30'],['Writing the Dockerfile','Text',{view:'code'}],['Demonstration – Building and Running NyxAcademy','7:45'],['Summary','1:15']])},
{n:9, t:'Docker Compose', lessons: STD(9, [['Compose File Basics','5:36'],['Demonstration – Multi-container NyxAcademy','9:20']])}
];
const serverlessOutline = [
{n:1, t:'Course Overview', lessons:[{id:'v1-1', t:'Course overview', d:'4 pages'}]},
{n:2, t:'Best Practices for Writing Functions', lessons:[{id:'v2-1', t:'Writing functions', d:'59 pages', view:'slides'}]},
{n:3, t:'Best Practices for Serverless Architecture', lessons:[{id:'v3-1', t:'Architecture', d:'42 pages'}]},
{n:4, t:'Best Practices for Security and Observability', lessons:[{id:'v4-1', t:'Security and observability', d:'37 pages'}]}
];
const systemicOutline = [
{n:0, t:'Before you start', lessons:[{id:'y0-1', t:'Choose your track: Rust or TypeScript', d:'Text'},{id:'y0-2', t:'The naive engine', d:'Exercise'}]},
{n:1, t:'Part I: Hearthstone', lessons:['Cards and zones','Triggers','Death phases','Auras','Text assembly'].map((t,i)=>({id:`y1-${i}`, t, d:'Text'}))},
{n:2, t:'Part II: Baba Is You', lessons:['Rules as pushable text','Parsing','Movement','Win and lose'].map((t,i)=>({id:`y2-${i}`, t, d:'Text'}))},
{n:3, t:'Part III: SpellGrid', lessons:['Combining both systems','Capstone: SpellGrid'].map((t,i)=>({id:`y3-${i}`, t, d:'Text'}))}
];
const COURSES = {
snake:{title:'Pure Snake', mono:'PS', cbg:'#272319', cfg:'#f1b44c', src:'Your course. Tracks: JavaScript, Scala 3, F#', types:['Text','Exercises'], outline:snakeOutline, start:'text'},
docker:{title:'Mastering Docker Essentials – Hands-on DevOps', mono:'MD', cbg:'#18202d', cfg:'#6ea8fe', src:'Packt, Thinknyx Technologies. Imported video package', types:['Video','Text','Files'], outline:dockerOutline, start:'video'},
serverless:{title:'Best Practices in Serverless', mono:'BS', cbg:'#2a1b24', cfg:'#f2709f', src:'Elle Krout, Pluralsight. Imported slides', types:['Slides'], outline:serverlessOutline, start:'slides'},
systemic:{title:'Systemic Design', mono:'SD', cbg:'#192521', cfg:'#86c7a6', src:'Your course. Tracks: Rust, TypeScript', types:['Text','Exercises'], outline:systemicOutline, start:'l/y1-0'}
};
const VIEWS = {text:['snake','s3-r'], exercise:['snake','s3-e1'], video:['docker','d1-2'], code:['docker','d8-3'], slides:['serverless','v2-1']};
const done = new Set();
snakeOutline.slice(0,3).forEach(m => m.lessons.forEach(l => done.add(l.id)));
['d1-1','v1-1','y0-1','y0-2'].forEach(id => done.add(id));
const flat = c => COURSES[c].outline.flatMap(m => m.lessons);
const findLesson = id => { for (const c in COURSES) { const l = flat(c).find(x => x.id === id); if (l) return [c, l]; } return [null, null]; };
const href = l => '#' + (l.view || 'l/' + l.id);
const pct = (ls) => ls.length ? Math.round(100 * ls.filter(l => done.has(l.id)).length / ls.length) : 0;
const cover = (c, cls = '', cw) => `<span class="cover ${cls}" style="--cbg:${COURSES[c].cbg};--cfg:${COURSES[c].cfg}${cw ? ';--cw:' + cw + 'px' : ''}" aria-hidden="true">${COURSES[c].mono}</span>`;
/* ---------- state ---------- */
const S = {course:'snake', lesson:'s3-r', view:'text', lookPref:'reading', open:new Set()};
/* ---------- sidebar ---------- */
function renderSide() {
const c = COURSES[S.course], q = $('#search').value.trim().toLowerCase();
const all = flat(S.course);
let h = `<div class="side-head">${cover(S.course, '', 36)}<div style="flex:1;min-width:0"><h2>${esc(c.title)}</h2><div class="src">${esc(c.src)}</div>
<div class="prog"><span class="bar"><i style="width:${pct(all)}%"></i></span><span class="tnum">${pct(all)}%</span></div></div></div>`;
let hits = 0;
for (const m of c.outline) {
const ls = q ? m.lessons.filter(l => l.t.toLowerCase().includes(q)) : m.lessons;
if (q && !ls.length && !m.t.toLowerCase().includes(q)) continue;
hits++;
const cur = m.lessons.some(l => l.id === S.lesson);
const open = q || S.open.has(S.course + m.n);
const d = m.lessons.filter(l => done.has(l.id)).length, p = pct(m.lessons);
h += `<div class="mod${cur ? ' is-current' : ''}"><button class="mod-head" aria-expanded="${!!open}" data-mod="${m.n}"><span class="n tnum">${m.n}</span><span>${esc(m.t)}</span><span class="c tnum">${d}/${m.lessons.length}</span>${ICON.chev}</button>
<span class="bar${p === 100 || !cur ? ' dim' : ''}" aria-hidden="true"><i style="width:${p}%"></i></span>`;
if (open) h += '<ol class="lessons">' + (q ? ls : m.lessons).map(l => `<li><a class="les${done.has(l.id) ? ' done' : ''}" href="${href(l)}"${l.id === S.lesson ? ' aria-current="page"' : ''}><span class="m">${done.has(l.id) ? ICON.check : ''}</span><span>${esc(l.t)}<span class="sr">${done.has(l.id) ? ', done' : l.id === S.lesson ? ', current' : ''}</span></span><span class="d tnum">${esc(l.d)}</span></a></li>`).join('') + '</ol>';
h += '</div>';
}
if (!hits) h += `<p class="nohit">No lessons match “${esc(q)}”.</p>`;
$('#side').innerHTML = h;
}
$('#side').addEventListener('click', e => {
const b = e.target.closest('.mod-head'); if (!b) { if (e.target.closest('.les')) closeSide(); return; }
const k = S.course + b.dataset.mod; S.open.has(k) ? S.open.delete(k) : S.open.add(k);
renderSide(); $(`.mod-head[data-mod="${b.dataset.mod}"]`).focus();
});
/* ---------- header ---------- */
function renderHeader() {
$('#courseTile').innerHTML = S.view === 'home' ? `<span style="width:24px;display:grid;place-items:center;color:var(--faint)">${ICON.dir}</span>` : cover(S.course, 'tiny');
$('#courseName').textContent = S.view === 'home' ? 'All courses' : COURSES[S.course].title;
$('#courseMenu').innerHTML = `<li><a href="#home"${S.view === 'home' ? ' aria-current="page"' : ''}><span style="width:24px;display:grid;place-items:center;color:var(--faint)">${ICON.dir}</span><span><span class="mt">Library</span><span class="ms">All four courses</span></span></a></li>` +
Object.entries(COURSES).map(([k, c]) => `<li><a href="#${c.start}"${k === S.course && S.view !== 'home' ? ' aria-current="page"' : ''}>${cover(k, 'tiny')}<span><span class="mt">${esc(c.title)}</span><span class="ms">${esc(c.src.split('.')[0])}</span></span></a></li>`).join('');
$('#search').placeholder = S.view === 'home' ? 'Search the library' : 'Search this course';
document.querySelectorAll('.review a').forEach(a => a.toggleAttribute('aria-current', a.getAttribute('href') === '#' + S.view));
}
const menuBtn = $('#courseBtn'), menu = $('#courseMenu');
function setMenu(open) { menu.hidden = !open; menuBtn.setAttribute('aria-expanded', open); if (open) menu.querySelector('a').focus(); }
menuBtn.addEventListener('click', () => setMenu(menu.hidden));
menu.addEventListener('click', e => { if (e.target.closest('a')) setMenu(false); });
document.addEventListener('click', e => { if (!menu.hidden && !e.target.closest('.switch')) setMenu(false); });
function applyLook() {
const look = S.view === 'exercise' ? 'work' : S.lookPref;
document.body.dataset.look = look;
$('#lookReading').setAttribute('aria-pressed', look === 'reading');
$('#lookWork').setAttribute('aria-pressed', look === 'work');
}
$('#lookReading').onclick = () => { S.lookPref = 'reading'; if (S.view === 'exercise') toast('Exercises always use Work. Reading applies to text lessons.'); applyLook(); };
$('#lookWork').onclick = () => { S.lookPref = 'work'; applyLook(); };
$('#search').addEventListener('input', () => S.view === 'home' ? renderLib() : renderSide());
$('#outlineBtn').onclick = () => { const y = $('.top').getBoundingClientRect().bottom + 'px'; $('#side').style.top = $('#scrim').style.top = y; const o = document.body.classList.toggle('side-open'); $('#outlineBtn').setAttribute('aria-expanded', o); };
function closeSide() { document.body.classList.remove('side-open'); $('#outlineBtn').setAttribute('aria-expanded', false); }
$('#scrim').onclick = closeSide;
matchMedia('(min-width: 961px)').addEventListener('change', e => { if (e.matches) $('#side').style.top = $('#scrim').style.top = ''; });
/* ---------- dock ---------- */
function neighbours() { const ls = flat(S.course), i = ls.findIndex(l => l.id === S.lesson); return [ls[i-1], ls[i+1]]; }
function renderDock() {
const home = S.view === 'home'; $('#dock').hidden = home; if (home) return;
const [p, n] = neighbours(), isDone = done.has(S.lesson);
$('#prev').disabled = !p; $('#prev').title = p ? 'Previous: ' + p.t + ' ([)' : 'First lesson';
$('#next').disabled = !n; $('#next').title = n ? 'Next: ' + n.t + ' (])' : 'Last lesson';
$('#done').setAttribute('aria-pressed', isDone); $('#doneLbl').textContent = isDone ? 'Done' : 'Mark done';
}
const go = d => { const n = neighbours()[d]; if (n) location.hash = href(n); };
$('#prev').onclick = () => go(0); $('#next').onclick = () => go(1);
$('#done').onclick = () => { done.has(S.lesson) ? done.delete(S.lesson) : done.add(S.lesson); renderDock(); renderSide(); };
let toastT; function toast(msg) { const t = $('#toast'); t.textContent = msg; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(() => t.hidden = true, 2600); }
/* ---------- router ---------- */
function route() {
let h = location.hash.slice(1) || 'text';
if (document.fullscreenElement) document.exitFullscreen();
let course, lesson, view = h, tpl = h;
if (VIEWS[h]) [course, lesson] = VIEWS[h];
else if (h === 'home') { course = S.course; lesson = null; }
else if (h.startsWith('l/')) { [course] = findLesson(h.slice(2)); lesson = h.slice(2); view = 'lesson'; tpl = 'generic'; }
if (!course) { location.hash = '#text'; return; }
if (course !== S.course) $('#search').value = '';
Object.assign(S, {course, lesson, view});
document.body.dataset.course = course;
document.body.classList.toggle('no-side', view === 'home');
if (lesson) { const m = COURSES[course].outline.find(m => m.lessons.some(l => l.id === lesson)); S.open.add(course + m.n); }
const v = $('#view'); v.replaceChildren($('#tpl-' + tpl).content.cloneNode(true));
(INIT[tpl] || (() => {}))(v);
applyLook(); renderHeader(); renderSide(); renderDock(); closeSide();
const cur = $('.les[aria-current]'); if (cur) cur.scrollIntoView({block:'nearest'});
document.title = (view === 'home' ? 'Library' : findLesson(lesson)[1].t) + ' – Homelab Academy';
window.scrollTo(0, 0);
}
const INIT = {};
INIT.text = v => {
const seg = $('[data-trackseg]', v);
const sync = () => seg.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', b.dataset.tr === document.body.dataset.track));
seg.onclick = e => { const b = e.target.closest('button'); if (b) { document.body.dataset.track = b.dataset.tr; sync(); } };
sync();
};
INIT.generic = () => {
const [c, l] = findLesson(S.lesson), m = COURSES[c].outline.find(m => m.lessons.includes(l));
$('#gChap').textContent = (c === 'docker' ? 'Section ' : c === 'systemic' ? '' : 'Module ') + (c === 'systemic' ? m.t : m.n + ': ' + m.t);
$('#gTitle').textContent = l.t;
};
const ATTACH = [['Thinknyx_Docker_CheatSheet.pdf','1.2 MB'],['Thinknyx_Mastering_Docker_Essentials_Handson_DevOps.pdf','8.6 MB']];
const attachRows = () => ATTACH.map(([n, s]) => `<li>${ICON.pdf}<a href="#" data-dl="${n}">${n}</a><span class="sz tnum">${s}</span></li>`).join('');
document.addEventListener('click', e => {
const a = e.target.closest('[data-dl]'); if (a) { e.preventDefault(); toast('Mockup: would download ' + a.dataset.dl); }
const c = e.target.closest('[data-copy]'); if (c) copy($('#' + c.dataset.copy).textContent, c);
});
function copy(text, btn) {
const ok = () => { const o = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => btn.textContent = o, 1400); };
(navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject()).then(ok, () => {
const t = document.createElement('textarea'); t.value = text; document.body.append(t); t.select();
try { document.execCommand('copy'); ok(); } catch (_) {} t.remove();
});
}
INIT.video = v => {
$('#attach1', v).innerHTML = attachRows();
const vid = $('#vid', v), ta = $('#notesTa', v);
const fmt = s => Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
vid.addEventListener('timeupdate', () => $('#stampT').textContent = fmt(vid.currentTime));
$('#stamp', v).onclick = () => {
const ins = `[${fmt(vid.currentTime)}] `, p = ta.selectionStart;
ta.value = ta.value.slice(0, p) + (p && ta.value[p-1] !== '\n' ? '\n' : '') + ins + ta.value.slice(ta.selectionEnd);
ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.indexOf(ins, p) + ins.length;
};
};
const SLIDES = 8, PAGES = 59; let slide = 0;
INIT.slides = v => {
slide = 0;
$('#thumbs', v).innerHTML = Array.from({length:SLIDES}, (_, i) => `<button data-s="${i}" aria-label="Slide ${i+1}"><img src="deck-0${i+1}.png" alt="" loading="lazy"></button>`).join('') +
`<span class="more">Pages 9–${PAGES} not in this mockup</span>`;
$('#thumbs', v).onclick = e => { const b = e.target.closest('[data-s]'); if (b) showSlide(+b.dataset.s); };
$('#sPrev', v).onclick = () => showSlide(slide - 1);
$('#sNext', v).onclick = () => showSlide(slide + 1);
$('#sFull', v).onclick = () => document.fullscreenElement ? document.exitFullscreen() : $('#stage').requestFullscreen().catch(() => toast('Fullscreen is not available here'));
showSlide(0);
};
const SLIDE_ALT = ['Title: Best Practices in Serverless, Elle Krout','Section: Best Practices for Writing Functions','Keep Code Small: single purpose, idempotent, limit dependencies','Keep Code Small','Keep Code Small: each bit of pathing should be its own function','Keep Code Small','Keep Code Small','Keep Code Small: reuse functions'];
function showSlide(i) {
if (i < 0 || i >= SLIDES) { if (i >= SLIDES) toast(`Pages ${SLIDES + 1}–${PAGES} are not part of this mockup`); return; }
slide = i; const img = $('#slideImg');
img.src = `deck-0${i+1}.png`; img.alt = `Slide ${i+1}: ${SLIDE_ALT[i]}`;
$('#sCount').textContent = `${i+1} / ${PAGES}`;
$('#sPrev').disabled = i === 0;
document.querySelectorAll('#thumbs [data-s]').forEach(b => b.setAttribute('aria-current', +b.dataset.s === i));
const cur = $(`#thumbs [data-s="${i}"]`); if (cur) cur.scrollIntoView({block:'nearest', inline:'nearest'});
}
const REPO = 'S08 - Containerizing Applications/NyxAcademy-main/';
const FILES = {
'Dockerfile.example':{lang:'docker', src:`# NyxAcademy: the Django sample app from Section 8
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \\
PYTHONUNBUFFERED=1
WORKDIR /app
RUN pip install --no-cache-dir "django>=4.2,<5"
COPY . .
RUN chmod +x startup.sh
EXPOSE 8000
CMD ["./startup.sh"]`},
'startup.sh':{lang:'sh', src:`#!/bin/sh
# Runs every time the container starts
set -e
python manage.py migrate --noinput
python manage.py collectstatic --noinput
# 0.0.0.0, not 127.0.0.1: the published port must reach it
exec python manage.py runserver 0.0.0.0:8000`},
'NyxAcademy/settings.py':{lang:'py', src:`# Excerpt: the lines the container depends on
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"]
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"`}
};
function hl(src, lang) {
return esc(src).split('\n').map(line => {
let l = line;
if (/^\s*#/.test(l)) l = `<span class="c">${l}</span>`;
else if (lang === 'docker') l = l.replace(/^(FROM|RUN|COPY|WORKDIR|ENV|EXPOSE|CMD)\b/, '<span class="k">$1</span>');
else if (lang === 'py') l = l.replace(/\s(#.*)$/, ' <span class="c">$1</span>');
return `<span class="ln">${l || ' '}</span>`;
}).join('');
}
INIT.code = v => {
$('#attach8', v).innerHTML = attachRows();
const f = (p, cls = '') => `<button class="${cls}" data-f="${p}">${ICON.file}${p.split('/').pop()}</button>`;
const s = n => `<span class="f">${ICON.file}${n}</span>`;
$('#tree', v).innerHTML = `<div class="dir">${ICON.dir}NyxAcademy-main</div>${f('Dockerfile.example')}${s('manage.py')}${f('startup.sh')}
<div class="dir sub">${ICON.dir}NyxAcademy</div>${f('NyxAcademy/settings.py', 'l2')}<span class="f" style="padding-left:40px">${ICON.file}urls.py</span>
<div class="dir sub">${ICON.dir}templates</div><div class="dir sub">${ICON.dir}static</div>`;
$('#tabs', v).innerHTML = Object.keys(FILES).map(p => `<button role="tab" data-f="${p}">${p.split('/').pop()}</button>`).join('') + '<button class="btn sm quiet" id="copyFile" style="font-family:var(--sans)">Copy</button>';
v.querySelector('.files').addEventListener('click', e => { const b = e.target.closest('[data-f]'); if (b) openFile(b.dataset.f); });
$('#copyFile', v).onclick = e => copy(FILES[openFile.cur].src, e.currentTarget);
$('#zip', v).onclick = () => toast('Mockup: would download “S08 - Containerizing Applications.zip” (7 files, 41 KB)');
openFile('Dockerfile.example');
};
function openFile(p) {
openFile.cur = p;
document.querySelectorAll('#tabs [data-f]').forEach(b => b.setAttribute('aria-selected', b.dataset.f === p));
document.querySelectorAll('#tree [data-f]').forEach(b => b.setAttribute('aria-current', b.dataset.f === p));
$('#fpath').textContent = 'From the sample repo: ' + REPO + p;
$('#fcode').innerHTML = hl(FILES[p].src, FILES[p].lang);
}
/* ---------- exercise: tests run in this page ---------- */
const STUB = `import { delta, point } from './model.js'
// Exercise 3.1: the cell one step from p in direction dir.
// y grows downward, so 'Up' is y - 1.
export const move = (p, dir) => {
const d = delta(dir)
p.x = p.x + d.x
p.y = p.y + d.y
return p
}
`;
let draft = STUB;
const fmtP = o => o && typeof o === 'object' ? `{x: ${o.x}, y: ${o.y}}` : String(o);
function runTests(src) {
const prelude = "const delta = d => ({Up:{x:0,y:-1}, Down:{x:0,y:1}, Left:{x:-1,y:0}, Right:{x:1,y:0}})[d];\nconst point = (x, y) => ({x, y});\n";
const body = src.replace(/^\s*import .*$/gm, '').replace(/\bexport\s+/g, '');
let move;
try { move = new Function(prelude + body + '\n;return typeof move === "function" ? move : undefined;')(); }
catch (e) { return {error: e.name + ': ' + e.message}; }
if (!move) return {error: 'No function named move. Keep the line “export const move = (p, dir) => …”.'};
const cases = [['move-up',{x:2,y:2},'Up',{x:2,y:1}],['move-down',{x:2,y:2},'Down',{x:2,y:3}],['move-left',{x:2,y:2},'Left',{x:1,y:2}],['move-right',{x:2,y:2},'Right',{x:3,y:2}],['move-does-not-wrap-at-the-edge',{x:4,y:2},'Right',{x:5,y:2}]];
const out = cases.map(([name, p, d, exp]) => {
try { const got = move({...p}, d); return {name, call:`move(${fmtP(p)}, '${d}')`, ok: !!got && got.x === exp.x && got.y === exp.y, exp: fmtP(exp), got: fmtP(got)}; }
catch (e) { return {name, call:`move(${fmtP(p)}, '${d}')`, ok:false, exp: fmtP(exp), got: 'threw ' + e.message}; }
});
const input = {x:2, y:2}; let r;
try { r = move(input, 'Up'); } catch (e) { r = e; }
const pure = input.x === 2 && input.y === 2 && r !== input;
out.push({name:'move-leaves-its-input-unchanged', call:"const p = {x: 2, y: 2}; move(p, 'Up')", ok: pure, exp:'p is still {x: 2, y: 2}', got: r === input ? `p became ${fmtP(input)}, and move returned p itself` : `p is ${fmtP(input)}`});
return {out};
}
function showResults(res) {
const el = $('#results');
if (!res) { el.innerHTML = `<div class="res-sum"><span>Not run yet. Six checks: five fixture cases and one purity check.</span></div>`; return; }
if (res.error) { el.innerHTML = `<div class="res-sum"><span><b>The code did not load.</b> ${esc(res.error)}</span></div>`; return; }
const n = res.out.filter(t => t.ok).length, all = n === res.out.length;
el.innerHTML = `<div class="res-sum" aria-live="polite"><span><b class="tnum">${n} of ${res.out.length}</b> passed${all ? '. Mark the exercise done when you are ready.' : ''}</span></div><ul class="res-list">` +
res.out.map(t => `<li class="${t.ok ? 'pass' : 'fail'}"><div class="res-row"><span class="s">${t.ok ? 'Pass' : 'Fail'}</span><code>${esc(t.name)}</code></div>` +
(t.ok ? '' : `<dl class="diff"><dt>Call</dt><dd>${esc(t.call)}</dd><dt>Expected</dt><dd>${esc(t.exp)}</dd><dt>Actual</dt><dd>${esc(t.got)}</dd></dl>`) + '</li>').join('') + '</ul>';
}
INIT.exercise = v => {
const ta = $('#code', v), gut = $('#gutter', v);
const lines = () => { gut.textContent = ta.value.split('\n').map((_, i) => i + 1).join('\n'); gut.scrollTop = ta.scrollTop; };
ta.value = draft; lines();
ta.addEventListener('input', () => { draft = ta.value; lines(); });
ta.addEventListener('scroll', () => gut.scrollTop = ta.scrollTop);
ta.addEventListener('keydown', e => {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); draft = ta.value; lines(); }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); $('#run').click(); }
if (e.key === 'Escape') ta.blur();
});
$('#run', v).onclick = () => showResults(runTests(ta.value));
$('#reset', v).onclick = () => { ta.value = draft = STUB; lines(); showResults(null); toast('Code reset to the starting stub'); };
showResults(null);
};
/* ---------- library ---------- */
function renderLib() {
const q = $('#search').value.trim().toLowerCase();
const rows = Object.entries(COURSES).filter(([, c]) => !q || (c.title + c.src).toLowerCase().includes(q));
$('#lib').innerHTML = rows.map(([k, c]) => {
const ls = flat(k), p = pct(ls);
return `<a class="lib-row" href="#${c.start}"><span class="lib-main">${cover(k, '', 44)}<span style="min-width:0"><span class="lt">${esc(c.title)}</span><span class="ls" style="display:block">${esc(c.src)}</span></span></span>
<span class="lnum tnum">${ls.length}</span><span class="tags">${c.types.map(t => `<span class="tag">${t}</span>`).join('')}</span>
<span class="prog"><span class="bar${p === 0 ? '' : ''}"><i style="width:${p}%"></i></span><span class="tnum" style="min-width:3ch;text-align:right">${p}%</span></span></a>`;
}).join('') || `<p class="nohit">No courses match “${esc(q)}”.</p>`;
}
function nextUp() { const m = snakeOutline.find(m => m.lessons.some(l => !done.has(l.id))), l = m.lessons.find(l => !done.has(l.id)); return [m, l]; }
INIT.home = () => {
const [m, l] = nextUp(), d = m.lessons.filter(x => done.has(x.id)).length;
$('#contS').textContent = `Module ${m.n}, ${m.t}. Next up: ${l.t === m.t ? 'the reading' : l.t}`;
$('#contBar').style.width = pct(m.lessons) + '%';
$('#contN').textContent = `${d} of ${m.lessons.length} in this module`;
$('#contBtn').href = href(l);
renderLib();
};
/* ---------- keyboard ---------- */
document.addEventListener('keydown', e => {
const t = e.target, typing = t.closest && t.closest('input, textarea, [contenteditable]');
if (e.key === 'Escape') { if (!menu.hidden) { setMenu(false); menuBtn.focus(); } else if (t.id === 'search') { t.value = ''; t.dispatchEvent(new Event('input')); t.blur(); } closeSide(); return; }
if (typing || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === ']') { e.preventDefault(); S.view === 'home' ? location.hash = href(nextUp()[1]) : go(1); }
else if (e.key === '[') { e.preventDefault(); go(0); }
else if (e.key === '/') { e.preventDefault(); const s = $('#search'); if (getComputedStyle(s.closest('.search')).display === 'none') return; s.focus(); }
else if (S.view === 'slides' && (e.key === 'ArrowRight' || e.key === 'ArrowLeft') && !(t.closest && t.closest('video'))) { e.preventDefault(); showSlide(slide + (e.key === 'ArrowRight' ? 1 : -1)); }
});
window.addEventListener('hashchange', route);
route();
</script>
</body>
</html>
@@ -0,0 +1,333 @@
# STCourses: design
Date: 2026-10-30. Status: draft for approval.
STCourses is a self-hosted course platform that one person or one organisation runs for its own learners. It mixes three things: the reading comfort of Packt's book reader, the structured video courses of Pluralsight, and boot.dev's habit of learning by doing small exercises. Course content lives in plain files that are the single source of truth. A browser studio edits those files. AI features are optional and off by default.
Background research on existing open-source platforms is in `reports/Open source course platforms.md`. None of them fits: they are too heavy (Open edX), too dated (Moodle), put key features in paid tiers (LearnHouse), or keep content in a database. That is why this is a new build.
## 1. Who it is for, and why
- **Homelabbers.** Host your own courses, plus course material you already own, such as a Packt video package or a Pluralsight slide PDF, in one place that tracks your progress.
- **Universities and companies.** Run your own catalogue of self-made courses, with your own sign-in, on your own hardware.
- **Course authors.** Write in Markdown in a git repo, or in a browser studio. Both edit the same files.
Each install has **one owner** (a person or an organisation). It is not multi-tenant.
The two reference courses are the quality bar. Everything in this design must work for them without rewriting them:
- `/home/user/functional-programming-course` (Pure Snake): 12 modules, three language tracks (JS, Scala 3, F#) that share JSON fixtures, stub files the learner fills in, three hint tiers per exercise, "check your understanding" questions, and a `MENTOR.md` that tells an AI tutor how to behave.
- `/home/user/systemic-design-course`: Rust and TypeScript tracks, scenario-named tests, a workspace template, module gating, the Observe → Predict → Spec → Build → Name it → Expansion Drop pattern, and an Astro site.
Real imported material is in `/home/user/example_course_content`:
- a Packt video package: a zip with about 50 MP4s and an old HTML player whose `index.html` holds the section and clip titles and their order;
- the companion code repo, with one folder per section, PDF slides, a cheat sheet and a Django sample app;
- a 59-page Pluralsight slide PDF.
This material is licensed to the user. Nothing from it is committed to this repo.
## 2. Goals, non-goals, anti-goals
**Goals**
1. Comfortable lessons of several types, in one consistent interface:
- long-form text;
- text with video;
- slides attached to the video or section they belong to;
- text with downloadable code files;
- small in-browser exercises.
2. Course content is files on disk, versioned with git. The database never holds course content.
3. Importing purchased material produces an ordinary course folder. Supported sources: video packages, slide PDFs and code repos or zips.
4. A browser studio that edits course files and commits them.
5. Opt-in AI:
- a course-aware assistant for learners;
- a studio generator that drafts documents, exercises and knowledge checks.
6. Easy self-hosting: one Docker image, SQLite by default, and external PostgreSQL as an option.
**Non-goals for now**
- Payments, storefronts, marketplaces, prices, ratings and revenue share.
- Multi-tenancy, meaning several separate organisations on one install.
- Running full course projects on the server. Learners run projects on their own machine. Only small exercises run in the browser.
- Mandatory video transcoding and DRM.
- SCORM, xAPI, cmi5 and LTI. They may come later, for example LTI so universities can embed courses. Nothing in this design should block them.
**Anti-goals** (failure, even if it technically works)
- Course content stored only in database tables, or in a proprietary format.
- Idle memory use above 2 GB.
- The visual style the user has rejected: glowing gradients, card grids, decorative accents, and interface elements themed to one course's subject (for example a snake-shaped progress bar).
- An AI mentor that silently hands out solutions when the course asks it not to, or an AI that publishes generated material without an author reviewing it.
- Unofficial redistribution of licensed imports. Imported courses are private by default.
## 3. Constraints and decided technology
These were decided with the user:
- **Runtime:** Bun, following the conventions in the `CLAUDE.md` that `bun init` generates:
- serving and routing: `Bun.serve()` with routes and WebSockets, and HTML imports for the frontend;
- databases: `bun:sqlite` for SQLite and `Bun.sql` for Postgres;
- tooling: `bun test`, `bun install`, `bunx`, `Bun.file`, and `` Bun.$ `` for shell commands;
- not used: Express, Vite, dotenv, `pg`, `better-sqlite3`.
- **Language:** TypeScript throughout.
- **Frontend:** a React single-page app served by `Bun.serve` through HTML imports. Next.js was considered and rejected. Its memory use leaves less of the 2 GB budget, it duplicates Bun's own server and bundler, and most pages are interactive and behind sign-in. If public pages need to be visible to search engines later, render just those pages with React's `renderToReadableStream` inside `Bun.serve`.
- **Database:**
- SQLite by default;
- external PostgreSQL through one connection-string setting;
- user data only (section 7);
- both databases are supported and tested in CI.
- **Course history:** git, through the `git` command line.
- **Sign-in:** OAuth/OIDC, SAML, social logins and username/password. The user suggested Better Auth (or Passport.js).
- **Memory:** at most 2 GB at idle for the whole deployment, including the database when Postgres runs in the same compose file.
- **License:** AGPL-3.0.
- **Deployment:** one Docker image, plus an optional docker-compose file that adds Postgres.
- **Repository:** `https://gitea.lucaswinther.info/lucasw89/STCourses.git`, default branch `main`.
## 4. The learner experience
Visual reference: `docs/design/mockups/platform-v1.html`. It is a click-through mockup that the user approved in direction. Its media files are not committed, so the video and slides appear blank when it is opened from this repo.
### 4.1 Visual direction (approved)
- **Theme.**
- Dark by default: ground `#12141a`, side surfaces `#181b22`, raised `#232733`, hairlines `#262a33`/`#343947`.
- A light theme is designed separately, not created by inverting the dark one.
- **Accent.**
- One accent colour: amber `#f1b44c`, with a dim variant `#8c7440`.
- It is used only for: the current item, progress, and the primary next action.
- A course can set its own accent in its manifest. The Docker course in the mockup uses blue `#6ea8fe`.
- **Type.**
- IBM Plex Sans for all interface text.
- Literata only for lesson titles and reading text: about 19px with line height 1.75, in a centred column about 660px wide.
- IBM Plex Mono for code.
- Fonts are bundled locally, not loaded from Google, so the platform works offline.
- **Structure.** Pill-shaped controls, rows separated by thin rules, and thin 4px progress bars. No gradients, glows, card grids, or labels in all caps.
- **Course-neutral interface.** A course brings only a cover image (or a generated monogram) and an optional accent colour.
- **Reading / Work toggle.** Reading is the roomy serif column. Work is denser sans text in a wider column. Exercise pages always use Work.
- **Floating action bar.** A pill at the bottom of the reading column holds "Previous", "Mark done" and "Next lesson".
- **Keyboard shortcuts.** Only the expected ones: arrow keys in the slide viewer, Escape to close overlays, and normal Tab focus. No `[`/`]` or `/` shortcuts and no shortcut hints. They may return later as an optional setting.
- **Responsive and accessible.** Works down to phone width, with visible focus and respect for reduced-motion settings.
### 4.2 Library (home)
The current mockup is "good and simple" but lacks the pull of browsing for your next course, the way Kickstarter browsing feels. The library has these parts:
- **Featured course.** The owner picks it. It shows a large cover, a one-line pitch and a Start button.
- **Continue row.** Lessons you have in progress.
- **Browsable rows.** "New in the library", then one row per collection. Each row is a horizontal strip of covers with thin progress bars.
- **Collections.** Defined by the owner, for example "Functional programming", "Game design" and "DevOps". A course can belong to several.
- **Filters.** By format (text, video, slides, exercises), length, and "in progress".
- **Search** across course titles, descriptions and lesson titles.
- **Cover images.**
- Use the course's own artwork when it has some.
- Otherwise use a frame taken from its first video, or its first slide, at import.
- Otherwise use a typographic monogram.
- **No storefront signals.** No prices, ratings or "bestseller" badges.
- **Scale.** It should feel right with 5 courses (a homelab) and with several hundred (a company or university).
### 4.3 Lesson types
1. **Text lesson.**
- Markdown with headings, code blocks, figures, callouts and folded "think about it first" sections.
- Hint tiers render as folded sections, opened in order.
- Multi-track courses show a track switcher (JS / Scala 3 / F#) that swaps the code samples and file names.
2. **Video lesson.**
- A video player (seeking needs HTTP range requests) with a transcript, if one exists, and personal notes.
- The notes have an "insert timestamp" action that links back to that moment in the video.
3. **Slides belong to something.**
- A slide deck is never a standalone lesson. It is attached to the video, lesson or section it belongs to.
- On a video lesson, the deck is a companion panel next to or under the video, and you can page through it on its own.
- On a section, the deck appears as part of that section.
- The viewer has a 16:9 stage, thumbnails, previous/next, arrow keys, fullscreen, and a page counter.
- Keeping the slide in step with the video's time is a later nice-to-have.
4. **Notes on slides.**
- Notes attach to a specific slide page.
- Coming back to the slide shows its note.
- All notes for a lesson, whether on slides or video timestamps, can be read together as one list.
5. **Code files.**
- A file tree and tabbed code viewer of the course's companion code for that section, with copy buttons.
- "Download files for this section" gives a zip.
- A command block shows how to run it locally.
- An attachments list holds PDFs and cheat sheets.
6. **In-browser exercise.**
- Instructions with a small editor (CodeMirror is acceptable).
- Tests run in the browser in a sandbox, a Web Worker or sandboxed iframe, never in the page itself.
- The first version supports JavaScript and TypeScript. Other languages (Python via Pyodide, and later others) come through a pluggable runner interface.
- Results show pass/fail per test, with expected and actual values. Hint tiers and a Reset button are included.
- The page shows the local command for running the full project.
- Exercises in tracks without a browser runner show "run locally" instead of an editor.
### 4.4 Progress
- Per-lesson state: not started, in progress or done. For video, the position reached also counts.
- Progress rolls up to modules and to the course.
- "Mark done" is manual for text and slides. Video counts as done when watched near the end. An exercise counts as done when all its tests pass.
## 5. Course format on disk
Phase 0 (section 10) pins down the exact schema. These principles are fixed:
- **A course is a folder,** normally its own git repo, under a configured courses directory. It has a manifest file at its root that gives:
- id, title, pitch, authors, cover, accent and collections;
- visibility, tracks and source/import info;
- an ordered list of modules and lessons, or a convention that derives the order from file names.
- **Lessons are Markdown files** with a small front matter block (type, title, duration, attachments, linked deck, linked video and track variants). Lesson types are listed in section 4.3.
- **Attachments live with the course:** videos, PDFs, images and code. Large binaries may live outside git, in a configured media directory, and be referenced by path and checksum. Phase 0 decides between that and Git LFS.
- **Slide decks are PDF files.** The manifest or lesson front matter links each deck to its video, lesson or section. Page images and extracted text are derived caches, not source.
- **Exercises** declare the starting code, tests, hint tiers and solution per track. The solution is visible only to authors.
- **Mentor rules:** a course may ship AI tutor instructions, such as Pure Snake's `MENTOR.md`, referenced from the manifest.
- **Compatibility.**
- The two reference courses must load by adding only a manifest and front matter, without moving or rewriting their existing files.
- Their existing tooling (test scripts, `tools/*.mjs`) must keep working.
- **Derived data lives in a cache directory that can be rebuilt from the files:** search index, slide images and text, transcripts, video posters and AI embeddings.
## 6. Accounts, roles and visibility
**Roles** (my proposal):
- **Owner/admin:** settings, users, sign-in providers, AI configuration and imports.
- **Author:** studio access, either to all courses or per course.
- **Learner.**
**Visibility per course, set by the author:**
- public: anyone, no sign-in;
- signed-in: any user;
- restricted: named users or groups.
Imported courses default to restricted, visible only to the importer.
**Sign-in:**
- OIDC/OAuth (including social providers), SAML and username/password.
- Group membership from SAML/OIDC claims can drive the restricted visibility.
## 7. Data stored in the database (user data only)
The database holds:
- users, sessions, sign-in links, roles and groups;
- per-user progress, lesson state and video position;
- notes, each attached to a lesson plus an optional video timestamp or slide page;
- exercise attempts (latest code per track) and results;
- AI settings: the server provider configuration, and each user's own API key stored encrypted (BYOK);
- AI conversation history (optional, user-deletable);
- library curation: featured course and collections. If the owner prefers, these can instead live in a settings file. That is the builder's call.
The same schema and migrations run on SQLite and Postgres. Deleting the database loses user data only, never courses.
## 8. Import
Import turns material the user owns into an ordinary course folder, writing a manifest and front matter. It never edits the original files; it copies them or references them in place. Sources in the first version:
- **Packt video package (zip).** Read the player's `index.html` for sections and clip titles and their order, and map them to `videos/videoN_M.mp4`. Each clip becomes a video lesson.
- **Companion code repo (zip or git URL).** Folders such as `S08 - Containerizing Applications` are matched to sections, by number and by name. PDFs inside them become decks or attachments.
- **Slide PDF (for example from Pluralsight).** It becomes a deck attached to a chosen lesson or section. Its text is extracted for search and AI.
- **Folder of videos** (the "point at a folder" homelab case). It becomes one lesson per file, ordered naturally by file name.
Import runs as a background job with visible progress. It produces cover candidates (a video frame, the first slide). Transcription is an optional, separate job using an external Whisper-style service. The user has `faster-whisper-rs` and `au_whisper_web` locally, which may fit. Transcription is never part of the main process's idle footprint.
Imported material is licensed to its owner. The platform makes imports restricted by default and never exposes import sources publicly unless the owner changes visibility.
## 9. Studio and AI
### 9.1 Studio
- Browser editing of the manifest, lessons (Markdown with a live preview in the real lesson renderer), deck links, attachments and exercises.
- Every save writes files and makes a git commit attributed to the author.
- Work can happen on a draft branch and be published by merging.
- External edits (VS Code, git pull) appear in the studio. Files remain the truth.
- Conflicts between studio edits and external edits must be detected and shown, never silently overwritten.
### 9.2 Opt-in AI
AI is off by default and has two ways to be configured:
- **Server provider, set by the admin** for everyone: Anthropic, any OpenAI-compatible endpoint (including a self-hosted Ollama), or others through a provider interface.
- **Bring your own key (BYOK):** a learner or author adds their own key, used only for their own requests.
**Learner assistant**
- It is course-aware. It retrieves from the course files, transcripts, slide text, the companion code and the learner's own notes and progress.
- It gets tools: the platform's own course and notes tools, plus MCP servers configured by the admin, globally or per course.
- It answers questions, explains and quizzes.
- **Mentor mode.**
- The course's mentor rules (for example "never write the solution") apply by default.
- The learner may switch them off.
- When they are off, the interface says so plainly, for example "Mentor mode off: I'll give full answers".
**Studio generator**
- It drafts documents, exercises (with tests and hint tiers) and knowledge-check questions from material the author can access: videos via transcripts, text, slides and code.
- It follows the house style of the reference courses:
- Pure Snake's module shape: why, objectives, concepts, a puzzle before the explanation, exercises with nudge / stronger hint / near-solution, and check questions;
- Systemic Design's Observe → Predict → Spec → Build → Name it → Expansion Drop cycle;
- shared fixtures across tracks.
- Output is always files on a draft branch, or marked as drafts, for an author to review. Nothing is published automatically.
## 10. Delivery approach (decided with the user)
- **Phase 0, the contract (one agent, sequential).** Produce:
- the course format schema and validator;
- the database schema and migrations (SQLite and Postgres);
- the internal module interfaces;
- the repo layout, as one Bun workspace with packages;
- the shared design tokens;
- manifests for both reference courses;
- fixture courses for tests.
- **Phase 1, four parallel agents, each in its own git worktree, building against the contract:**
1. learner app: library, reader, video and slide viewers, exercises, notes, progress, sign-in;
2. importer;
3. studio;
4. AI: assistant and studio generator.
- **Phase 2, integration.** Merge branch by branch. Run everything against Pure Snake, Systemic Design and the real Docker and Serverless material (kept outside the repo). Fix what breaks, and check the 2 GB idle budget.
## 11. Left to the builder
These are the builder's call:
- the exact manifest file format (YAML or JSON) and file layout within the principles in section 5;
- the client router (React Router or TanStack Router);
- the editor component;
- the search engine (SQLite FTS5 against Postgres full-text search, or an in-process index);
- the PDF rendering library;
- the Markdown pipeline;
- how background jobs run, as long as they run in the same process or one small worker;
- whether Better Auth fits Bun's database clients directly or needs a thin adapter. If it is a poor fit, choose an equivalent and record why.
## 12. Decisions settled after the builder review (designer's calls)
The decisions below were made by the designer, not by the user, to settle questions a fresh builder raised.
- **Lesson granularity.**
- A manifest lesson entry points at a file, or at a file plus a heading anchor.
- A Pure Snake module file is one text lesson. Each of its exercises (for example "Exercise 3.1 (move)") is a separate exercise page, addressed by anchor. The hints in the module's `## Hints` section are matched to exercises by their labels.
- Progress is tracked per manifest entry, so per exercise.
- **Metadata location.**
- All course metadata lives in the manifest. Lesson front matter is optional, never required.
- This keeps Systemic Design's `site/scripts/sync-content.mjs` and the Astro site working unchanged.
- **Module gating** is an optional manifest setting per course: `gating: sequential`. It is off by default. When on, the reader locks a module until the previous one is done. Systemic Design turns it on.
- **Git model.**
- Learners see the course's published branch (default `main`), read from a separate worktree that the platform manages. Unpublished drafts never reach learners.
- The studio works in one worktree per author per draft branch. Publishing merges the draft into the published branch.
- The platform watches the published worktree and reloads when it changes, after an external commit or a pull.
- Pushing to and pulling from a course's remote happen only when an author clicks "Sync", and only if a remote is configured. Nothing syncs automatically.
- Merge conflicts are shown in the studio and resolved by the author. The platform never resolves them automatically.
- **Roles and access.**
- The roles in section 6 stand.
- Only the owner can make an imported course public or signed-in visible.
- Restricted visibility uses platform groups, which can be filled from SAML/OIDC group claims.
- **Light theme.** Version 1 ships dark only. The design tokens are structured so a light theme can be added later as a second set of values.
- **Exercise completion when there is no in-browser runner.** The exercise counts as done when the learner marks it done. A "verify locally" command is shown, and its passing output can be pasted back as optional evidence.
## 13. Decided by the user after the builder review
- **The reference courses' exercises run locally in v1.**
- Pure Snake and Systemic Design exercises do not run in the browser. Their exercise pages show the instructions, hint tiers, the local test command and "Mark done".
- The in-browser runner (section 4.3) only targets new, small exercises authored for it.
- TODO (future feature): a richer browser runner for project-style exercises. It needs a `node:test`/`node:assert` shim, a virtual file system for fixtures, multi-file module graphs, and learner code that carries over between modules.
- **Manifests for existing course repos live in STCourses, not in those repos (option B).**
- The platform supports an overlay manifest stored on the platform side, alongside the course, and pointing at an unmodified course repo.
- The manifests for the reference courses are created this way. Their repos are never written to.
- A course repo may still contain its own manifest. That is the normal case for courses created in the studio. If both exist, the overlay manifest wins.