@neuromatch/course-kit 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/cells/cells.css +121 -0
- package/dist/assets/cells/page.css +71 -0
- package/dist/assets/cells/panel.mjs +27 -0
- package/dist/assets/cells/runtime-widget.mjs +4 -0
- package/dist/assets/feedback/widget.mjs +146 -0
- package/dist/assets/scrolly/engine.mjs +140 -0
- package/dist/assets/scrolly/runnable.css +67 -0
- package/dist/assets/scrolly/runnable.mjs +353 -0
- package/dist/assets/scrolly/scrolly.css +173 -0
- package/dist/build-info.json +4 -0
- package/dist/nma.mjs +8285 -0
- package/dist/nma.yml +9 -0
- package/package.json +38 -4
- package/README.md +0 -3
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Runnable Python panel for scrolly pages (emitted by scrolly.mjs for each
|
|
2
|
+
// visible code cell). One Pyodide runtime per page, shared on window.NMA_PY,
|
|
3
|
+
// started by the first Run (or the "Enable Python" pill in engine.mjs).
|
|
4
|
+
//
|
|
5
|
+
// Notebook semantics: running cell k first runs every earlier cell that
|
|
6
|
+
// hasn't run yet, in order, in one namespace (cells come from engine.mjs via
|
|
7
|
+
// window.NMA_SCROLLY). An earlier exercise that still has an uncommented
|
|
8
|
+
// `raise NotImplementedError` runs its {nma-solution} code instead, so the
|
|
9
|
+
// cells after it work. Same "hidden, not secret" rule as the notebooks.
|
|
10
|
+
//
|
|
11
|
+
// Model: {code, mode: edit|collapsed|output, params, index, exercise,
|
|
12
|
+
// fallback, initial: {stdout, images, error} | null}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PYODIDE_URL = 'https://cdn.jsdelivr.net/pyodide/v314.0.7/full/';
|
|
15
|
+
const UNFINISHED_RE = /^[ \t]*raise NotImplementedError/m;
|
|
16
|
+
const FALLBACK_NOTE = 'An earlier exercise is not finished yet, so its reference solution ran.';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------- pure helpers (unit-tested)
|
|
19
|
+
|
|
20
|
+
export const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
21
|
+
|
|
22
|
+
/** Minimal Python syntax highlighting as HTML. */
|
|
23
|
+
export function highlight(code) {
|
|
24
|
+
const re =
|
|
25
|
+
/(#.*$)|("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')|(\b(?:def|return|import|for|in|as|from|if|else|elif|while|with|lambda|not|and|or|None|True|False|class|try|except|finally|raise|yield|pass|break|continue|global|is|async|await)\b)|(\.\.\.)|(\b\d+(?:\.\d+)?(?:e[-+]?\d+)?\b)|(\b[A-Za-z_]\w*(?=\())/gm;
|
|
26
|
+
let out = '';
|
|
27
|
+
let last = 0;
|
|
28
|
+
for (const m of code.matchAll(re)) {
|
|
29
|
+
out += esc(code.slice(last, m.index));
|
|
30
|
+
const [all, c, s, k, b, n] = m;
|
|
31
|
+
const cls = c ? 'c' : s ? 's' : k ? 'k' : b ? 'b' : n ? 'n' : 'f';
|
|
32
|
+
out += `<i class="tk-${cls}">${esc(all)}</i>`;
|
|
33
|
+
last = m.index + all.length;
|
|
34
|
+
}
|
|
35
|
+
return out + esc(code.slice(last));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A JS value as a Python literal. */
|
|
39
|
+
export function pyLiteral(v) {
|
|
40
|
+
if (v === true) return 'True';
|
|
41
|
+
if (v === false) return 'False';
|
|
42
|
+
if (v === null || v === undefined) return 'None';
|
|
43
|
+
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'float("nan")';
|
|
44
|
+
return JSON.stringify(String(v));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Rewrite the `name = ... # @param ...` lines of `code` with the current
|
|
49
|
+
* control values, keeping the comment. Other lines are untouched.
|
|
50
|
+
*/
|
|
51
|
+
export function applyParams(code, values) {
|
|
52
|
+
return code
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((line) => {
|
|
55
|
+
const m = line.match(/^([A-Za-z_]\w*)(\s*=\s*)(.+?)(\s*#\s*@param\b.*)$/);
|
|
56
|
+
if (!m || !(m[1] in values)) return line;
|
|
57
|
+
return `${m[1]}${m[2]}${pyLiteral(values[m[1]])}${m[4]}`;
|
|
58
|
+
})
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The code to run for an earlier cell: the reader's edit, or the solution if unfinished. */
|
|
63
|
+
export function codeForEarlierCell(cell, edits) {
|
|
64
|
+
const code = edits[cell.index] ?? cell.code;
|
|
65
|
+
if (UNFINISHED_RE.test(code) && cell.fallback) return { code: cell.fallback, fallback: true };
|
|
66
|
+
return { code, fallback: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Value shown for a slider (avoid 0.30000000000000004). */
|
|
70
|
+
export function formatValue(v) {
|
|
71
|
+
return typeof v === 'number' ? String(Number(v.toPrecision(10))) : String(v);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------- shared runtime
|
|
75
|
+
|
|
76
|
+
const PY_SETUP = `
|
|
77
|
+
import os, sys
|
|
78
|
+
os.environ["MPLBACKEND"] = "AGG"
|
|
79
|
+
|
|
80
|
+
async def _nma_exec(src):
|
|
81
|
+
from pyodide.code import eval_code_async
|
|
82
|
+
result = await eval_code_async(src, globals(), return_mode="last_expr")
|
|
83
|
+
if result is not None:
|
|
84
|
+
print(repr(result))
|
|
85
|
+
|
|
86
|
+
def _nma_figures():
|
|
87
|
+
if "matplotlib.pyplot" not in sys.modules:
|
|
88
|
+
return []
|
|
89
|
+
import base64, io, warnings
|
|
90
|
+
import matplotlib.pyplot as plt
|
|
91
|
+
out = []
|
|
92
|
+
for n in plt.get_fignums():
|
|
93
|
+
buf = io.BytesIO()
|
|
94
|
+
with warnings.catch_warnings():
|
|
95
|
+
warnings.simplefilter("ignore")
|
|
96
|
+
plt.figure(n).savefig(buf, format="png", bbox_inches="tight")
|
|
97
|
+
out.append(base64.b64encode(buf.getvalue()).decode())
|
|
98
|
+
plt.close("all")
|
|
99
|
+
return out
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
function createRuntime() {
|
|
103
|
+
return {
|
|
104
|
+
state: 'idle', // idle | loading | ready | error
|
|
105
|
+
msg: '',
|
|
106
|
+
py: null,
|
|
107
|
+
promise: null,
|
|
108
|
+
done: new Set(), // indexes of cells that ran successfully
|
|
109
|
+
queue: Promise.resolve(),
|
|
110
|
+
set(state, msg = '') {
|
|
111
|
+
this.state = state;
|
|
112
|
+
this.msg = msg;
|
|
113
|
+
window.dispatchEvent(new CustomEvent('nma-py', { detail: { state, msg } }));
|
|
114
|
+
},
|
|
115
|
+
ensure() {
|
|
116
|
+
if (this.promise) return this.promise;
|
|
117
|
+
this.promise = (async () => {
|
|
118
|
+
try {
|
|
119
|
+
const url = (window.NMA_SCROLLY && window.NMA_SCROLLY.pyodideUrl) || DEFAULT_PYODIDE_URL;
|
|
120
|
+
this.set('loading', 'Downloading Python (about 10 MB)…');
|
|
121
|
+
const { loadPyodide } = await import(/* webpackIgnore: true */ url + 'pyodide.mjs');
|
|
122
|
+
const py = await loadPyodide({ indexURL: url });
|
|
123
|
+
await py.runPythonAsync(PY_SETUP);
|
|
124
|
+
this.py = py;
|
|
125
|
+
this.set('ready', 'Python is running in your browser');
|
|
126
|
+
return py;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
this.promise = null;
|
|
129
|
+
this.set('error', String((e && e.message) || e));
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
return this.promise;
|
|
134
|
+
},
|
|
135
|
+
async exec(code, onStatus) {
|
|
136
|
+
const py = this.py;
|
|
137
|
+
let out = '';
|
|
138
|
+
py.setStdout({ batched: (line) => (out += line + '\n') });
|
|
139
|
+
py.setStderr({ batched: (line) => (out += line + '\n') });
|
|
140
|
+
try {
|
|
141
|
+
onStatus && onStatus('Loading packages…');
|
|
142
|
+
await py.loadPackagesFromImports(code, { messageCallback: () => {}, errorCallback: () => {} });
|
|
143
|
+
onStatus && onStatus('Running…');
|
|
144
|
+
await py.globals.get('_nma_exec')(code);
|
|
145
|
+
const proxy = py.globals.get('_nma_figures')();
|
|
146
|
+
const images = proxy.toJs();
|
|
147
|
+
proxy.destroy();
|
|
148
|
+
return { stdout: out, images, error: null };
|
|
149
|
+
} catch (e) {
|
|
150
|
+
try {
|
|
151
|
+
py.globals.get('_nma_figures')().destroy();
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore
|
|
154
|
+
}
|
|
155
|
+
const lines = String((e && e.message) || e).trim().split('\n').filter(Boolean);
|
|
156
|
+
return { stdout: out, images: [], error: { summary: lines.at(-1) || 'Error', detail: lines.slice(-12).join('\n') } };
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
/** Run cell `index` with `code`, after any earlier cells that haven't run. Serialised. */
|
|
160
|
+
run(index, code, onStatus) {
|
|
161
|
+
const job = this.queue.then(async () => {
|
|
162
|
+
onStatus && onStatus(this.state === 'ready' ? 'Running…' : 'Starting Python…');
|
|
163
|
+
await this.ensure();
|
|
164
|
+
const page = window.NMA_SCROLLY || { cells: [], edits: {} };
|
|
165
|
+
let usedFallback = false;
|
|
166
|
+
for (const cell of page.cells) {
|
|
167
|
+
if (cell.index >= index || this.done.has(cell.index)) continue;
|
|
168
|
+
const earlier = codeForEarlierCell(cell, page.edits || {});
|
|
169
|
+
onStatus && onStatus(`Running earlier cell ${cell.index + 1}…`);
|
|
170
|
+
const res = await this.exec(earlier.code);
|
|
171
|
+
if (res.error) {
|
|
172
|
+
return {
|
|
173
|
+
stdout: '',
|
|
174
|
+
images: [],
|
|
175
|
+
error: { summary: `Earlier cell ${cell.index + 1} failed: ${res.error.summary}`, detail: res.error.detail },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
usedFallback ||= earlier.fallback;
|
|
179
|
+
this.done.add(cell.index);
|
|
180
|
+
}
|
|
181
|
+
const res = await this.exec(code, onStatus);
|
|
182
|
+
if (!res.error) this.done.add(index);
|
|
183
|
+
if (usedFallback) res.note = FALLBACK_NOTE;
|
|
184
|
+
return res;
|
|
185
|
+
});
|
|
186
|
+
this.queue = job.catch(() => {});
|
|
187
|
+
return job;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const RT = typeof window !== 'undefined' ? (window.NMA_PY ||= createRuntime()) : null;
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------- the widget
|
|
195
|
+
|
|
196
|
+
const LABELS = { edit: 'Python · editable', collapsed: 'Python · interactive', output: 'Output' };
|
|
197
|
+
|
|
198
|
+
function render({ model, el }) {
|
|
199
|
+
const code0 = model.get('code') || '';
|
|
200
|
+
const mode = model.get('mode') || 'edit';
|
|
201
|
+
const params = model.get('params') || [];
|
|
202
|
+
const index = model.get('index') ?? 0;
|
|
203
|
+
const exercise = !!model.get('exercise');
|
|
204
|
+
const initial = model.get('initial');
|
|
205
|
+
const values0 = Object.fromEntries(params.map((p) => [p.name, p.value]));
|
|
206
|
+
|
|
207
|
+
const st = { code: code0, values: { ...values0 }, out: initial, note: '' };
|
|
208
|
+
const box = document.createElement('div');
|
|
209
|
+
box.className = `fl-codebox mode-${mode}`;
|
|
210
|
+
const controls = params
|
|
211
|
+
.map((p, i) =>
|
|
212
|
+
p.kind === 'slider'
|
|
213
|
+
? `<label><span>${esc(p.name)}</span><input type="range" data-i="${i}" min="${p.min}" max="${p.max}" step="${p.step}" value="${p.value}" /><output>${esc(formatValue(p.value))}</output></label>`
|
|
214
|
+
: `<label><span>${esc(p.name)}</span><select data-i="${i}">${p.options
|
|
215
|
+
.map((o, k) => `<option value="${k}"${o === p.value ? ' selected' : ''}>${esc(pyLiteral(o))}</option>`)
|
|
216
|
+
.join('')}</select></label>`,
|
|
217
|
+
)
|
|
218
|
+
.join('');
|
|
219
|
+
const hasEditor = mode !== 'output';
|
|
220
|
+
box.innerHTML = `
|
|
221
|
+
${hasEditor ? `<div class="fl-codehead"><span>${exercise ? 'Exercise · fill in, then run' : LABELS[mode]}</span><span>cell ${index + 1}</span></div>` : ''}
|
|
222
|
+
${params.length ? `<div class="fl-params">${controls}</div>` : ''}
|
|
223
|
+
${hasEditor ? '<div class="fl-editor"><pre class="fl-hl" aria-hidden="true"></pre><textarea class="fl-ta" spellcheck="false" wrap="off" aria-label="Python code"></textarea></div>' : ''}
|
|
224
|
+
<div class="fl-actions"><button type="button" class="fl-run">▶ Run</button>${hasEditor ? '<button type="button" class="fl-reset">Reset</button>' : ''}${mode === 'collapsed' ? '<button type="button" class="fl-toggle">Show code</button>' : ''}<span class="fl-status"></span></div>
|
|
225
|
+
<div class="fl-out"><p class="fl-note"></p><pre class="fl-stdout"></pre><div class="fl-err"></div><div class="fl-figs"></div><p class="fl-empty">Run to see output</p></div>`;
|
|
226
|
+
el.appendChild(box);
|
|
227
|
+
|
|
228
|
+
const $ = (s) => box.querySelector(s);
|
|
229
|
+
const ta = $('.fl-ta');
|
|
230
|
+
const hl = $('.fl-hl');
|
|
231
|
+
const status = $('.fl-status');
|
|
232
|
+
const runBtn = $('.fl-run');
|
|
233
|
+
let timer = 0;
|
|
234
|
+
let runId = 0;
|
|
235
|
+
|
|
236
|
+
if (mode === 'collapsed') {
|
|
237
|
+
box.classList.add('is-collapsed');
|
|
238
|
+
$('.fl-toggle').addEventListener('click', (e) => {
|
|
239
|
+
e.target.textContent = box.classList.toggle('is-collapsed') ? 'Show code' : 'Hide code';
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const syncCode = () => {
|
|
244
|
+
if (hl) hl.innerHTML = highlight(st.code) + '\n';
|
|
245
|
+
};
|
|
246
|
+
const renderOut = () => {
|
|
247
|
+
const o = st.out;
|
|
248
|
+
$('.fl-note').textContent = st.note;
|
|
249
|
+
$('.fl-note').hidden = !st.note;
|
|
250
|
+
$('.fl-stdout').textContent = (o && o.stdout) || '';
|
|
251
|
+
$('.fl-stdout').hidden = !(o && o.stdout);
|
|
252
|
+
$('.fl-err').innerHTML =
|
|
253
|
+
o && o.error ? `<b>${esc(o.error.summary)}</b><details><summary>details</summary><pre>${esc(o.error.detail || '')}</pre></details>` : '';
|
|
254
|
+
$('.fl-figs').innerHTML = ((o && o.images) || [])
|
|
255
|
+
.map((b64) => `<img src="data:image/png;base64,${b64}" alt="Figure produced by the code" />`)
|
|
256
|
+
.join('');
|
|
257
|
+
const empty = !o || (!o.stdout && !o.error && !(o.images && o.images.length));
|
|
258
|
+
$('.fl-empty').hidden = !empty || exercise;
|
|
259
|
+
$('.fl-out').hidden = empty && exercise;
|
|
260
|
+
};
|
|
261
|
+
if (ta) ta.value = st.code;
|
|
262
|
+
syncCode();
|
|
263
|
+
renderOut();
|
|
264
|
+
|
|
265
|
+
async function run() {
|
|
266
|
+
const my = ++runId;
|
|
267
|
+
runBtn.disabled = true;
|
|
268
|
+
let res;
|
|
269
|
+
try {
|
|
270
|
+
res = await RT.run(index, applyParams(st.code, st.values), (msg) => {
|
|
271
|
+
if (my === runId) status.textContent = msg;
|
|
272
|
+
});
|
|
273
|
+
} catch (e) {
|
|
274
|
+
res = { stdout: '', images: [], error: { summary: 'Python could not start.', detail: String((e && e.message) || e) } };
|
|
275
|
+
}
|
|
276
|
+
if (my !== runId) return;
|
|
277
|
+
runBtn.disabled = false;
|
|
278
|
+
st.out = res;
|
|
279
|
+
st.note = res.note || '';
|
|
280
|
+
status.textContent = res.error ? '' : 'Done';
|
|
281
|
+
renderOut();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
runBtn.addEventListener('click', run);
|
|
285
|
+
const reset = $('.fl-reset');
|
|
286
|
+
if (reset) {
|
|
287
|
+
reset.addEventListener('click', () => {
|
|
288
|
+
st.code = code0;
|
|
289
|
+
st.values = { ...values0 };
|
|
290
|
+
st.out = initial;
|
|
291
|
+
st.note = '';
|
|
292
|
+
delete window.NMA_SCROLLY?.edits?.[index];
|
|
293
|
+
box.querySelectorAll('[data-i]').forEach((input) => {
|
|
294
|
+
const p = params[Number(input.dataset.i)];
|
|
295
|
+
input.value = p.kind === 'slider' ? p.value : String(p.options.findIndex((o) => o === p.value));
|
|
296
|
+
if (p.kind === 'slider') input.nextElementSibling.textContent = formatValue(p.value);
|
|
297
|
+
});
|
|
298
|
+
ta.value = code0;
|
|
299
|
+
syncCode();
|
|
300
|
+
renderOut();
|
|
301
|
+
status.textContent = '';
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
if (ta) {
|
|
305
|
+
ta.addEventListener('input', () => {
|
|
306
|
+
st.code = ta.value;
|
|
307
|
+
if (window.NMA_SCROLLY) (window.NMA_SCROLLY.edits ||= {})[index] = ta.value;
|
|
308
|
+
syncCode();
|
|
309
|
+
});
|
|
310
|
+
ta.addEventListener('scroll', () => {
|
|
311
|
+
hl.scrollLeft = ta.scrollLeft;
|
|
312
|
+
hl.scrollTop = ta.scrollTop;
|
|
313
|
+
});
|
|
314
|
+
ta.addEventListener('keydown', (e) => {
|
|
315
|
+
if (e.key === 'Tab') {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
const a = ta.selectionStart;
|
|
318
|
+
ta.value = ta.value.slice(0, a) + ' ' + ta.value.slice(ta.selectionEnd);
|
|
319
|
+
ta.selectionStart = ta.selectionEnd = a + 4;
|
|
320
|
+
ta.dispatchEvent(new Event('input'));
|
|
321
|
+
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey || e.shiftKey)) {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
run();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
box.querySelectorAll('[data-i]').forEach((input) => {
|
|
328
|
+
input.addEventListener('input', () => {
|
|
329
|
+
const p = params[Number(input.dataset.i)];
|
|
330
|
+
if (p.kind === 'slider') {
|
|
331
|
+
st.values[p.name] = Number(input.value);
|
|
332
|
+
input.nextElementSibling.textContent = formatValue(Number(input.value));
|
|
333
|
+
} else st.values[p.name] = p.options[Number(input.value)];
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
if (RT.state === 'ready') timer = setTimeout(run, 180);
|
|
336
|
+
else status.textContent = 'Press Run to start Python; after that, changes update the output live.';
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
const onPy = (e) => {
|
|
340
|
+
if (runBtn.disabled && e.detail.state === 'loading') status.textContent = e.detail.msg;
|
|
341
|
+
};
|
|
342
|
+
window.addEventListener('nma-py', onPy);
|
|
343
|
+
el.getRootNode().host && (el.getRootNode().host._run = run); // test hook
|
|
344
|
+
RT.set(RT.state, RT.msg); // tell the engine a runtime exists
|
|
345
|
+
|
|
346
|
+
return () => {
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
window.removeEventListener('nma-py', onPy);
|
|
349
|
+
box.remove();
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export default { render };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/* Scrollytelling page layout (plugins/scrolly/).
|
|
2
|
+
Attached to the engine widget so MyST bundles it; engine.mjs copies it
|
|
3
|
+
into <head>. Without JavaScript the page is a plain linear document; the
|
|
4
|
+
pinned layout switches on when the engine sets data-ready.
|
|
5
|
+
|
|
6
|
+
The section sits between the book theme's menus: the TOC sidebar
|
|
7
|
+
(col-margin-left, from 1280px) and the "On this page" outline
|
|
8
|
+
(col-margin-right, from 1024px). Its layout follows its own width
|
|
9
|
+
(container queries), not the viewport's: stage beside the cards when there
|
|
10
|
+
is room, stage above the cards otherwise. */
|
|
11
|
+
|
|
12
|
+
/* ---- placement in the article grid ----
|
|
13
|
+
Default: between the menus. Each menu a page hides (frontmatter
|
|
14
|
+
`site: hide_outline: true`, recommended, or `hide_toc: true`) gives the
|
|
15
|
+
section its space. Scrolly pages keep the TOC: with hide_toc the theme
|
|
16
|
+
leaves it out of the page and readers can't navigate mid-story. */
|
|
17
|
+
.article-grid > .scrolly-col { grid-column: page; container: scrolly / inline-size; }
|
|
18
|
+
@media (min-width: 1024px) {
|
|
19
|
+
.article-grid > .scrolly-col { grid-column: page-start / body-end; }
|
|
20
|
+
.article-grid:not(:has(> .lg\:col-margin-right)) > .scrolly-col { grid-column: screen-inset; }
|
|
21
|
+
}
|
|
22
|
+
@media (min-width: 1280px) {
|
|
23
|
+
.article-grid > .scrolly-col { grid-column: body; }
|
|
24
|
+
.article-grid:not(:has(> .lg\:col-margin-right)) > .scrolly-col { grid-column: body-start / page-end; }
|
|
25
|
+
body:has(.myst-primary-sidebar.lg\:hidden) .article-grid > .scrolly-col { grid-column: page-start / body-end; }
|
|
26
|
+
body:has(.myst-primary-sidebar.lg\:hidden) .article-grid:not(:has(> .lg\:col-margin-right)) > .scrolly-col { grid-column: screen-inset; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.scrolly {
|
|
30
|
+
--scrolly-top: 60px;
|
|
31
|
+
--nm-indigo: #2a2952;
|
|
32
|
+
--nm-indigo-deep: #1c1b3e;
|
|
33
|
+
--nm-purple: #45429c;
|
|
34
|
+
--nm-blue: #008cbf;
|
|
35
|
+
--nm-salmon: #fa7a7a;
|
|
36
|
+
--nm-mint: #5efbd7;
|
|
37
|
+
--nm-lilac: #b9b8ff;
|
|
38
|
+
--nm-cream: #f3f0ec;
|
|
39
|
+
--nm-ink: #161922;
|
|
40
|
+
--nm-muted: #b4b3e6;
|
|
41
|
+
--nm-text: #f3f0ec;
|
|
42
|
+
--nm-font-body: inherit;
|
|
43
|
+
--nm-font-head: inherit;
|
|
44
|
+
position: relative;
|
|
45
|
+
margin-block: 1.5rem;
|
|
46
|
+
color: var(--nm-text);
|
|
47
|
+
background: var(--nm-indigo-deep);
|
|
48
|
+
border-radius: 12px;
|
|
49
|
+
padding: 1rem;
|
|
50
|
+
}
|
|
51
|
+
.scrolly *, .scrolly *::before, .scrolly *::after { box-sizing: border-box; }
|
|
52
|
+
.scrolly-engine { display: block; }
|
|
53
|
+
.scrolly-stage { margin-bottom: 1.5rem; }
|
|
54
|
+
.scrolly-view { margin: 0 0 1.5rem; }
|
|
55
|
+
.scrolly-step { margin: 0 0 1.5rem; }
|
|
56
|
+
.scrolly-step.is-hero { display: none; }
|
|
57
|
+
|
|
58
|
+
/* JupyterLite is not used on scrolly pages: the panels run their own Python. */
|
|
59
|
+
body:has(.scrolly) .myst-jp-nb-toolbar-power { display: none !important; }
|
|
60
|
+
|
|
61
|
+
/* ---- pinned layout ----
|
|
62
|
+
Engine (HUD), stage and steps share one grid cell instead of overlapping
|
|
63
|
+
through negative margins. A sticky element stops when its margin box reaches
|
|
64
|
+
the end of its parent; with `margin-bottom: -height` that box is 0px tall,
|
|
65
|
+
so the stage would stick for a whole screen past the section and cover the
|
|
66
|
+
page footer (prev/next links). In the grid each one has its real height. */
|
|
67
|
+
.scrolly[data-ready="true"] { padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); }
|
|
68
|
+
.scrolly[data-ready="true"] > :is(.scrolly-engine, .scrolly-stage, .scrolly-steps) { grid-area: 1 / 1; min-width: 0; }
|
|
69
|
+
.scrolly[data-ready="true"] .scrolly-engine { align-self: start; position: sticky; top: var(--scrolly-top); height: 52px; z-index: 6; pointer-events: none; }
|
|
70
|
+
.scrolly[data-ready="true"] .scrolly-stage {
|
|
71
|
+
align-self: start;
|
|
72
|
+
position: sticky;
|
|
73
|
+
top: var(--scrolly-top);
|
|
74
|
+
height: calc(100vh - var(--scrolly-top));
|
|
75
|
+
margin: 0;
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
z-index: 0;
|
|
78
|
+
border-radius: 12px;
|
|
79
|
+
background:
|
|
80
|
+
radial-gradient(ellipse 60% 50% at 78% 20%, rgba(68, 67, 156, 0.55), transparent 70%),
|
|
81
|
+
radial-gradient(ellipse 40% 40% at 10% 95%, rgba(0, 137, 247, 0.16), transparent 70%);
|
|
82
|
+
}
|
|
83
|
+
.scrolly[data-ready="true"] .scrolly-view {
|
|
84
|
+
position: absolute;
|
|
85
|
+
inset: 52px 28px 28px 42%;
|
|
86
|
+
margin: 0;
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
justify-content: safe center;
|
|
90
|
+
gap: 14px;
|
|
91
|
+
overflow: auto;
|
|
92
|
+
opacity: 0;
|
|
93
|
+
visibility: hidden;
|
|
94
|
+
transition: opacity 0.2s, visibility 0.2s;
|
|
95
|
+
}
|
|
96
|
+
.scrolly[data-ready="true"] .scrolly-view.is-active { opacity: 1; visibility: visible; }
|
|
97
|
+
.scrolly[data-ready="true"] .scrolly-view.is-hero { left: 28px; }
|
|
98
|
+
.scrolly[data-ready="true"] .scrolly-steps { position: relative; z-index: 1; pointer-events: none; }
|
|
99
|
+
.scrolly[data-ready="true"] .scrolly-step { min-height: 90vh; width: 40%; display: flex; align-items: center; margin: 0; padding-left: max(20px, 3cqi); padding-right: 12px; }
|
|
100
|
+
.scrolly[data-ready="true"] .scrolly-step.is-hero { display: block; width: 100%; min-height: 105vh; padding: 0; }
|
|
101
|
+
|
|
102
|
+
/* ---- cards ---- */
|
|
103
|
+
.scrolly-card {
|
|
104
|
+
pointer-events: auto;
|
|
105
|
+
max-width: 32rem;
|
|
106
|
+
min-width: 0; /* flex item in .scrolly-step: don't grow to the widest code line */
|
|
107
|
+
background: var(--nm-cream);
|
|
108
|
+
color: var(--nm-ink);
|
|
109
|
+
border-radius: 14px;
|
|
110
|
+
border-left: 6px solid var(--nm-salmon);
|
|
111
|
+
padding: 1.1rem 1.35rem 1.25rem;
|
|
112
|
+
box-shadow: 0 14px 40px rgba(8, 7, 30, 0.45);
|
|
113
|
+
line-height: 1.6;
|
|
114
|
+
}
|
|
115
|
+
.scrolly[data-ready="true"] .scrolly-card { opacity: 0.32; transform: translateX(-10px) scale(0.985); transition: opacity 0.35s, transform 0.35s; }
|
|
116
|
+
.scrolly[data-ready="true"] .scrolly-step.is-active .scrolly-card { opacity: 1; transform: none; }
|
|
117
|
+
.scrolly-card > :first-child { margin-top: 0; }
|
|
118
|
+
.scrolly-card > :last-child { margin-bottom: 0; }
|
|
119
|
+
.scrolly-card :is(h1, h2, h3, h4, h5, h6) { display: block; border: 0; padding: 0; margin: 0.2rem 0 0.45rem; font-weight: 600; font-size: 1.35rem; line-height: 1.2; color: var(--nm-indigo); }
|
|
120
|
+
.scrolly-card p { margin: 0 0 0.6rem; font-size: 1rem; }
|
|
121
|
+
.scrolly-card :is(p, li, strong, em, span, b, i, td, th) { color: var(--nm-ink); }
|
|
122
|
+
.scrolly-card code { background: #e4e0f7; color: var(--nm-indigo); padding: 0.05em 0.4em; border-radius: 5px; font-size: 0.88em; }
|
|
123
|
+
.scrolly-card a { color: var(--nm-purple); }
|
|
124
|
+
.scrolly-card .katex-display { overflow-x: auto; overflow-y: hidden; }
|
|
125
|
+
.scrolly-card details { margin: 0.5rem 0; }
|
|
126
|
+
.scrolly-card pre { max-width: 100%; overflow-x: auto; }
|
|
127
|
+
.scrolly-card .nma-solution-link p { margin: 0.4rem 0; }
|
|
128
|
+
.scrolly-kicker { margin: 0 0 0.1rem; }
|
|
129
|
+
.scrolly-kicker p { margin: 0; font-weight: 600; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--nm-purple) !important; }
|
|
130
|
+
|
|
131
|
+
/* ---- stage content ---- */
|
|
132
|
+
.scrolly-view { color: var(--nm-text); }
|
|
133
|
+
.scrolly-view :is(p, li, strong, em, span) { color: inherit; }
|
|
134
|
+
.scrolly-view :is(h1, h2, h3, h4) { display: block; border: 0; padding: 0; color: #fff; }
|
|
135
|
+
.scrolly-view a { color: var(--nm-mint); }
|
|
136
|
+
.scrolly-view img { max-width: 100%; max-height: 70vh; width: auto; height: auto; border-radius: 10px; background: #fff; margin: 0 auto; display: block; }
|
|
137
|
+
.scrolly-view video { max-width: 100%; max-height: 70vh; border-radius: 10px; margin: 0 auto; display: block; }
|
|
138
|
+
.scrolly-view figure { margin: 0; }
|
|
139
|
+
.scrolly-view figcaption, .scrolly-view figcaption * { color: var(--nm-muted); }
|
|
140
|
+
.scrolly-view iframe { width: 100%; aspect-ratio: 16 / 9; height: auto; border: 0; border-radius: 10px; background: #000; }
|
|
141
|
+
.scrolly-view .nma-video p, .scrolly-view .nma-slides p { margin: 0.3rem 0; font-size: 0.85rem; }
|
|
142
|
+
.scrolly-view .nma-video [role="tab"] { color: var(--nm-lilac); }
|
|
143
|
+
.scrolly-view .scrolly-runnable { width: 100%; }
|
|
144
|
+
.scrolly-view.is-list ol, .scrolly-view.is-list ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.75rem; counter-reset: chip; }
|
|
145
|
+
.scrolly-view.is-list li { counter-increment: chip; display: grid; grid-template-columns: 2.6rem 1fr; align-items: center; gap: 0.9rem; font-size: 1.05rem; line-height: 1.45; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(185, 184, 255, 0.2); border-radius: 12px; padding: 0.75rem 1rem; margin: 0; }
|
|
146
|
+
.scrolly-view.is-list li::before { content: counter(chip); display: grid; place-items: center; width: 2.6rem; height: 2.6rem; border-radius: 50%; background: var(--nm-mint); color: var(--nm-indigo); font-weight: 700; font-size: 1.1rem; }
|
|
147
|
+
.scrolly-view.is-list li > p { margin: 0; }
|
|
148
|
+
.scrolly-view.is-list :is(h2, h3, h4) { margin: 0 0 0.5rem; font-size: clamp(1.4rem, 4cqi, 2.2rem); font-weight: 700; }
|
|
149
|
+
|
|
150
|
+
/* ---- hero (title and closing screens) ----
|
|
151
|
+
MyST re-levels headings, so the title is styled by position, not by tag. */
|
|
152
|
+
.scrolly-view.is-hero { max-width: 72rem; margin-inline: auto; padding: 0 clamp(0.5rem, 5cqi, 4rem); }
|
|
153
|
+
.scrolly-view.is-hero .scrolly-kicker p { font-size: 0.85rem; letter-spacing: 0.32em; color: var(--nm-mint) !important; margin-bottom: 0.6rem; }
|
|
154
|
+
.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6) { display: block; margin: 0; padding: 0; border: 0; }
|
|
155
|
+
.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6):not(:is(h1, h2, h3, h4, h5, h6) ~ *) { font-weight: 700; font-size: clamp(2rem, 8cqi, 4.6rem); line-height: 1.05; color: #fff; }
|
|
156
|
+
.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6) ~ :is(h1, h2, h3, h4, h5, h6) { font-weight: 500; font-size: 1.15rem; color: var(--nm-lilac); margin: 0.9rem 0 0.3rem; }
|
|
157
|
+
.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6) em { font-style: normal; background: linear-gradient(90deg, var(--nm-salmon), var(--nm-mint)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
|
158
|
+
.scrolly-view.is-hero p { margin: 0.5rem 0; font-size: 0.98rem; line-height: 1.5; max-width: 52rem; }
|
|
159
|
+
.scrolly-view.is-hero p strong { color: var(--nm-salmon); }
|
|
160
|
+
|
|
161
|
+
/* ---- narrow sections: stage on top, cards below it ----
|
|
162
|
+
Keep the 760px breakpoint in sync with WIDE in engine.mjs. */
|
|
163
|
+
@container scrolly (max-width: 759.98px) {
|
|
164
|
+
.scrolly[data-ready="true"] .scrolly-stage { height: 55vh; }
|
|
165
|
+
.scrolly[data-ready="true"] .scrolly-view, .scrolly[data-ready="true"] .scrolly-view.is-hero { inset: 46px 12px 10px 12px; }
|
|
166
|
+
.scrolly[data-ready="true"] .scrolly-step { width: 100%; min-height: 70vh; display: block; padding: calc(55vh + 12px) 12px 0; }
|
|
167
|
+
.scrolly[data-ready="true"] .scrolly-step + .scrolly-step { padding-top: 0; }
|
|
168
|
+
.scrolly[data-ready="true"] .scrolly-step.is-hero { min-height: 100vh; padding: 0; }
|
|
169
|
+
.scrolly[data-ready="true"] .scrolly-card { position: sticky; top: calc(var(--scrolly-top) + 55vh + 12px); max-height: calc(45vh - var(--scrolly-top) - 24px); overflow: auto; }
|
|
170
|
+
}
|
|
171
|
+
@media (prefers-reduced-motion: reduce) {
|
|
172
|
+
.scrolly-view, .scrolly-card { transition: none !important; }
|
|
173
|
+
}
|