@graphlearning/shell 0.10.0 → 0.10.2

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/styles.css CHANGED
@@ -1,103 +1,49 @@
1
- /* @graphlearning/shell — the concept-app stylesheet.
2
- *
3
- * Import ONCE in a content repo's main.tsx, and import it BEFORE the repo's own theme.css:
4
- * import '@graphlearning/flow/styles.css' // engine: react-flow + the IBM Plex faces
5
- * import '@graphlearning/shell/styles.css' // this file: the shell's layout + surface
6
- * import './theme.css' // the repo: --brand / --brand-hover / --accent-2
7
- *
8
- * THE SHELL PAINTS THE CANVAS. ui-flow's SceneView draws its dots at #2a2f38 and assumes a dark
9
- * surface behind it; that surface (--bg) is defined here. This is the contract each content repo
10
- * used to re-declare by hand in its own index.css.
11
- */
12
-
13
1
  :root {
14
- --bg: #1a1d23; /* Zed-slate (matches graphl-studio/aws) */
2
+ --bg: #1a1d23;
15
3
  --fg: #eceef2;
16
- /* The foreground's RGB CHANNELS, so every `rgba(var(--ink-rgb), a)` in this file flips with the
17
- theme. There were ~20 literal rgba(255,255,255,a) values here — all of them "ink at some alpha"
18
- — and one token converts every one by substitution rather than needing eleven alpha tokens.
19
- Dark keeps the exact values it always had. */
4
+
20
5
  --ink-rgb: 255, 255, 255;
21
- /* Surfaces lifted OFF --bg: the slide panel, and a catalog card's hover. Literals before 0.4.0. */
6
+
22
7
  --surface-lift: #2f333d;
23
8
  --surface-lift-2: #22262e;
24
- --slide-ink: #c8cdd6; /* the slide body's text colour */
25
- --foot-dim: #5b616f; /* the capture footer's section counter */
26
- --foot-dimmer: #4b515e; /* the catalog chevron at rest */
27
- /* A SCRIM, not ink: the page colour at 90%, sat behind the floating controls so they stay legible
28
- over whatever the scene puts under them. It has to follow --bg, so it cannot be alpha over
29
- --ink-rgb like the rest — on light, ink-at-90% would be a near-black button. */
9
+ --slide-ink: #c8cdd6;
10
+ --foot-dim: #5b616f;
11
+ --foot-dimmer: #4b515e;
12
+
30
13
  --scrim: rgba(26, 29, 35, 0.9);
31
- /* --- the three tokens a CONTENT REPO owns -------------------------------------------------
32
- These are NEUTRAL DEFAULTS. Each repo overrides them in its own theme.css, imported AFTER
33
- this stylesheet so the cascade lets the repo win. They are the entire per-repo design
34
- surface — everything else in this file is identical for every concept.
35
- --brand the one accent: section eyebrow, slide title (h2), catalog accent, brand hover
36
- --brand-hover a lighter tint of --brand, for the eyebrow's hover state
37
- --accent-2 a counter tone to --brand, for the slide's `###` subheading
38
- Defaults rather than nothing: a repo that forgets theme.css renders in slate and is obviously
39
- unbranded, instead of painting invalid colours and losing the text entirely. */
14
+
40
15
  --brand: #8b909c;
41
16
  --brand-hover: #a8adb8;
42
17
  --accent-2: #c8cdd6;
43
18
  --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
44
19
 
45
- /* --- site-chrome tokens (SiteHeader) ------------------------------------------------------
46
- The same names and the same values ui-graphl declares, so the bar is identical on the catalog
47
- and inside every app. Do NOT rename them: when light mode arrives it is purely additive here
48
- — a light :root plus prefers-color-scheme and [data-theme] blocks, same names — and the
49
- header's own CSS never has to change again. --text aliases the shell's existing --fg (same
50
- value) rather than duplicating the colour. These are PLATFORM tokens: the bar stays
51
- platform-blue on every site, deliberately NOT --brand, because looking the same everywhere is
52
- the whole reason it exists. */
53
20
  --text: var(--fg);
54
21
  --muted: #8b909c;
55
22
  --accent: #5b8cff;
56
- --accent-soft: rgba(91, 140, 255, 0.14); /* the current nav item */
23
+ --accent-soft: rgba(91, 140, 255, 0.14);
57
24
  --line: rgba(var(--ink-rgb), 0.09);
58
25
  --header-bg: rgba(var(--ink-rgb), 0.014);
59
26
  --hover: rgba(var(--ink-rgb), 0.05);
60
- --site-h: 60px; /* the bar's row height (its 1px rule is extra) — .idx subtracts it from 100vh */
61
- }
62
-
63
- /* LIGHT — same token NAMES, different values, exactly as the note above anticipated. Set by
64
- ConceptApp on <html> from its `theme` prop; nothing selects on it but this block.
65
- *
66
- * WHY AN EXPLICIT ATTRIBUTE AND NOT prefers-color-scheme: a deck is a VIDEO. The recorded frame
67
- * cannot depend on the machine that renders it, or the same slug captures differently on two
68
- * laptops. The repo chooses the theme at build time and the recorder inherits it.
69
- *
70
- * --line / --header-bg / --hover / --idx-line are NOT redeclared: they are alpha over --ink-rgb,
71
- * so flipping the channels flips them too. That is the whole reason --ink-rgb exists.
72
- *
73
- * NOT TOUCHED HERE: --brand, --brand-hover and --accent-2. Those belong to the content repo
74
- * (theme.css, loaded after this file), and a brand tuned for glow on slate can be unreadable on
75
- * off-white — python's is a mid-blue that drops to ~2.5:1 on #f7f8fa. A repo going light must
76
- * re-pick its three tokens; the shell cannot do it for them without taking ownership of the one
77
- * surface it deliberately leaves alone. */
27
+ --site-h: 60px;
28
+ }
29
+
78
30
  :root[data-theme='light'] {
79
- --bg: #f7f8fa; /* identical to ui-flow's light surface, so the scene pane and the page agree */
31
+ --bg: #f7f8fa;
80
32
  --fg: #1a1d23;
81
33
  --ink-rgb: 26, 29, 35;
82
- /* On dark the slide panel is LIFTED off the canvas; on light the same relationship means going
83
- brighter than the page, not darker — so white, not a grey. */
34
+
84
35
  --surface-lift: #ffffff;
85
36
  --surface-lift-2: #eef1f5;
86
37
  --slide-ink: #333a45;
87
38
  --foot-dim: #7a8493;
88
39
  --foot-dimmer: #98a2b1;
89
40
  --muted: #5a6675;
90
- /* Platform blue darkened until it carries text contrast on off-white; #5b8cff is tuned to glow
91
- on slate and reads washed out here. Still platform blue, still not --brand. */
41
+
92
42
  --accent: #2f6fe0;
93
43
  --accent-soft: rgba(47, 111, 224, 0.12);
94
44
  --scrim: rgba(247, 248, 250, 0.9);
95
45
  }
96
46
 
97
- /* Set for the single frame a theme swap lands in, so the whole page changes at once instead of the
98
- ground flipping while everything with a hover transition cross-fades after it. Ported from
99
- ui-graphl's styles.css — the site bar exists three times and they only stay one design if every
100
- change lands in all of them. */
101
47
  .theme-swap * {
102
48
  transition: none !important;
103
49
  }
@@ -116,8 +62,6 @@ body {
116
62
  -webkit-font-smoothing: antialiased;
117
63
  }
118
64
 
119
- /* The stage fills the viewport; at capture time the viewport is the target frame (4K landscape or
120
- 1080×1920 portrait), so the composition fills it exactly. */
121
65
  .stage {
122
66
  width: 100vw;
123
67
  height: 100vh;
@@ -131,14 +75,10 @@ body {
131
75
  opacity: 0.7;
132
76
  }
133
77
 
134
- /* react-flow theming to sit on the dark stage */
135
78
  .react-flow {
136
79
  background: var(--bg);
137
80
  }
138
81
 
139
- /* --- Section layout (scene + fixed-right slide surface) -------------------- */
140
- /* The slide is a full-height surface LIFTED off the scene canvas (figure-ground), fixed right.
141
- Base = PORTRAIT (mobile → reel): scene fills the frame; slide is an off-canvas right drawer. */
142
82
  .stage--section {
143
83
  position: relative;
144
84
  overflow: hidden;
@@ -152,13 +92,13 @@ body {
152
92
  top: 0;
153
93
  right: 0;
154
94
  height: 100%;
155
- width: 100%; /* portrait: full-screen reading surface (landscape overrides to a 42% column) */
95
+ width: 100%;
156
96
  display: flex;
157
97
  align-items: safe center;
158
98
  justify-content: center;
159
99
  overflow: auto;
160
100
  box-sizing: border-box;
161
- background: var(--surface-lift); /* lifted slide surface, off the #1a1d23 scene canvas */
101
+ background: var(--surface-lift);
162
102
  border-left: 1px solid rgba(var(--ink-rgb), 0.09);
163
103
  transform: translateX(100%);
164
104
  transition: transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
@@ -167,41 +107,43 @@ body {
167
107
  .slide-panel--open {
168
108
  transform: translateX(0);
169
109
  }
170
- .slide-toggle {
171
- position: absolute;
172
- top: 14px;
173
- right: 14px;
174
- z-index: 11;
110
+ .slide-toggle,
111
+ .reel-foot__ctrl {
175
112
  width: 36px;
176
113
  height: 36px;
177
114
  border-radius: 10px;
178
115
  border: 1px solid rgba(var(--ink-rgb), 0.12);
179
116
  background: var(--scrim);
180
117
  color: rgba(var(--ink-rgb), 0.82);
181
- font-size: 19px;
182
- line-height: 1;
183
118
  cursor: pointer;
184
119
  transition: color 0.15s, border-color 0.15s;
185
120
  -webkit-tap-highlight-color: transparent;
186
121
  }
122
+ .slide-toggle {
123
+ position: absolute;
124
+ top: 14px;
125
+ right: 14px;
126
+ z-index: 11;
127
+ font-size: 19px;
128
+ line-height: 1;
129
+ }
187
130
  @media (hover: hover) {
188
- .slide-toggle:hover {
189
- color: var(--idx-accent);
190
- border-color: var(--idx-accent);
131
+ .slide-toggle:hover,
132
+ .reel-foot__ctrl:hover {
133
+ color: var(--brand);
134
+ border-color: var(--brand);
191
135
  }
192
136
  }
193
- .slide-toggle:focus:not(:focus-visible) {
137
+ .slide-toggle:focus:not(:focus-visible),
138
+ .reel-foot__ctrl:focus:not(:focus-visible) {
194
139
  outline: none;
195
140
  }
196
- .slide-toggle:focus-visible {
197
- outline: 2px solid var(--idx-accent);
141
+ .slide-toggle:focus-visible,
142
+ .reel-foot__ctrl:focus-visible {
143
+ outline: 2px solid var(--brand);
198
144
  outline-offset: 2px;
199
145
  }
200
146
 
201
- /* Footer control bar (interactive only). Raised above the portrait drawer so Home + narration + nav
202
- stay usable while the slide is open. Compound selector (.reel-foot.reel-foot--controls, 0,2,0) so it
203
- outranks the base .reel-foot rule (z-index 8 / align baseline), which is defined LATER in the file —
204
- at equal specificity that base would win here. */
205
147
  .reel-foot.reel-foot--controls {
206
148
  z-index: 12;
207
149
  align-items: center;
@@ -216,35 +158,7 @@ body {
216
158
  display: inline-flex;
217
159
  align-items: center;
218
160
  justify-content: center;
219
- width: 36px;
220
- height: 36px;
221
- border-radius: 10px;
222
- border: 1px solid rgba(var(--ink-rgb), 0.12);
223
- background: var(--scrim);
224
- color: rgba(var(--ink-rgb), 0.82);
225
- cursor: pointer;
226
- pointer-events: auto; /* the footer container is pointer-events:none; re-enable the buttons */
227
- transition: color 0.15s, border-color 0.15s;
228
- -webkit-tap-highlight-color: transparent; /* no grey/blue tap flash on mobile */
229
- }
230
- /* Hover feedback only on real-pointer devices — on touch, :hover "sticks" after a tap and leaves the
231
- icon highlighted until you tap elsewhere.
232
- --brand, NOT --idx-accent: that token is declared on `.idx` (the catalog column) and does not
233
- exist on a section, so every --idx-accent here resolved to nothing and the hover/focus accent was
234
- silently dead. --brand is what .idx aliases it to anyway. */
235
- @media (hover: hover) {
236
- .reel-foot__ctrl:hover {
237
- color: var(--brand);
238
- border-color: var(--brand);
239
- }
240
- }
241
- /* Drop the focus ring on click/tap; keep a keyboard-only ring for accessibility. */
242
- .reel-foot__ctrl:focus:not(:focus-visible) {
243
- outline: none;
244
- }
245
- .reel-foot__ctrl:focus-visible {
246
- outline: 2px solid var(--brand);
247
- outline-offset: 2px;
161
+ pointer-events: auto;
248
162
  }
249
163
  .reel-foot__count--live {
250
164
  pointer-events: none;
@@ -252,12 +166,6 @@ body {
252
166
  text-align: center;
253
167
  }
254
168
 
255
- /* Narration scrub bar — the middle child of the control cluster (NarrationBar). LANDSCAPE ONLY: it
256
- is `display: none` here and switched on in the landscape block below. In portrait the row is
257
- already full at phone width (three controls + the pager leave ~60px), and the only other place to
258
- put it is a second row — which would grow this absolutely-positioned footer UP into the frame,
259
- since the portrait scene reserve (.stage--section .scene-area, bottom: 7.5vh) is sized for one
260
- row. Widening that reserve would re-frame every recorded reel, so the bar yields instead. */
261
169
  .reel-foot__audio {
262
170
  display: none;
263
171
  align-items: center;
@@ -270,8 +178,7 @@ body {
270
178
  text-align: center;
271
179
  font-size: 1.3vh;
272
180
  }
273
- /* The track is the hit target, so it gets vertical padding (a 4px line is not tappable) with the
274
- visible rail drawn as a pseudo-element inside it. */
181
+
275
182
  .reel-foot__track {
276
183
  position: relative;
277
184
  flex: 1 1 auto;
@@ -280,8 +187,8 @@ body {
280
187
  display: flex;
281
188
  align-items: center;
282
189
  cursor: pointer;
283
- pointer-events: auto; /* the footer container is pointer-events:none */
284
- touch-action: none; /* a drag scrubs; it must not scroll the page */
190
+ pointer-events: auto;
191
+ touch-action: none;
285
192
  -webkit-tap-highlight-color: transparent;
286
193
  }
287
194
  .reel-foot__track::before {
@@ -312,8 +219,7 @@ body {
312
219
  transition: transform 0.12s;
313
220
  pointer-events: none;
314
221
  }
315
- /* The thumb is an affordance, not decoration: it appears on hover/focus/scrub and otherwise the bar
316
- reads as a thin progress line (the footer sits over the scene). */
222
+
317
223
  @media (hover: hover) {
318
224
  .reel-foot__track:hover .reel-foot__thumb {
319
225
  transform: translateY(-50%) scale(1);
@@ -323,13 +229,7 @@ body {
323
229
  .reel-foot__track:focus-visible .reel-foot__thumb {
324
230
  transform: translateY(-50%) scale(1);
325
231
  }
326
- /* THE FOCUS INDICATOR IS THE THUMB, NOT A BOX ROUND THE BAR. An outline on the track draws a brand
327
- rectangle ~500px wide across the scene, and it showed up where nothing had been clicked: Chrome
328
- promotes an ALREADY mouse-focused element to :focus-visible on the next key press, so a reader who
329
- click-scrubbed and then pressed Space (narration — a window-level shortcut with nothing to do with
330
- this bar) got the rectangle, and it stayed for the rest of the section. The thumb carries the same
331
- information at the size of the control, so keyboard focus is still visible without painting the
332
- frame. Keep the ring on `.reel-foot__ctrl` — those are 28px buttons where a box IS the control. */
232
+
333
233
  .reel-foot__track:focus {
334
234
  outline: none;
335
235
  }
@@ -337,9 +237,6 @@ body {
337
237
  box-shadow: 0 0 0 3px rgba(var(--ink-rgb), 0.3);
338
238
  }
339
239
 
340
- /* GraphL wordmark — top-left of the bare SCENE view; click or Esc → back to catalog. (The section
341
- view brands via its own header/footer, whose eyebrow is the home link.)
342
- Fixed to the viewport so it sits above the stage regardless of layout; hidden under ?capture=1. */
343
240
  .brand-home {
344
241
  position: fixed;
345
242
  top: 14px;
@@ -358,16 +255,9 @@ body {
358
255
  transition: color 0.2s;
359
256
  }
360
257
  .brand-home:hover {
361
- color: var(--brand); /* Python-blue, matching the catalog accent */
258
+ color: var(--brand);
362
259
  }
363
260
 
364
- /* Section header/footer — shared chrome for the interactive app AND the captured video. It brands the
365
- empty canvas around the fitView-centred scene: an "AWS · FOUNDATIONS" eyebrow (also the back-to-
366
- catalog link) + the section title up top, a GraphL brand + §n/N counter below. Sizes are in vh so
367
- they hold the SAME pixels at the capture frames (3840×2160 / 1080×1920) yet scale proportionally to
368
- any interactive window. The bands are pointer-events:none (they overlay the scene) except the
369
- eyebrow button, which re-enables clicks; z-index sits below the portrait slide drawer (z 10) so an
370
- open drawer covers them. */
371
261
  .reel-head {
372
262
  position: absolute;
373
263
  top: 0;
@@ -388,8 +278,8 @@ body {
388
278
  font-weight: 700;
389
279
  letter-spacing: 0.18em;
390
280
  text-transform: uppercase;
391
- color: var(--brand); /* Python-blue accent, matching the catalog brand */
392
- pointer-events: auto; /* the one clickable element in the header → back to catalog */
281
+ color: var(--brand);
282
+ pointer-events: auto;
393
283
  cursor: pointer;
394
284
  transition: color 0.2s;
395
285
  }
@@ -416,81 +306,51 @@ body {
416
306
  justify-content: space-between;
417
307
  pointer-events: none;
418
308
  }
419
- .reel-foot__brand {
420
- font-size: 1.55vh;
421
- font-weight: 700;
422
- letter-spacing: -0.01em;
423
- color: rgba(var(--ink-rgb), 0.9);
424
- }
425
309
  .reel-foot__count {
426
310
  font-size: 1.4vh;
427
311
  color: rgba(var(--ink-rgb), 0.5);
428
312
  font-variant-numeric: tabular-nums;
429
313
  letter-spacing: 0.04em;
430
314
  }
431
- /* Reserve the bands so fitView packs the scene BETWEEN them (never under the title). Every section
432
- view shows the header/footer, so the reserve is scoped to the section (not just capture). */
315
+
433
316
  .stage--section .scene-area {
434
317
  top: 12.5vh;
435
318
  bottom: 7.5vh;
436
319
  }
437
320
 
438
- /* LANDSCAPE (laptop → 4K video): the slide is a true 42% COLUMN — no absolute cap, so it stays 42%
439
- of whatever the frame is, 4K included. The scene fills the rest; drawer transform/toggle off. */
440
321
  @media (orientation: landscape) {
441
322
  .stage--section {
442
323
  display: flex;
443
324
  }
444
- /* The scene is a 9:16 PHONE FRAME at full height — the shape a reel is — centred in the left 58%
445
- (the slide keeps its 42% column). Width follows height (56.25vh), capped at the column so a
446
- squat window shrinks the frame's width, never its height. --frame-gap is the empty band each
447
- side of it; the header and footer are pulled in to the frame's edges so they brand the frame,
448
- not the empty bands. */
449
- .stage--section {
450
- --frame-w: min(56.25vh, 58vw);
451
- --frame-gap: calc((58vw - var(--frame-w)) / 2);
452
- }
453
325
  .scene-area {
454
326
  position: relative;
455
- flex: 0 0 var(--frame-w);
456
- width: var(--frame-w);
457
- margin: 0 var(--frame-gap);
327
+ flex: 1 1 auto;
328
+ min-width: 0;
458
329
  height: 100%;
459
330
  inset: auto;
460
331
  }
461
- /* Make the frame visible: a hairline just OUTSIDE its edge (box-shadow, so the scene's own canvas
462
- can never paint over it and the frame keeps its exact 9:16 size). Interactive only — a recorded
463
- frame must not carry a border the reader-facing bands exist to show. */
464
- .stage--section:not(.stage--capture) .scene-area {
465
- box-shadow: 0 0 0 1px rgba(var(--ink-rgb), 0.2);
466
- }
467
- /* Header/footer carry over but constrained to the FRAME. The section title is dropped here — it
468
- already headlines the slide — leaving the eyebrow on top and the GraphL + §n/N line below,
469
- sitting in the scene's fitView margins. Font sizes (vh) are shared with portrait. */
332
+
470
333
  .reel-head {
471
- left: var(--frame-gap);
472
- right: calc(42% + var(--frame-gap));
334
+ right: 42%;
473
335
  padding: 2.8vh 0 0 1.9vw;
474
336
  }
475
337
  .reel-head__title {
476
- display: none; /* the section title lives in the slide in landscape */
338
+ display: none;
477
339
  }
478
340
  .reel-head__eyebrow {
479
341
  margin-bottom: 0;
480
342
  }
481
343
  .reel-foot {
482
- left: var(--frame-gap);
483
- right: calc(42% + var(--frame-gap));
344
+ right: 42%;
484
345
  padding: 0 1.9vw 2.6vh;
485
346
  }
486
- /* The frame is wide enough for the scrub bar between the two clusters (see the portrait-only
487
- note on .reel-foot__audio). */
347
+
488
348
  .reel-foot__audio {
489
349
  display: flex;
490
350
  flex: 1 1 auto;
491
351
  margin: 0 1.4vw;
492
352
  }
493
- /* Scene reserve is portrait-only; in landscape the scene-area is the full-height flex column. */
353
+
494
354
  .stage--section .scene-area {
495
355
  top: auto;
496
356
  bottom: auto;
@@ -507,25 +367,18 @@ body {
507
367
  .slide-toggle {
508
368
  display: none;
509
369
  }
510
- /* Restore the capture-tuned slide metrics (the base carries the larger portrait values). Scoped
511
- with .stage--section so it outranks the base .slide-panel__scaler rule, which is defined LATER
512
- in the file — at equal specificity that base would otherwise win here too. */
370
+
513
371
  .stage--section .slide-panel__scaler {
514
372
  padding: 0 60px;
515
373
  --fs-body: 26px;
516
374
  }
517
375
  }
518
376
 
519
- /* --- Slide scaler + markdown ----------------------------------------------- */
520
- /* The scaler is authored at DESIGN_PANE px (see useSlideScale) and `zoom`-scaled to the live pane,
521
- so every dimension below is DESIGN px and stays proportional to the target frame at any size.
522
- One type scale drives the whole slide — every size steps off --fs-body, so retuning the slide for
523
- capture legibility is a ONE-NUMBER change (adapted from the studio reveal-engine). */
524
377
  .slide-panel__scaler {
525
378
  box-sizing: border-box;
526
- padding: 0 40px; /* portrait: trimmed side padding to fill the phone width (landscape restores 60) */
379
+ padding: 0 40px;
527
380
  color: var(--slide-ink);
528
- --fs-body: 31px; /* portrait/reels: larger so the slide fills the phone (landscape restores 26) */
381
+ --fs-body: 31px;
529
382
  --fs-title: calc(var(--fs-body) * 1.73);
530
383
  --fs-heading: calc(var(--fs-body) * 1.2);
531
384
  --fs-code: calc(var(--fs-body) * 0.78);
@@ -538,25 +391,23 @@ body {
538
391
  .slide-panel__scaler > :last-child {
539
392
  margin-bottom: 0;
540
393
  }
541
- /* `##` → the slide title. */
394
+
542
395
  .slide-panel__scaler h2 {
543
396
  margin: 0 0 20px;
544
397
  font-size: var(--fs-title);
545
398
  font-weight: 700;
546
399
  line-height: 1.15;
547
400
  letter-spacing: -0.01em;
548
- color: var(--brand); /* Python-blue brand accent — same anchor as the reel eyebrow + catalog */
401
+ color: var(--brand);
549
402
  }
550
- /* `###` → a section heading in Python-yellow: a warm counter to the cool blue title (the Python
551
- logo's blue + yellow). Sentence-case and sized above the body (a real heading, not a quiet
552
- eyebrow) — the warm/cool pair structures the slide at a glance while the body stays neutral. */
403
+
553
404
  .slide-panel__scaler h3 {
554
405
  margin: 30px 0 12px;
555
406
  font-size: var(--fs-heading);
556
407
  font-weight: 700;
557
408
  line-height: 1.2;
558
409
  letter-spacing: -0.01em;
559
- color: var(--accent-2); /* Python-yellow */
410
+ color: var(--accent-2);
560
411
  }
561
412
  .slide-panel__scaler p {
562
413
  margin: 0 0 16px;
@@ -596,17 +447,6 @@ body {
596
447
  color: rgba(var(--ink-rgb), 0.9);
597
448
  }
598
449
 
599
- /* --- Course catalog / landing page (CourseIndex) ---------------------------- */
600
- /* The index is a scrollable page (unlike the fixed stage): the catalog of courses, each course a
601
- card listing its ordered sections. Python-blue (--idx-accent) is the one accent. */
602
- /* ── Site chrome (SiteHeader) ──────────────────────────────────────────────────────────────────
603
- Ported from ui-graphl/styles.css, class for class. ui-graphl is buildless vanilla and this is a
604
- React package, so one shared component is impossible — but one shared DESIGN is not, and it only
605
- survives if neither copy drifts. Change this, change that. The single deliberate deviation is
606
- --site-h: over there the 60px row height is a literal, here .idx has to subtract it.
607
-
608
- Traditional chrome: brand on the left, section nav on the right, full-bleed rule underneath. The
609
- inner row matches the catalog's own max-width so the brand lines up with the cards below it. */
610
450
  .site {
611
451
  border-bottom: 1px solid var(--line);
612
452
  background: var(--header-bg);
@@ -614,10 +454,7 @@ body {
614
454
  .site__inner {
615
455
  max-width: 940px;
616
456
  margin: 0 auto;
617
- /* Explicit, and NOT optional: ui-graphl has a global `* { box-sizing: border-box }` and this
618
- stylesheet deliberately does not (a global reset here would move the section view, which is
619
- burned into video). Without it the bar's column is 940 + 64 of padding while .idx is 940
620
- inclusive, and the brand sits 32px left of the cards it is supposed to line up with. */
457
+
621
458
  box-sizing: border-box;
622
459
  padding: 0 32px;
623
460
  min-height: var(--site-h);
@@ -626,7 +463,7 @@ body {
626
463
  gap: 20px;
627
464
  }
628
465
  .site__brand {
629
- margin-right: auto; /* holds the brand left; nav + actions collect on the right */
466
+ margin-right: auto;
630
467
  display: inline-flex;
631
468
  align-items: center;
632
469
  gap: 10px;
@@ -669,16 +506,13 @@ body {
669
506
  outline: 2px solid var(--accent);
670
507
  outline-offset: 2px;
671
508
  }
672
- /* The icon buttons — the theme toggle and the account control when they exist. Kept separate from
673
- the nav so that on a phone the nav can drop to its own row while these stay up on the brand row.
674
- Empty until something is passed to SiteHeader's `actions` slot. */
509
+
675
510
  .site__actions {
676
511
  display: flex;
677
512
  align-items: center;
678
513
  gap: 6px;
679
514
  }
680
- /* Square icon button, styled here so the slot's occupant does not have to carry its own CSS. Sized
681
- 36px so it is a comfortable target without towering over the 60px header row. */
515
+
682
516
  .site__icon {
683
517
  flex: none;
684
518
  width: 36px;
@@ -705,9 +539,6 @@ body {
705
539
  outline-offset: 2px;
706
540
  }
707
541
 
708
- /* Phone: the nav row scrolls horizontally rather than collapsing into a hamburger. There are three
709
- sections today and there will be more, but they are the primary navigation and the whole point of
710
- the current one is being visible; a menu would hide "you are here" behind a tap. */
711
542
  @media (max-width: 560px) {
712
543
  .site__inner {
713
544
  padding: 8px 20px;
@@ -715,9 +546,9 @@ body {
715
546
  gap: 0 12px;
716
547
  }
717
548
  .site__nav {
718
- order: 3; /* after brand and actions ⇒ its own row */
549
+ order: 3;
719
550
  flex: 1 0 100%;
720
- margin: 2px -20px 0; /* bleed to the screen edges so the row can scroll fully */
551
+ margin: 2px -20px 0;
721
552
  padding: 0 20px 4px;
722
553
  overflow-x: auto;
723
554
  scrollbar-width: none;
@@ -728,7 +559,7 @@ body {
728
559
  .site__link {
729
560
  display: flex;
730
561
  align-items: center;
731
- min-height: 40px; /* comfortable touch target */
562
+ min-height: 40px;
732
563
  padding: 8px 10px;
733
564
  font-size: 14px;
734
565
  }
@@ -740,12 +571,10 @@ body {
740
571
 
741
572
  .idx {
742
573
  --idx-accent: var(--brand);
743
- --idx-muted: var(--muted); /* was the literal #8b909c — the same value --muted already holds, so
744
- aliasing is byte-identical on dark and picks up the light block */
574
+ --idx-muted: var(--muted);
745
575
  --idx-line: rgba(var(--ink-rgb), 0.09);
746
- /* The bar is a sibling above this column, so the viewport it can fill is what the bar leaves.
747
- On a phone the bar wraps to two rows and this under-subtracts — harmless, it is a min. */
748
- min-height: calc(100vh - var(--site-h) - 1px); /* the bar's row, plus its 1px rule */
576
+
577
+ min-height: calc(100vh - var(--site-h) - 1px);
749
578
  max-width: 940px;
750
579
  margin: 0 auto;
751
580
  padding: 28px 32px 64px;
@@ -754,8 +583,7 @@ body {
754
583
  .idx__head {
755
584
  margin-bottom: 36px;
756
585
  }
757
- /* Catalog header: just the subject title now — the GraphL wordmark it used to sit under is in
758
- the site bar above (SiteHeader), which is the same bar graphl.in carries. */
586
+
759
587
  .idx__subject {
760
588
  margin: 0;
761
589
  font-size: 40px;
@@ -771,7 +599,6 @@ body {
771
599
  border-radius: 14px;
772
600
  }
773
601
 
774
- /* Courses grid — a single column of accordion cards (tight gap since most sit collapsed). */
775
602
  .idx__grid {
776
603
  list-style: none;
777
604
  margin: 0;
@@ -780,8 +607,6 @@ body {
780
607
  gap: 12px;
781
608
  }
782
609
 
783
- /* A course card = an accordion: a header button (number · title · count · caret) that toggles the
784
- section list below it. */
785
610
  .idx-card {
786
611
  border: 1px solid var(--idx-line);
787
612
  border-radius: 14px;
@@ -806,7 +631,7 @@ body {
806
631
  .idx-card__head:hover {
807
632
  background: var(--surface-lift-2);
808
633
  }
809
- /* Divider only when open — a collapsed row is a clean single bar. */
634
+
810
635
  .idx-card--open .idx-card__head {
811
636
  border-bottom: 1px solid var(--idx-line);
812
637
  }
@@ -846,7 +671,6 @@ body {
846
671
  color: var(--idx-accent);
847
672
  }
848
673
 
849
- /* Section list inside a card — thin numbered rows, hover slides the chevron. */
850
674
  .idx-sec {
851
675
  list-style: none;
852
676
  margin: 0;
@@ -906,10 +730,6 @@ body {
906
730
  color: rgba(var(--ink-rgb), 0.9);
907
731
  }
908
732
 
909
- /* The bar's phone gutter is 20px, so the catalog column's has to be too — otherwise the brand and
910
- the subject title below it sit on two different left edges. Matches ui-graphl's own phone rule.
911
- It lives down HERE, not up with the rest of the .site phone rules, because .idx's own padding is
912
- declared after them and would otherwise win at equal specificity. */
913
733
  @media (max-width: 560px) {
914
734
  .idx {
915
735
  padding: 20px 20px 48px;
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Scene } from '@graphlearning/flow';
1
2
  export interface Section {
2
3
  id: string;
3
4
  title: string;
@@ -11,9 +12,9 @@ export interface Course {
11
12
  title: string;
12
13
  sections: Section[];
13
14
  }
14
- export type SceneLookup = (id: string) => import('@graphlearning/flow').Scene | undefined;
15
- export declare function slugOf(course: Course, section: Section): string;
16
- export declare function allSections(course: Course): {
15
+ export type SceneLookup = (id: string) => Scene | undefined;
16
+ export declare const slugOf: (course: Course, section: Section) => string;
17
+ export declare const allSections: (course: Course) => {
17
18
  section: Section;
18
19
  slug: string;
19
20
  }[];
@@ -1,16 +1,4 @@
1
1
  import type { ThemeKey } from '@graphlearning/flow';
2
- export declare function readStored(): ThemeKey | null;
3
- /**
4
- * The theme actually painted. CAPTURE PINS IT TO THE DECK, ignoring the stored choice and the reader
5
- * entirely — and that is not a nicety. A recorder runs a fresh headless profile with empty storage,
6
- * so without this pin what a capture painted would depend on the machine that rendered it. The
7
- * declared prop is the only thing burned into a video.
8
- */
9
- export declare const resolveTheme: (deck: ThemeKey, stored: ThemeKey | null, capture: boolean) => ThemeKey;
10
- /**
11
- * Owns the reader's choice and keeps <html data-theme> in step with it. Returns the theme to hand
12
- * SceneView, plus a two-state flip for the toggle.
13
- */
14
2
  export declare function useTheme(deck: ThemeKey, capture: boolean): {
15
3
  theme: ThemeKey;
16
4
  toggle: () => void;