@oceanum/oceanumlab 4.7.2 → 4.8.0

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.
@@ -189,6 +189,49 @@ export function buildSpecBody(
189
189
  return { ok: false, bytes: strippedBytes };
190
190
  }
191
191
 
192
+ /**
193
+ * The notebook stored in a record, untrusted. Throws if the record does not hold an
194
+ * nbformat 4 notebook.
195
+ */
196
+ function recordNotebook(record: ISpecRecord): INotebookContent {
197
+ const spec = record.spec;
198
+ if (!isNotebook(spec)) {
199
+ throw new Error('This Oceanum.io record is not a valid notebook.');
200
+ }
201
+ return withoutTrust(spec);
202
+ }
203
+
204
+ function isNotebook(value: unknown): value is INotebookContent {
205
+ return (
206
+ isObject(value) &&
207
+ value.nbformat === 4 &&
208
+ Array.isArray(value.cells) &&
209
+ value.cells.every(
210
+ cell => isObject(cell) && typeof cell.cell_type === 'string'
211
+ ) &&
212
+ isObject(value.metadata)
213
+ );
214
+ }
215
+
216
+ /**
217
+ * A notebook uploaded from the user's computer, untrusted and linked to nothing: a file
218
+ * someone else downloaded from a record must not save over that record, and its outputs
219
+ * must not run scripts on the origin that holds the Oceanum.io session. Throws if the
220
+ * text is not an nbformat 4 notebook; the message never quotes the file.
221
+ */
222
+ export function notebookFromUpload(text: string): INotebookContent {
223
+ let parsed: unknown;
224
+ try {
225
+ parsed = JSON.parse(text);
226
+ } catch {
227
+ throw new Error('The file is not a Jupyter notebook: it is not JSON.');
228
+ }
229
+ if (!isNotebook(parsed)) {
230
+ throw new Error('The file is not a Jupyter notebook in nbformat 4.');
231
+ }
232
+ return withoutLink(withoutTrust(parsed));
233
+ }
234
+
192
235
  /**
193
236
  * The notebook stored in a record, with its `oceanum` link set. Throws if the record
194
237
  * does not hold an nbformat 4 notebook.
@@ -197,26 +240,28 @@ export function notebookFromRecord(
197
240
  record: ISpecRecord,
198
241
  specsUrl: string
199
242
  ): INotebookContent {
200
- const spec = record.spec;
201
- if (
202
- !isObject(spec) ||
203
- spec.nbformat !== 4 ||
204
- !Array.isArray(spec.cells) ||
205
- !isObject(spec.metadata)
206
- ) {
207
- throw new Error('This Oceanum.io record is not a valid notebook.');
208
- }
209
243
  const link: IOceanumLink = { spec_id: record.id, specs_url: specsUrl };
210
244
  if (record.description) {
211
245
  link.description = record.description;
212
246
  }
213
- const content = withoutTrust(spec as unknown as INotebookContent);
247
+ const content = recordNotebook(record);
214
248
  return {
215
249
  ...content,
216
250
  metadata: { ...content.metadata, [METADATA_KEY]: { ...link } }
217
251
  };
218
252
  }
219
253
 
254
+ /**
255
+ * A copy of the notebook stored in a record, linked to nothing: untrusted, and without
256
+ * any `oceanum` link, so saving it creates a record of the user's own instead of
257
+ * writing to this one. Throws if the record does not hold an nbformat 4 notebook.
258
+ */
259
+ export function unlinkedNotebookFromRecord(
260
+ record: ISpecRecord
261
+ ): INotebookContent {
262
+ return withoutLink(recordNotebook(record));
263
+ }
264
+
220
265
  /** Make a record name safe to use as a single path segment. */
221
266
  export function sanitizeName(name: string): string {
222
267
  const clean = name
@@ -307,6 +352,70 @@ export function partitionSummaries(
307
352
  };
308
353
  }
309
354
 
355
+ /** The `kind` of a Notebook Demo record's spec. */
356
+ export const NOTEBOOK_DEMO_KIND = 'notebook-demo';
357
+
358
+ /** One example in a Notebook Demo record: a notebook record and how to list it. */
359
+ export interface INotebookDemoItem {
360
+ id: string;
361
+ title: string;
362
+ summary?: string;
363
+ }
364
+
365
+ /** A titled group of examples. */
366
+ export interface INotebookDemoSection {
367
+ title: string;
368
+ items: INotebookDemoItem[];
369
+ }
370
+
371
+ function nonBlank(value: unknown): value is string {
372
+ return typeof value === 'string' && value.trim() !== '';
373
+ }
374
+
375
+ /**
376
+ * The sections of a Notebook Demo record's spec, keeping only what can be listed: items
377
+ * with a record id and a title, in sections with a title and at least one such item.
378
+ * A spec of another kind or version gives no sections, with a warning.
379
+ */
380
+ export function parseNotebookDemo(spec: unknown): INotebookDemoSection[] {
381
+ if (
382
+ !isObject(spec) ||
383
+ spec.kind !== NOTEBOOK_DEMO_KIND ||
384
+ spec.version !== 1 ||
385
+ !Array.isArray(spec.sections)
386
+ ) {
387
+ console.warn(
388
+ 'Oceanum: ignoring a Notebook Demo record this version cannot read.'
389
+ );
390
+ return [];
391
+ }
392
+ const sections: INotebookDemoSection[] = [];
393
+ for (const section of spec.sections) {
394
+ if (
395
+ !isObject(section) ||
396
+ !nonBlank(section.title) ||
397
+ !Array.isArray(section.items)
398
+ ) {
399
+ continue;
400
+ }
401
+ const items: INotebookDemoItem[] = [];
402
+ for (const item of section.items) {
403
+ if (!isObject(item) || !isSpecId(item.id) || !nonBlank(item.title)) {
404
+ continue;
405
+ }
406
+ items.push(
407
+ typeof item.summary === 'string'
408
+ ? { id: item.id, title: item.title, summary: item.summary }
409
+ : { id: item.id, title: item.title }
410
+ );
411
+ }
412
+ if (items.length > 0) {
413
+ sections.push({ title: section.title, items });
414
+ }
415
+ }
416
+ return sections;
417
+ }
418
+
310
419
  /** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
311
420
  export function parseTimestamp(value: string): Date | null {
312
421
  const zoned = /(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`;
@@ -14,6 +14,9 @@ import type { Menu, Widget } from '@lumino/widgets';
14
14
  /** The widget id `@jupyterlab/filebrowser-extension:browser` gives the file browser. */
15
15
  export const FILE_BROWSER_ID = 'filebrowser';
16
16
 
17
+ /** The widget id of this extension's Oceanum.io panel, which takes the browser's place. */
18
+ export const OCEANUM_PANEL_ID = 'datamesh-connect';
19
+
17
20
  /**
18
21
  * File menu entries that manage local files. Save, Save All and Rename stay: saving is
19
22
  * routed to Oceanum, and renaming the local file is how a notebook gets its name.
@@ -28,23 +31,71 @@ export const LOCAL_FILE_COMMANDS: readonly string[] = [
28
31
  'docmanager:download'
29
32
  ];
30
33
 
34
+ /**
35
+ * File > New entries for files that are not notebooks. The spec store holds notebooks
36
+ * only, so such a file could never be saved anywhere the user can see.
37
+ */
38
+ export const NON_NOTEBOOK_NEW_COMMANDS: readonly string[] = [
39
+ 'fileeditor:create-new',
40
+ 'fileeditor:create-new-markdown-file'
41
+ ];
42
+
31
43
  /**
32
44
  * Take the file browser out of the sidebar; true if it was there. It is closed, not
33
45
  * disposed: other extensions hold it through IDefaultFileBrowser and still use it to
34
- * open paths.
46
+ * open paths. If it was the open panel, `instead` is opened in its place; a sidebar the
47
+ * user had collapsed stays collapsed.
35
48
  */
36
- export function hideFileBrowser(shell: {
37
- widgets(area: 'left'): Iterable<Widget>;
38
- }): boolean {
49
+ export function hideFileBrowser(
50
+ shell: {
51
+ widgets(area: 'left'): Iterable<Widget>;
52
+ activateById(id: string): void;
53
+ },
54
+ instead?: string
55
+ ): boolean {
39
56
  for (const widget of shell.widgets('left')) {
40
57
  if (widget.id === FILE_BROWSER_ID) {
58
+ const wasOpen = widget.isVisible;
41
59
  widget.close();
60
+ if (wasOpen && instead) {
61
+ shell.activateById(instead);
62
+ }
42
63
  return true;
43
64
  }
44
65
  }
45
66
  return false;
46
67
  }
47
68
 
69
+ /**
70
+ * Put a left-sidebar widget first, and keep it open if it was. True if it moved.
71
+ *
72
+ * A rank alone is not enough: the layout restorer replays the sidebar order each user
73
+ * last had, so a returning user would keep the panel wherever it used to be. Adding it
74
+ * again after the restore re-inserts it by rank, and the shell saves the new order.
75
+ */
76
+ export function moveToTop(
77
+ shell: {
78
+ widgets(area: 'left'): Iterable<Widget>;
79
+ add(widget: Widget, area: 'left', options: { rank: number }): void;
80
+ activateById(id: string): void;
81
+ },
82
+ id: string
83
+ ): boolean {
84
+ const widgets = Array.from(shell.widgets('left'));
85
+ const index = widgets.findIndex(widget => widget.id === id);
86
+ if (index <= 0) {
87
+ return false;
88
+ }
89
+ const widget = widgets[index];
90
+ // Adding hides the widget, so an open panel has to be opened again.
91
+ const wasOpen = widget.isVisible;
92
+ shell.add(widget, 'left', { rank: 0 });
93
+ if (wasOpen) {
94
+ shell.activateById(id);
95
+ }
96
+ return true;
97
+ }
98
+
48
99
  /**
49
100
  * Submenus of local files. "Open Recent" is built by the document manager rather than
50
101
  * declared in a schema, and its menu has no id, so it is known by the commands it holds.
@@ -16,7 +16,7 @@ import { IMainMenu } from '@jupyterlab/mainmenu';
16
16
  import type { INotebookContent } from '@jupyterlab/nbformat';
17
17
  import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
18
18
  import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
19
- import { Menu } from '@lumino/widgets';
19
+ import { Menu, type Widget } from '@lumino/widgets';
20
20
 
21
21
  import { startSignIn } from '../auth/startSignIn';
22
22
  import { IOceanumAuth } from '../auth/tokens';
@@ -29,6 +29,7 @@ import {
29
29
  ISpecRecord,
30
30
  METADATA_KEY,
31
31
  notebookFromRecord,
32
+ notebookFromUpload,
32
33
  notebookNamesFor,
33
34
  OCEANUM_DIR,
34
35
  partitionSummaries,
@@ -37,18 +38,25 @@ import {
37
38
  reportForShareFailures,
38
39
  sanitizeName,
39
40
  shareLink,
40
- uniqueNotebookPath
41
+ uniqueNotebookPath,
42
+ unlinkedNotebookFromRecord
41
43
  } from './notebook';
42
44
  import {
43
45
  hideFileBrowser,
46
+ moveToTop,
47
+ NON_NOTEBOOK_NEW_COMMANDS,
48
+ OCEANUM_PANEL_ID,
44
49
  pruneBeforeEachOpen,
45
50
  pruneFileMenu,
46
51
  routeSaves
47
52
  } from './oceanumOnly';
53
+ import { chooseNotebookFile } from './upload';
48
54
  import { NotebookListBody, ShareBody } from './widgets';
49
55
 
50
56
  export const CommandIDs = {
51
57
  open: 'oceanum-share:open',
58
+ openExample: 'oceanum-share:open-example',
59
+ upload: 'oceanum-share:upload',
52
60
  save: 'oceanum-share:save',
53
61
  share: 'oceanum-share:share',
54
62
  rename: 'oceanum-share:rename',
@@ -317,6 +325,23 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
317
325
  return null;
318
326
  }
319
327
 
328
+ /** Write a notebook to `path` on the local drive and open it. */
329
+ async function writeAndOpen(
330
+ path: string,
331
+ content: INotebookContent
332
+ ): Promise<Widget> {
333
+ await docManager.services.contents.save(path, {
334
+ type: 'notebook',
335
+ format: 'json',
336
+ content
337
+ });
338
+ const widget = docManager.openOrReveal(path);
339
+ if (!widget) {
340
+ throw new Error(`Could not open ${path}.`);
341
+ }
342
+ return widget;
343
+ }
344
+
320
345
  /** Write a record to the local drive as `Oceanum/<name>.ipynb` and open it. */
321
346
  async function openRecord(s: IStore, record: ISpecRecord): Promise<void> {
322
347
  // Already open: bring it forward rather than writing a second copy.
@@ -333,14 +358,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
333
358
  const path =
334
359
  (await pathHoldingRecord(s, record, names)) ??
335
360
  uniqueNotebookPath(record.name, names);
336
- await docManager.services.contents.save(path, {
337
- type: 'notebook',
338
- format: 'json',
339
- content
340
- });
341
- if (!docManager.openOrReveal(path)) {
342
- throw new Error(`Could not open ${path}.`);
343
- }
361
+ await writeAndOpen(path, content);
344
362
  }
345
363
 
346
364
  async function openById(id: string): Promise<void> {
@@ -359,6 +377,84 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
359
377
  }
360
378
  }
361
379
 
380
+ // The copy each example was last opened into, so opening it again while that copy
381
+ // is still open brings it forward rather than writing another.
382
+ const exampleCopies = new Map<string, Widget>();
383
+ // Examples whose copy is still being written, so a second click in that time does
384
+ // not write a second copy.
385
+ const openingExamples = new Set<string>();
386
+
387
+ /**
388
+ * Open a copy of an example as a new file in the Oceanum folder, untrusted and
389
+ * linked to nothing. Not `openRecord`: that links the file to the example's record,
390
+ * which is Oceanum's and read-only to everyone else, so the first save would be
391
+ * refused. Unlinked, the first save creates a record of the user's own.
392
+ */
393
+ async function openExample(id: string, title: string): Promise<void> {
394
+ if (
395
+ !store ||
396
+ !(await ensureSignedIn(
397
+ 'Opening notebooks needs an Oceanum.io account.'
398
+ ))
399
+ ) {
400
+ return;
401
+ }
402
+ const copy = exampleCopies.get(id);
403
+ if (copy && !copy.isDisposed) {
404
+ app.shell.activateById(copy.id);
405
+ return;
406
+ }
407
+ if (openingExamples.has(id)) {
408
+ return;
409
+ }
410
+ openingExamples.add(id);
411
+ try {
412
+ const record = await store.client.get(id);
413
+ const content = unlinkedNotebookFromRecord(record);
414
+ const path = uniqueNotebookPath(
415
+ title || record.name,
416
+ await oceanumFolderNames()
417
+ );
418
+ exampleCopies.set(id, await writeAndOpen(path, content));
419
+ } catch (error) {
420
+ await reportError('Open from Oceanum failed', error);
421
+ } finally {
422
+ openingExamples.delete(id);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Bring a notebook from the user's computer into the Oceanum folder and open it,
428
+ * untrusted and linked to nothing. It reaches Oceanum on its first save, like any
429
+ * new notebook; nothing is sent on upload.
430
+ */
431
+ async function uploadNotebook(): Promise<void> {
432
+ if (
433
+ !store ||
434
+ !(await ensureSignedIn(
435
+ 'Uploading notebooks needs an Oceanum.io account.'
436
+ ))
437
+ ) {
438
+ return;
439
+ }
440
+ try {
441
+ const file = await chooseNotebookFile();
442
+ if (!file) {
443
+ return;
444
+ }
445
+ const content = notebookFromUpload(await file.text());
446
+ const path = uniqueNotebookPath(file.name, await oceanumFolderNames());
447
+ await writeAndOpen(path, content);
448
+ // Outside Oceanum-only mode a plain Save stays local.
449
+ Notification.info(
450
+ `Uploaded "${file.name}". ${oceanumOnly ? 'Save it' : 'Use Save to Oceanum'} to store it on Oceanum.io.`,
451
+ { autoClose: 5000 }
452
+ );
453
+ } catch (error) {
454
+ await reportError('Upload failed', error);
455
+ }
456
+ }
457
+
362
458
  async function openFromOceanum(): Promise<void> {
363
459
  if (
364
460
  !store ||
@@ -959,6 +1055,26 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
959
1055
  await (id ? openById(id) : openFromOceanum());
960
1056
  }
961
1057
  });
1058
+ // Run by the Notebooks tab's Examples; not offered in menus or the palette.
1059
+ app.commands.addCommand(CommandIDs.openExample, {
1060
+ label: 'Open a copy',
1061
+ caption: 'Open a copy of an Oceanum example notebook',
1062
+ isEnabled: () => signedIn(),
1063
+ execute: async args => {
1064
+ if (isSpecId(args.id)) {
1065
+ await openExample(
1066
+ args.id,
1067
+ typeof args.title === 'string' ? args.title : ''
1068
+ );
1069
+ }
1070
+ }
1071
+ });
1072
+ app.commands.addCommand(CommandIDs.upload, {
1073
+ label: label('Upload Notebook…'),
1074
+ caption: 'Upload a notebook from this computer, to save on Oceanum.io',
1075
+ isEnabled: () => signedIn(),
1076
+ execute: () => uploadNotebook()
1077
+ });
962
1078
  app.commands.addCommand(CommandIDs.save, {
963
1079
  label: label('Save to Oceanum'),
964
1080
  caption: 'Save the notebook to Oceanum.io',
@@ -1013,6 +1129,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
1013
1129
 
1014
1130
  const commands = [
1015
1131
  CommandIDs.open,
1132
+ CommandIDs.upload,
1016
1133
  CommandIDs.save,
1017
1134
  CommandIDs.share,
1018
1135
  CommandIDs.rename,
@@ -1075,7 +1192,9 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
1075
1192
 
1076
1193
  if (oceanumOnly) {
1077
1194
  void app.restored.then(() => {
1078
- hideFileBrowser(app.shell);
1195
+ hideFileBrowser(app.shell, OCEANUM_PANEL_ID);
1196
+ // Oceanum is where notebooks live now, so its panel leads the sidebar.
1197
+ moveToTop(app.shell, OCEANUM_PANEL_ID);
1079
1198
  // IFileMenu is the narrow interface; the menu itself is a Lumino Menu, which
1080
1199
  // is what can drop items.
1081
1200
  const fileMenu = mainMenu?.fileMenu;
@@ -1083,6 +1202,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
1083
1202
  pruneFileMenu(fileMenu);
1084
1203
  pruneBeforeEachOpen(fileMenu);
1085
1204
  }
1205
+ const newMenu = mainMenu?.fileMenu.newMenu;
1206
+ if (newMenu instanceof Menu) {
1207
+ pruneFileMenu(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
1208
+ pruneBeforeEachOpen(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
1209
+ }
1086
1210
  routeSaves(tracker, pushQuietly);
1087
1211
  });
1088
1212
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ask the user for a notebook file on their computer; null if they cancel.
3
+ *
4
+ * Kept apart from the plugin because it is browser-only: the tests replace it.
5
+ */
6
+ export function chooseNotebookFile(): Promise<File | null> {
7
+ return new Promise(resolve => {
8
+ const input = document.createElement('input');
9
+ input.type = 'file';
10
+ input.accept = '.ipynb,application/x-ipynb+json';
11
+ input.hidden = true;
12
+ // Attached while the picker is open: some browsers (Safari) drop the `change`
13
+ // event of a detached input, or collect the input before it fires.
14
+ document.body.appendChild(input);
15
+ const settle = (file: File | null): void => {
16
+ input.remove();
17
+ resolve(file);
18
+ };
19
+ input.addEventListener('change', () => settle(input.files?.[0] ?? null), {
20
+ once: true
21
+ });
22
+ // Browsers fire `cancel` when the picker is dismissed; without it the promise
23
+ // would simply never settle, which is harmless.
24
+ input.addEventListener('cancel', () => settle(null), { once: true });
25
+ input.click();
26
+ });
27
+ }
package/style/index.css CHANGED
@@ -600,7 +600,8 @@
600
600
  list-style: none;
601
601
  }
602
602
 
603
- .oceanum-notebooks-item {
603
+ .oceanum-notebooks-item,
604
+ .oceanum-notebooks-example {
604
605
  display: block;
605
606
  width: 100%;
606
607
  padding: 4px 8px;
@@ -613,7 +614,8 @@
613
614
  cursor: pointer;
614
615
  }
615
616
 
616
- .oceanum-notebooks-item:hover {
617
+ .oceanum-notebooks-item:hover,
618
+ .oceanum-notebooks-example:hover {
617
619
  background: var(--jp-layout-color2);
618
620
  }
619
621
 
@@ -621,10 +623,107 @@
621
623
  cursor: default;
622
624
  }
623
625
 
626
+ .oceanum-notebooks-actions {
627
+ padding: 6px 8px 2px;
628
+ }
629
+
630
+ .oceanum-notebooks-upload {
631
+ padding: 3px 8px;
632
+ border: var(--jp-border-width) solid var(--jp-border-color1);
633
+ border-radius: var(--jp-border-radius);
634
+ background: var(--jp-layout-color1);
635
+ color: var(--jp-ui-font-color1);
636
+ font-family: var(--jp-ui-font-family);
637
+ font-size: var(--jp-ui-font-size1);
638
+ cursor: pointer;
639
+ }
640
+
641
+ .oceanum-notebooks-upload:hover {
642
+ background: var(--jp-layout-color2);
643
+ }
644
+
624
645
  .oceanum-notebooks-item-static:hover {
625
646
  background: transparent;
626
647
  }
627
648
 
649
+ /* The Examples heading's count and show/hide switch, kept together on the right. */
650
+ .oceanum-notebooks-heading-end {
651
+ display: flex;
652
+ align-items: center;
653
+ gap: 6px;
654
+ }
655
+
656
+ /* The Examples show/hide switch (a button with role="switch"). */
657
+ .oceanum-notebooks-switch {
658
+ position: relative;
659
+ flex: none;
660
+ width: 24px;
661
+ height: 14px;
662
+ padding: 0;
663
+ border: none;
664
+ border-radius: 7px;
665
+ background: var(--jp-layout-color3);
666
+ cursor: pointer;
667
+ }
668
+
669
+ .oceanum-notebooks-switch[aria-checked='true'] {
670
+ background: var(--jp-brand-color1);
671
+ }
672
+
673
+ .oceanum-notebooks-switch:focus-visible {
674
+ outline: 1px solid var(--jp-brand-color1);
675
+ outline-offset: 1px;
676
+ }
677
+
678
+ .oceanum-notebooks-switch-thumb {
679
+ position: absolute;
680
+ top: 2px;
681
+ left: 2px;
682
+ width: 10px;
683
+ height: 10px;
684
+ border-radius: 50%;
685
+ background: var(--jp-layout-color0);
686
+ transition: left 0.1s ease;
687
+ }
688
+
689
+ .oceanum-notebooks-switch[aria-checked='true'] .oceanum-notebooks-switch-thumb {
690
+ left: 12px;
691
+ }
692
+
693
+ /* Forced colours replace backgrounds, which would leave a switch drawn only with them
694
+ invisible: draw it with system colours instead. */
695
+ @media (forced-colors: active) {
696
+ .oceanum-notebooks-switch {
697
+ forced-color-adjust: none;
698
+ box-sizing: border-box;
699
+ border: 1px solid ButtonText;
700
+ background: Canvas;
701
+ }
702
+
703
+ .oceanum-notebooks-switch[aria-checked='true'] {
704
+ background: Highlight;
705
+ }
706
+
707
+ .oceanum-notebooks-switch-thumb {
708
+ top: 1px;
709
+ left: 1px;
710
+ background: ButtonText;
711
+ }
712
+
713
+ .oceanum-notebooks-switch[aria-checked='true']
714
+ .oceanum-notebooks-switch-thumb {
715
+ left: 11px;
716
+ background: HighlightText;
717
+ }
718
+ }
719
+
720
+ /* A group within Examples: quieter than the tab's section headings. */
721
+ .oceanum-notebooks-subheading {
722
+ padding: 4px 8px 0;
723
+ color: var(--jp-ui-font-color2);
724
+ font-size: var(--jp-ui-font-size0);
725
+ }
726
+
628
727
  .oceanum-notebooks-name {
629
728
  overflow: hidden;
630
729
  text-overflow: ellipsis;