Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/notebook-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: notebook e2e

on:
push:
paths:
- ".github/workflows/notebook-e2e.yml"
- "Cargo.lock"
- "Cargo.toml"
- "crates/sema-notebook/**"
pull_request:
paths:
- ".github/workflows/notebook-e2e.yml"
- "Cargo.lock"
- "Cargo.toml"
- "crates/sema-notebook/**"

jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install system dependencies
# The webServer command (playwright.config.ts) shells out to
# `cargo run --bin sema -- notebook serve`, which compiles the native
# binary. That pulls in `serialport` (transitive `libudev-sys`), which
# needs libudev-dev on Linux. Mirrors the same install in ci.yml.
run: |
sudo apt-get update
sudo apt-get install -y libudev-dev

- uses: dtolnay/rust-toolchain@stable

- name: Cache cargo + target
uses: Swatinem/rust-cache@v2

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Pre-build the sema binary
# The playwright webServer launches `cargo run --bin sema` with a 60s
# startup timeout; a cold-cache debug build takes minutes. Building here
# keeps the in-webServer `cargo run` near-instant.
run: cargo build --bin sema

- run: npm ci
working-directory: crates/sema-notebook/tests/e2e

- run: npx playwright install --with-deps chromium
working-directory: crates/sema-notebook/tests/e2e

- run: npx playwright test
working-directory: crates/sema-notebook/tests/e2e

- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
# test-results/ (traces, screenshots, error context) is always
# produced; the config declares no html reporter.
path: crates/sema-notebook/tests/e2e/test-results/
12 changes: 12 additions & 0 deletions crates/sema-notebook/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub fn asset(path: &str) -> Option<(String, String)> {
sema_ui_js().to_string(),
"application/javascript".to_string(),
)),
"vendor/tokens.css" => Some((tokens_css().to_string(), "text/css".to_string())),
_ => None,
}
}
Expand Down Expand Up @@ -68,3 +69,14 @@ fn js() -> &'static str {
fn sema_ui_js() -> &'static str {
include_str!("ui/vendor/sema-ui.js")
}

/// The `@sema-lang/ui` design-token sheet — the `--gold*`/`--text-*`/spacing/radius
/// custom properties the component bundle's own styles fall back to. Linked before
/// `style.css` so the notebook's palette overrides land on top. Vendored from the
/// published npm package's `src/styles/tokens.css`. Refresh alongside `sema_ui_js`
/// by fetching the pinned version from the unpkg CDN:
/// curl -fsSL https://unpkg.com/@sema-lang/ui@<version>/src/styles/tokens.css \
/// -o crates/sema-notebook/src/ui/vendor/tokens.css
fn tokens_css() -> &'static str {
include_str!("ui/vendor/tokens.css")
}
171 changes: 103 additions & 68 deletions crates/sema-notebook/src/ui/index.html

Large diffs are not rendered by default.

67 changes: 32 additions & 35 deletions crates/sema-notebook/src/ui/notebook.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* Sema Notebook — Alpine.js Component */
/* Sema Notebook — Alpine.js Component (ES module) */
import { toast } from './vendor/sema-ui.js';

document.addEventListener('alpine:init', () => {
Alpine.data('notebook', () => ({
// ── State ──
Expand All @@ -7,18 +9,11 @@ document.addEventListener('alpine:init', () => {
focusedCellId: null,
canUndo: false,
shiftEnterUsed: localStorage.getItem('sema-nb-shift-enter-used') === 'true',
openDropdownId: null,
saveFeedback: false,
resetDialogOpen: false,

// ── Lifecycle ──
init() {
this.load();
// Close dropdowns on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.add-cell-btn') && !e.target.closest('.add-cell-dropdown')) {
this.openDropdownId = null;
}
});
},

// ── API helper ──
Expand Down Expand Up @@ -101,6 +96,21 @@ document.addEventListener('alpine:init', () => {
}
},

// The editable control lives in the editor's shadow root; the host element's
// focus() delegates into it. (A new/empty markdown cell opens in edit mode, so
// it too renders a <sema-editor>.) `sema-editor` upgrades asynchronously (it
// registers from a `type="module"` script), so a focus() called right after
// Alpine renders the host can land before upgrade and silently no-op —
// customElements.whenDefined guards against that race.
focusCellEditor(id) {
this.$nextTick(() => {
customElements.whenDefined('sema-editor').then(() => {
const el = document.querySelector('#cell-' + id + ' sema-editor');
if (el) el.focus();
});
});
},

// ── Cell management ──
async addCell(type, afterId) {
try {
Expand All @@ -109,33 +119,20 @@ document.addEventListener('alpine:init', () => {
const data = await this.api('POST', '/api/cells', body);
await this.load();
this.focusedCellId = data.id;
this.$nextTick(() => {
// The editable control lives in the editor's shadow root; the host
// element's focus() delegates into it. (A new/empty markdown cell opens
// in edit mode, so it too renders a <sema-editor>.)
const el = document.querySelector('#cell-' + data.id + ' sema-editor');
if (el) el.focus();
});
this.focusCellEditor(data.id);
} catch (e) {
console.error('Failed to create cell:', e);
}
},

async insertCell(type, afterId) {
this.openDropdownId = null;
const body = { type, source: '' };
if (afterId && afterId !== 'top') body.after = afterId;
try {
const data = await this.api('POST', '/api/cells', body);
await this.load();
this.focusedCellId = data.id;
this.$nextTick(() => {
// The editable control lives in the editor's shadow root; the host
// element's focus() delegates into it. (A new/empty markdown cell opens
// in edit mode, so it too renders a <sema-editor>.)
const el = document.querySelector('#cell-' + data.id + ' sema-editor');
if (el) el.focus();
});
this.focusCellEditor(data.id);
} catch (e) {
console.error('Failed to insert cell:', e);
}
Expand Down Expand Up @@ -174,10 +171,9 @@ document.addEventListener('alpine:init', () => {
// stale content and the edits appear lost.
await Promise.all([this.persistTitle(), ...this.cells.map(c => this.persistSource(c))]);
await this.api('POST', '/api/save');
this.saveFeedback = true;
setTimeout(() => { this.saveFeedback = false; }, 600);
toast.success('Saved');
} catch (e) {
alert('Save failed: ' + e.message);
toast.error('Save failed: ' + e.message);
}
},

Expand All @@ -191,8 +187,12 @@ document.addEventListener('alpine:init', () => {
}
},

async reset() {
if (!confirm('Reset the environment? All cell outputs will be cleared.')) return;
openReset() {
this.resetDialogOpen = true;
},

async confirmReset() {
this.resetDialogOpen = false;
try {
await this.api('POST', '/api/reset');
this.canUndo = false;
Expand All @@ -211,8 +211,8 @@ document.addEventListener('alpine:init', () => {
},

// Push the notebook title to the server. Like cell source, the title is
// client-only state (x-model) until synced, so without this a renamed
// notebook would save under its old title.
// client-only state until synced, so without this a renamed notebook would
// save under its old title.
persistTitle() {
return this.api('POST', '/api/title', { title: this.title }).catch(() => {});
},
Expand All @@ -224,10 +224,7 @@ document.addEventListener('alpine:init', () => {
if (!cell) return;
cell._rendered = false;
this.focusedCellId = id;
this.$nextTick(() => {
const ed = document.querySelector('#cell-' + id + ' sema-editor');
if (ed) ed.focus();
});
this.focusCellEditor(id);
},

// Editor lost focus (or Escape): persist, and return a non-empty markdown cell
Expand Down
Loading
Loading