<!DOCTYPE html>
<html>
<head>
<title>Javelit App</title>
<link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,[email protected],100..700,0..1,-50..200&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,[email protected],100..700,0..1,-50..200&display=swap">
<link rel="alternate" type="application/json+oembed" href="/_/oembed?url=https://workshop.labdevrel.ovh/&format=json" title="Javelit App">
<style>
:root {
--jt-primary-color: #0066cc;
--jt-primary-hover: #0052a3;
--jt-primary-active: #003d7a;
/* Streamlit-like theme color */
--jt-theme-color: #ff4b4b;
--jt-theme-hover: #ff6c6c;
--jt-theme-active: #ff2b2b;
--jt-secondary-color: #6c757d;
--jt-secondary-hover: #545b62;
--jt-tertiary-color: #e9ecef;
--jt-tertiary-hover: #dee2e6;
--jt-success-color: rgb(23, 114, 51);
--jt-danger-color: #dc3545;
--jt-warning-color: rgb(146, 108, 5);
--jt-info-color: rgb(0, 66, 128);
--jt-error-color: rgb(125, 53, 59);
--jt-text-primary: #212529;
--jt-text-secondary: #6c757d;
--jt-text-muted: #868e96;
--jt-text-white: #ffffff;
--jt-bg-primary: #ffffff;
--jt-bg-secondary: #f8f9fa;
--jt-bg-tertiary: #e9ecef;
--jt-bg-input: rgb(240, 242, 246);
--jt-bg-error: rgba(255, 43, 43, 0.09);
--jt-bg-success: rgba(33, 195, 84, 0.1);;
--jt-bg-info: rgba(28, 131, 225, 0.1);
--jt-bg-warning: rgba(255, 227, 18, 0.1);
--jt-border-color: #dee2e6;
--jt-light-border-color: #f1f3f4;
--jt-border-radius: 0.38rem;
--jt-border-radius-lg: 0.5rem;
--jt-border-radius-sm: 0.25rem;
--jt-border-radius-popover: 0.75rem;
--jt-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--jt-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--jt-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--jt-shadow-popover: rgba(0, 0, 0, 0.16) 0px 4px 16px;
--jt-spacing-xs: 4px;
--jt-spacing-sm: 8px;
--jt-spacing-md: 12px;
--jt-spacing-lg: 16px;
--jt-spacing-xl: 24px;
--jt-spacing-2xl: 32px;
--jt-spacing-3xl: 40px;
--jt-spacing-4xl: 48px;
--jt-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--jt-font-family-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;
--jt-font-size-xs: 0.75rem;
--jt-font-size-sm: 0.875rem;
--jt-font-size-base: 1rem;
--jt-font-size-lg: 1.125rem;
--jt-font-size-xl: 1.25rem;
--jt-font-size-2xl: 1.5rem;
--jt-font-size-3xl: 1.875rem;
--jt-font-size-4xl: 2.25rem;
--jt-font-weight-normal: 400;
--jt-font-weight-medium: 500;
--jt-font-weight-semibold: 600;
--jt-font-weight-bold: 700;
--jt-line-height-tight: 1.25;
--jt-line-height-normal: 1.5;
--jt-line-height-relaxed: 1.75;
--jt-transition-fast: 150ms ease-in-out;
--jt-transition-normal: 250ms ease-in-out;
--jt-transition-slow: 350ms ease-in-out;
}
</style>
<style>
html, body {
height: 100%;
}
body {
font-family: var(--jt-font-family);
margin: 0;
background-color: var(--jt-bg-primary);
color: var(--jt-text-primary);
line-height: var(--jt-line-height-normal);
}
.app-wrapper {
display: flex;
height: 100vh;
gap: var(--jt-spacing-lg);
}
.layout-container {
background: var(--jt-bg-primary);
padding: var(--jt-spacing-xl);
padding-bottom: 0;
padding-top: 0;
overflow-y: auto;
overflow-x: hidden;
height: 100%;
flex: 1;
min-width: 0; /* Allow shrinking */
}
.main-layout {
/* TODO make this configurable */
max-width: 768px;
margin: 0 auto;
/* likely people will ask for this - first child margin
:first-child {
margin-top: 0;
}
*/
}
.toolbar {
min-height: 3.25rem;
background: var(--jt-bg-primary);
margin: 0 0;
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--jt-spacing-md);
padding: 0 var(--jt-spacing-lg);
position: sticky;
top: 0;
z-index: 10;
}
.full-sidebar {
display: block;
width: 256px;
flex-shrink: 0;
background: var(--jt-bg-secondary);
position: relative;
transition: width var(--jt-transition-normal);
padding: 0 var(--jt-spacing-xl);
overflow-y: auto;
height: 100%;
}
/* Hide entire sidebar when content is empty */
.full-sidebar:has(.sidebar-content:empty) {
display: none;
}
/* Fallback for browsers without :has() support */
@supports not (selector(:has(*))) {
.sidebar-content:empty {
display: none;
}
.sidebar-content:empty + * {
display: none;
}
}
/* Collapsed state */
.full-sidebar.collapsed {
width: 24px;
background-color: var(--jt-bg-primary);
overflow-y: hidden;
}
/* Sidebar tooling (chevron area) */
.sidebar-tooling {
display: flex;
justify-content: right;
padding-top: var(--jt-spacing-md);
}
/* Collapse button */
.collapse-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: var(--jt-border-radius-lg);
color: var(--jt-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all var(--jt-transition-fast);
}
.collapse-btn:hover {
background: var(--jt-bg-tertiary);
color: var(--jt-text-primary);
}
/* Hide chevron by default when sidebar is expanded */
.full-sidebar:not(.collapsed) .collapse-btn {
opacity: 0;
transition: opacity var(--jt-transition-fast);
}
/* Show chevron on hover when sidebar is expanded */
.full-sidebar:not(.collapsed):hover .collapse-btn {
opacity: 1;
}
/* Always show chevron when collapsed */
.full-sidebar.collapsed .collapse-btn {
opacity: 1;
}
/* Rotate chevron when collapsed */
.full-sidebar.collapsed .collapse-btn .material-symbols-rounded {
transform: rotate(180deg);
}
/* Hide content when collapsed, but keep tooling visible */
.full-sidebar.collapsed .sidebar-content {
opacity: 0;
pointer-events: none;
}
/* Resize handle for dragging sidebar width */
.sidebar-resize-handle {
position: absolute;
top: 0;
right: -3px;
bottom: 0;
width: 6px;
cursor: col-resize;
background: transparent;
z-index: 20;
transition: background-color var(--jt-transition-fast);
}
.sidebar-resize-handle:hover {
background: var(--jt-border-color);
}
/* Hide handle when sidebar is collapsed */
.full-sidebar.collapsed .sidebar-resize-handle {
display: none;
}
/* Prevent text selection during resize */
body.resizing {
user-select: none;
cursor: col-resize;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--jt-spacing-md);
height: 200px;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--jt-border-color);
border-top-color: var(--jt-theme-active);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-family: var(--jt-font-family);
font-size: var(--jt-font-size-sm);
color: var(--jt-text-secondary);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes errorPulse {
0% {
box-shadow: 0 0 0 0 var(--jt-theme-color);
}
50% {
box-shadow: 0 0 0 4px var(--jt-theme-color);
}
100% {
box-shadow: 0 0 0 0 var(--jt-theme-color);
}
}
.error-updated {
border-radius: var(--jt-border-radius-lg);
animation: errorPulse 0.6s linear 3;
}
.javelit-attribution {
position: fixed;
bottom: 0;
right: 0;
z-index: 500;
font-family: var(--jt-font-family);
font-size: var(--jt-font-size-xs);
color: var(--jt-text-white);
text-decoration: none;
transition: color var(--jt-transition-fast);
background-color: var(--jt-theme-color);
padding: var(--jt-spacing-sm) var(--jt-spacing-md);
border-radius: var(--jt-border-radius) 0 0 0;
}
.javelit-attribution:hover {
font-weight: var(--jt-font-weight-bold);
padding: var(--jt-spacing-sm) var(--jt-spacing-sm);
}
/* Embed mode styles - activated when ?embed=true */
#root {
/* Transparent container in normal mode */
}
body.embed-mode {
margin: 0;
padding: 0;
overflow: hidden; /* Prevent body scroll */
}
body.embed-mode #root {
display: flex;
flex-direction: column;
height: 100vh;
border: 3px solid var(--jt-bg-tertiary);
border-radius: 0.75rem;
overflow: hidden;
}
body.embed-mode .app-wrapper {
flex: 1;
overflow-y: auto; /* Single scrollbar inside the border */
overflow-x: hidden;
}
/* Embed footer - hidden by default, shown in embed mode */
.embed-footer {
display: none;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--jt-bg-tertiary);
font-size: 14px;
color: var(--jt-text-secondary);
font-family: var(--jt-font-family);
}
body.embed-mode .embed-footer {
display: flex;
}
.embed-footer a {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--jt-text-secondary);
text-decoration: none;
}
.embed-footer a:hover .link-text {
text-decoration: underline;
}
.embed-footer .material-symbols-rounded {
font-size: 16px;
}
</style>
<script type="module">
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';
class JtTooltip extends LitElement {
static styles = css`
:host {
position: relative;
display: inline-block;
}
.tooltip-trigger {
cursor: help;
color: var(--jt-text-secondary);
font-size: var(--jt-font-size-sm);
margin-left: var(--jt-spacing-xs);
}
.tooltip-content {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--jt-text-primary);
color: var(--jt-text-white);
padding: var(--jt-spacing-sm) var(--jt-spacing-md);
border-radius: var(--jt-border-radius-sm);
font-size: var(--jt-font-size-sm);
white-space: nowrap;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity var(--jt-transition-fast), visibility var(--jt-transition-fast);
margin-bottom: var(--jt-spacing-xs);
}
.tooltip-content::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: var(--jt-text-primary);
}
:host(:hover) .tooltip-content {
opacity: 1;
visibility: visible;
}
`;
static properties = {
text: { type: String }
};
render() {
return html`
<span class="tooltip-trigger">?</span>
<div class="tooltip-content">${this.text}</div>
`;
}
}
customElements.define('jt-tooltip', JtTooltip);
</script>
<script type="module">
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';
class JtConnectionErrorModal extends LitElement {
static styles = css`
:host {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: none;
}
:host([show]) {
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--jt-bg-primary);
border-radius: var(--jt-border-radius-lg);
box-shadow: var(--jt-shadow-lg);
min-width: 400px;
max-width: 80%;
margin: var(--jt-spacing-xl);
z-index: 1;
max-height: 90%;
display: flex;
flex-direction: column;
}
:host([top-aligned]) .modal-container {
top: 8%;
transform: translate(-50%, 0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--jt-spacing-lg);
border-bottom: 1px solid var(--jt-border-color);
}
:host([prevent-close]) .modal-header {
justify-content: center;
}
.modal-title {
font-family: var(--jt-font-family);
font-size: var(--jt-font-size-lg);
font-weight: var(--jt-font-weight-bold);
color: var(--jt-text-primary);
margin: 0;
}
.close-button {
background: none;
border: none;
font-family: 'Material Symbols Rounded';
font-size: var(--jt-font-size-xl);
color: var(--jt-text-secondary);
cursor: pointer;
padding: var(--jt-spacing-xs);
border-radius: var(--jt-border-radius);
transition: background-color var(--jt-transition-fast), color var(--jt-transition-fast);
}
.close-button:hover {
background-color: var(--jt-bg-tertiary);
color: var(--jt-text-primary);
}
.modal-body {
padding: var(--jt-spacing-lg);
font-family: var(--jt-font-family);
font-size: var(--jt-font-size-base);
line-height: var(--jt-line-height-relaxed);
color: var(--jt-text-primary);
position: relative;
}
.code-snippet {
background: var(--jt-bg-secondary);
border: 1px solid var(--jt-border-color);
border-radius: var(--jt-border-radius);
padding: var(--jt-spacing-sm) var(--jt-spacing-md);
margin: var(--jt-spacing-md) 0;
font-family: var(--jt-font-family-mono);
font-size: var(--jt-font-size-sm);
color: var(--jt-text-primary);
cursor: pointer;
user-select: all;
position: relative;
transition: background-color var(--jt-transition-fast);
}
.code-snippet:hover {
background: var(--jt-bg-tertiary);
}
::slotted(h1), ::slotted(h2), ::slotted(h3) {
margin: 0;
font-family: var(--jt-font-family);
font-weight: var(--jt-font-weight-bold);
color: var(--jt-text-primary);
}
::slotted(p) {
margin: 0 0 var(--jt-spacing-md) 0;
font-family: var(--jt-font-family);
font-size: var(--jt-font-size-base);
line-height: var(--jt-line-height-relaxed);
color: var(--jt-text-primary);
}
::slotted(p:last-child) {
margin-bottom: 0;
}
`;
static properties = {
show: { type: Boolean, reflect: true },
preventClose: { type: Boolean, reflect: true, attribute: 'prevent-close' }
};
constructor() {
super();
this.show = false;
this.preventClose = false;
}
firstUpdated() {
super.firstUpdated();
}
handleCloseClick() {
this.show = false;
this.dispatchEvent(new CustomEvent('modal-close', {
bubbles: true,
composed: true
}));
}
handleOverlayClick(e) {
// Only close if clicking the overlay itself, not the modal content
// And only if closing is not prevented
if (e.target === e.currentTarget && !this.preventClose) {
this.handleCloseClick();
}
}
render() {
return html`
<div class="modal-overlay" @click="${this.handleOverlayClick}">
<div class="modal-container">
<div class="modal-header">
<div class="modal-title">
<slot name="title">Modal</slot>
</div>
${!this.preventClose ? html`
<button class="close-button" @click="${this.handleCloseClick}">
close
</button>
` : ''}
</div>
<div class="modal-body">
<slot></slot>
</div>
</div>
</div>
`;
}
}
customElements.define('jt-connection-error-modal', JtConnectionErrorModal);
</script>
<script type="module">
import { LitElement, html, css, unsafeHTML } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';
class JtInternalToast extends LitElement {
static styles = css`
:host {
position: fixed;
right: var(--jt-spacing-xl);
z-index: 10000;
max-width: 400px;
animation: slideIn 0.3s ease-out;
}
:host([position="bottom"]) {
bottom: var(--jt-spacing-xl);
}
:host([position="top"]) {
top: var(--jt-spacing-4xl);
}
:host([hiding]) {
animation: slideOut 0.3s ease-out forwards;
pointer-events: none;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.toast-container {
background: var(--jt-bg-primary);
border-radius: var(--jt-border-radius-lg);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: var(--jt-spacing-md);
font-family: var(--jt-font-family);
display: flex;
align-items: center;
gap: var(--jt-spacing-sm);
}
.toast-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--jt-text-primary);
flex-shrink: 0;
}
.toast-icon.material {
font-family: 'Material Symbols Rounded';
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: var(--jt-font-size-xl);
}
.toast-icon.emoji {
font-size: var(--jt-font-size-xl);
}
.toast-content {
flex: 1;
min-width: 0;
}
.toast-body {
color: var(--jt-text-primary);
font-size: var(--jt-font-size-base);
word-wrap: break-word;
}
.toast-body p:first-child {
margin-top: 0;
}
.toast-body p:last-child {
margin-bottom: 0;
}
.close-button {
background: none;
border: none;
font-family: 'Material Symbols Rounded';
font-size: var(--jt-font-size-lg);
color: var(--jt-text-secondary);
cursor: pointer;
padding: 0;
opacity: 0.7;
transition: opacity var(--jt-transition-fast);
flex-shrink: 0;
}
.close-button:hover {
opacity: 1;
}
`;
static properties = {
body: { type: String },
icon: { type: String },
duration: { type: Number },
position: { type: String, reflect: true }
};
constructor() {
super();
this.body = '';
this.icon = ':info:';
this.duration = 6;
this.position = 'bottom';
this.autoDismissTimeout = null;
}
connectedCallback() {
super.connectedCallback();
// Auto-dismiss after duration (if positive)
if (this.duration > 0) {
this.autoDismissTimeout = setTimeout(() => {
this.dismiss();
}, this.duration * 1000);
}
}
disconnectedCallback() {
super.disconnectedCallback();
// Clear timeout if element is removed
if (this.autoDismissTimeout) {
clearTimeout(this.autoDismissTimeout);
this.autoDismissTimeout = null;
}
}
dismiss() {
// Clear timeout
if (this.autoDismissTimeout) {
clearTimeout(this.autoDismissTimeout);
this.autoDismissTimeout = null;
}
// Start exit animation
this.setAttribute('hiding', '');
// Remove from DOM after animation completes
setTimeout(() => {
this.remove();
}, 300); // Match animation duration
}
handleCloseClick() {
this.dismiss();
}
isEmoji(str) {
return str && str.length <= 2 && /\p{Emoji}/u.test(str);
}
isMaterialIcon(str) {
return str && str.startsWith(':') && str.endsWith(':') && str.length > 2;
}
getMaterialIconName(str) {
if (!this.isMaterialIcon(str)) return '';
return str.slice(1, -1); // Remove leading and trailing colons
}
render() {
const iconContent = this.icon ? (
this.isEmoji(this.icon) ?
html`<span class="toast-icon emoji">${this.icon}</span>` :
this.isMaterialIcon(this.icon) ?
html`<span class="toast-icon material">${this.getMaterialIconName(this.icon)}</span>` :
null
) : null;
return html`
<div class="toast-container">
${iconContent}
<div class="toast-content">
<div class="toast-body">
<div class="markdown-content">${unsafeHTML(this.body)}</div>
</div>
</div>
<button class="close-button" @click="${this.handleCloseClick}">
close
</button>
</div>
`;
}
}
customElements.define('jt-internal-toast', JtInternalToast);
</script>
<script type="module">
import {LitElement, html, css} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';
import Prism from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';
import 'https://cdn.jsdelivr.net/npm/[email protected]/plugins/autoloader/prism-autoloader.min.js/+esm';
import 'https://cdn.jsdelivr.net/npm/[email protected]/plugins/line-numbers/prism-line-numbers.min.js/+esm';
Prism.plugins.autoloader.languages_path = 'https://cdn.jsdelivr.net/npm/[email protected]/components/';
class JtCode extends LitElement {
static styles = [
css`
pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
:host {
display: block;
margin: var(--jt-spacing-md) 0;
position: relative;
}
:host([width="content"]) {
width: auto;
display: inline-block;
}
:host([height="stretch"]) {
height: 100%;
.code-container {
height: 100%;
}
}
:host([width]:not([width="stretch"]):not([width="content"])) {
width: var(--code-width);
max-width: 100%;
}
:host([height]:not([height="content"]):not([height="stretch"])) {
height: var(--code-height);
.code-container {
height: 100%;
overflow: auto;
}
}
.code-container {
position: relative;
border-radius: var(--jt-border-radius-lg);
overflow: scroll;
background: #f5f2f0;
border: 1px solid var(--jt-border-color);
}
.copy-button {
position: absolute;
top: var(--jt-spacing-sm);
right: var(--jt-spacing-sm);
background: var(--jt-bg-primary);
border: 1px solid var(--jt-border-color);
border-radius: var(--jt-border-radius);
padding: var(--jt-spacing-xs);
cursor: pointer;
opacity: 0;
transition: opacity var(--jt-transition-fast);
font-family: 'Material Symbols Rounded', sans-serif;
font-size: var(--jt-font-size-sm);
color: var(--jt-text-secondary);
z-index: 2;
}
:host(:hover) .copy-button {
opacity: 1;
}
.copy-button:hover {
background: var(--jt-bg-secondary);
color: var(--jt-text-primary);
}
.copy-button:active {
transform: scale(0.95);
}
:host([wrap-lines]) pre {
white-space: pre-wrap;
word-break: break-word;
}
:host([wrap-lines]) code {
white-space: pre-wrap;
}
`];
static properties = {
body: {type: String},
language: {type: String},
lineNumbers: {type: Boolean, attribute: 'line-numbers'},
wrapLines: {type: Boolean, attribute: 'wrap-lines'},
height: {type: String, reflect: true},
width: {type: String, reflect: true}
};
constructor() {
super();
this.body = '';
this.lineNumbers = false;
this.wrapLines = false;
}
updated(changedProperties) {
// Set height CSS variable if height is specified and is a pixel value
if (changedProperties.has('height')) {
if (this.height && /^\d+$/.test(this.height)) {
this.style.setProperty('--code-height', `${this.height}px`);
} else {
this.style.removeProperty('--code-height');
}
}
// Set width CSS variable if width is specified and is a pixel value
if (changedProperties.has('width')) {
if (this.width && /^\d+$/.test(this.width)) {
this.style.setProperty('--code-width', `${this.width}px`);
} else {
this.style.removeProperty('--code-width');
}
}
// Trigger Prism highlighting after DOM update
if (Prism) {
Prism.highlightAllUnder(this.shadowRoot);
} else {
console.warn("prism not available");
}
}
async handleCopy() {
try {
await navigator.clipboard.writeText(this.body);
// Brief visual feedback
const button = this.shadowRoot.querySelector('.copy-button');
const originalText = button.textContent;
button.textContent = 'check';
button.style.color = 'var(--jt-color-success)';
setTimeout(() => {
button.textContent = originalText;
button.style.color = '';
}, 1000);
} catch (error) {
console.warn('Failed to copy code to clipboard:', error);
// Fallback visual feedback for failure
const button = this.shadowRoot.querySelector('.copy-button');
const originalText = button.textContent;
button.textContent = 'error';
button.style.color = 'var(--jt-color-danger)';
setTimeout(() => {
button.textContent = originalText;
button.style.color = '';
}, 1000);
}
}
render() {
const languageClass = this.language ? `language-${this.language}` : 'language-none';
const preClasses = [
this.lineNumbers ? 'line-numbers' : '',
this.wrapLines ? 'wrap-lines' : ''
].filter(Boolean).join(' ');
return html`
<button class="copy-button" @click="${this.handleCopy}" title="Copy code">
content_copy
</button>
<div id="code-container" class="code-container">
<pre class="${preClasses}"><code class="${languageClass}">${this.body}</code></pre>
</div>
`;
}
}
customElements.define('jt-internal-code', JtCode);
</script>
<script type="module">
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';
class JtToolbarMenu extends LitElement {
static styles = css`
:host {
display: inline-block;
position: relative;
}
.menu-button {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: var(--jt-border-radius-lg);
color: var(--jt-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all var(--jt-transition-fast);
}
.menu-button:hover {
background: var(--jt-bg-tertiary);
color: var(--jt-text-primary);
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: var(--jt-spacing-sm);
background: var(--jt-bg-primary);
border: 1px solid var(--jt-border-color);
border-radius: var(--jt-border-radius-lg);
box-shadow: var(--jt-shadow-lg);
min-width: 200px;
z-index: 1000;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all var(--jt-transition-fast);
}
.menu-dropdown.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.menu-item {
display: flex;
align-items: center;
padding: var(--jt-spacing-md) var(--jt-spacing-lg);
color: var(--jt-text-primary);
text-decoration: none;
cursor: pointer;
transition: background-color var(--jt-transition-fast);
border: none;
background: transparent;
width: 100%;
text-align: left;
font-family: var(--jt-font-family);
font-size: var(--jt-font-size-sm);
gap: var(--jt-spacing-md);
}
.menu-item:hover {
background: var(--jt-bg-tertiary);
}
.menu-item:first-child {
border-radius: var(--jt-border-radius-lg) var(--jt-border-radius-lg) 0 0;
}
.menu-item:last-child {
border-radius: 0 0 var(--jt-border-radius-lg) var(--jt-border-radius-lg);
}
.menu-divider {
height: 1px;
background: var(--jt-border-color);
margin: 0 0;
}
.menu-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--jt-text-secondary);
}
.menu-label {
flex: 1;
}
.menu-shortcut {
font-size: var(--jt-font-size-xs);
color: var(--jt-text-secondary);
margin-left: auto;
}
.material-symbols-rounded {
font-family: 'Material Symbols Rounded';
}
.menu-section-label {
padding: var(--jt-spacing-xs) var(--jt-spacing-md);
font-size: var(--jt-font-size-xs);
}
.developer-section {
background: var(--jt-bg-secondary);
}
`;
static properties = {
showMenu: { type: Boolean, state: true },
isDevMode: { type: Boolean, state: true }
};
constructor() {
super();
this.showMenu = false;
this.isDevMode = false;
this.handleOutsideClick = this.handleOutsideClick.bind(this);
}
connectedCallback() {
super.connectedCallback();
document.addEventListener('click', this.handleOutsideClick);
// Check if running on localhost
this.isDevMode = window.javelit && window.javelit.isDevMode;
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.handleOutsideClick);
}
handleOutsideClick(e) {
if (!this.contains(e.target)) {
this.showMenu = false;
}
}
toggleMenu(e) {
e.stopPropagation();
this.showMenu = !this.showMenu;
}
handleMenuItemClick(action, e) {
e.stopPropagation();
this.showMenu = false;
switch(action) {
case 'rerun':
// Send reload message to backend
if (window.javelit && window.javelit.sendMessage) {
window.javelit.sendMessage({ type: 'reload' });
}
break;
case 'copy-embed':
// Copy embed code to clipboard
this.handleCopyEmbed();
break;
case 'settings':
// Open settings modal
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.show = true;
}
break;
case 'clear-cache':
// Send clear cache message to backend (only works on localhost)
if (window.javelit && window.javelit.sendMessage) {
window.javelit.sendMessage({ type: 'clear_cache' });
}
break;
}
}
handleCopyEmbed() {
// Get current URL with ?embed=true
const embedUrl = window.location.origin + window.location.pathname + '?embed=true';
// Generate iframe HTML matching the oEmbed format
const iframeCode = `<iframe src="${embedUrl}" allow="camera;microphone;clipboard-read;clipboard-write;" style="width:100%;height:600px;border:0;" loading="lazy"></iframe>`;
// Copy to clipboard
navigator.clipboard.writeText(iframeCode).then(() => {
// Show success toast notification
const toast = document.createElement('jt-internal-toast');
toast.body = 'Embed code copied to clipboard';
toast.icon = ':content_copy:';
toast.duration = 2;
toast.position = 'top';
document.body.appendChild(toast);
}).catch(err => {
console.error('Failed to copy embed code:', err);
// Show error toast notification
const toast = document.createElement('jt-internal-toast');
toast.body = 'Failed to copy embed code to clipboard';
toast.icon = ':error:';
toast.duration = 5;
toast.position = 'top';
document.body.appendChild(toast);
});
}
render() {
return html`
<button
class="menu-button"
@click="${this.toggleMenu}"
aria-label="Menu"
aria-expanded="${this.showMenu}"
>
<span class="material-symbols-rounded">menu</span>
</button>
<div class="menu-dropdown ${this.showMenu ? 'show' : ''}">
<button
class="menu-item"
@click="${(e) => this.handleMenuItemClick('rerun', e)}"
>
<span class="material-symbols-rounded menu-icon">refresh</span>
<span class="menu-label">Rerun</span>
</button>
<div class="menu-divider"></div>
<button
class="menu-item"
@click="${(e) => this.handleMenuItemClick('copy-embed', e)}"
>
<span class="material-symbols-rounded menu-icon">code</span>
<span class="menu-label">Get embed code</span>
</button>
<div class="menu-divider"></div>
<button
class="menu-item"
@click="${(e) => this.handleMenuItemClick('settings', e)}"
>
<span class="material-symbols-rounded menu-icon">settings</span>
<span class="menu-label">Settings</span>
</button>
${this.isDevMode ? html`
<div class="menu-divider"></div>
<div class="developer-section">
<div class="menu-section-label">Developer Options</div>
<button
class="menu-item"
@click="${(e) => this.handleMenuItemClick('clear-cache', e)}"
>
<span class="material-symbols-rounded menu-icon">delete</span>
<span class="menu-label">Clear cache</span>
</button>
</div>
` : ''}
</div>
`;
}
}
customElements.define('jt-toolbar-menu', JtToolbarMenu);
</script>
<script type="module">
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';
class JtStatusWidget extends LitElement {
static styles = css`
:host {
display: none;
}
:host([show]) {
display: inline-block;
}
.status-container {
display: flex;
align-items: center;
gap: var(--jt-spacing-sm);
padding: var(--jt-spacing-sm) var(--jt-spacing-md);
font-size: var(--jt-font-size-sm);
color: var(--jt-text-secondary);
}
.status-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--jt-border-color);
border-top-color: var(--jt-theme-active);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.status-text {
font-family: var(--jt-font-family);
}
`;
static properties = {
status: { type: String },
show: { type: Boolean, reflect: true }
};
constructor() {
super();
this.status = 'IDLE';
this.show = false;
}
updateStatus(newStatus) {
this.status = newStatus;
// Show widget for BEGIN and RUNNING states
this.show = (newStatus === 'BEGIN' || newStatus === 'RUNNING');
}
getStatusText() {
switch(this.status) {
case 'BEGIN':
return 'Running...';
case 'RUNNING':
return 'Running';
default:
return '';
}
}
render() {
if (!this.show) {
return html``;
}
return html`
<div class="status-container">
<div class="status-spinner"></div>
<span class="status-text">${this.getStatusText()}</span>
</div>
`;
}
}
customElements.define('jt-status-widget', JtStatusWidget);
</script>
<script type="module">
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';
class JtDeployButton extends LitElement {
static styles = css`
:host {
display: none;
}
:host([is-dev-mode][is-standalone-mode]) {
display: inline-block;
}
.deploy-button {
height: 32px;
padding: 0 var(--jt-spacing-md);
border: none;
background: var(--jt-primary);
color: var(--jt-text-primary);
border-radius: var(--jt-border-radius-lg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--jt-spacing-xs);
font-family: var(--jt-font-family);
font-size: var(--jt-font-size-sm);
font-weight: var(--jt-font-weight-medium);
transition: all var(--jt-transition-fast);
}
.deploy-button:hover {
background: var(--jt-primary-dark);
transform: translateY(-1px);
box-shadow: var(--jt-shadow-sm);
}
.deploy-button:active {
transform: translateY(0);
}
.material-symbols-rounded {
font-family: 'Material Symbols Rounded';
font-size: 18px;
}
`;
static properties = {
isDevMode: { type: Boolean, reflect: true, attribute: 'is-dev-mode' },
isStandaloneMode: { type: Boolean, reflect: true, attribute: 'is-standalone-mode' }
};
constructor() {
super();
this.isDevMode = false;
this.isStandaloneMode = false;
}
connectedCallback() {
super.connectedCallback();
// Check if running in dev mode and standalone mode
this.isDevMode = window.javelit && window.javelit.isDevMode;
this.isStandaloneMode = window.javelit && window.javelit.isStandaloneMode;
}
handleClick(e) {
e.stopPropagation();
// Open deploy modal
const deployModal = document.getElementById('deploy-modal');
if (deployModal) {
deployModal.show = true;
}
}
render() {
return html`
<button
class="deploy-button"
@click="${this.handleClick}"
aria-label="Deploy"
>
<span class="material-symbols-rounded">rocket_launch</span>
<span>Deploy</span>
</button>
`;
}
}
customElements.define('jt-deploy-button', JtDeployButton);
</script>
<script type="module">
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js';
class JtSettingsContent extends LitElement {
static styles = css`
:host {
display: block;
font-family: var(--jt-font-family);
}
.settings-container {
min-height: 200px;
}
.settings-section {
margin-bottom: var(--jt-spacing-2xl);
}
.settings-section:last-child {
margin-bottom: var(--jt-spacing-lg);
}
.section-title {
font-size: var(--jt-font-size-lg);
font-weight: var(--jt-font-weight-bold);
color: var(--jt-text-primary);
}
.setting-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--jt-spacing-lg) 0;
gap: var(--jt-spacing-lg);
}
.setting-info {
flex: 1;
}
.setting-label {
font-size: var(--jt-font-size-base);
font-weight: var(--jt-font-weight-medium);
color: var(--jt-text-primary);
margin-bottom: var(--jt-spacing-xs);
}
.setting-description {
font-size: var(--jt-font-size-sm);
color: var(--jt-text-secondary);
line-height: var(--jt-line-height-relaxed);
}
.setting-control {
flex-shrink: 0;
display: flex;
align-items: center;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
}
.toggle-input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--jt-bg-tertiary);
border: 1px solid var(--jt-border-color);
transition: all var(--jt-transition-normal);
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
top: 2px;
background: white;
border-radius: 50%;
transition: all var(--jt-transition-normal);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.toggle-input:checked + .toggle-slider {
background: var(--jt-theme-color);
border-color: var(--jt-theme-color);
}
.toggle-input:checked + .toggle-slider:before {
transform: translateX(24px);
}
.toggle-switch:hover .toggle-slider {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--jt-theme-color) 20%, transparent);
}
.placeholder-text {
text-align: center;
color: var(--jt-text-secondary);
padding: var(--jt-spacing-xl);
font-size: var(--jt-font-size-sm);
}
`;
static properties = {
config: { type: Object, state: true }
};
constructor() {
super();
// Load config from localStorage or use defaults
this.config = this.loadConfig();
}
loadConfig() {
try {
const stored = localStorage.getItem('javelit-settings');
return stored ? JSON.parse(stored) : this.getDefaultConfig();
} catch (e) {
console.warn('Failed to load settings from localStorage:', e);
return this.getDefaultConfig();
}
}
getDefaultConfig() {
return {
appearance: {
wideMode: false
}
};
}
saveConfig() {
try {
localStorage.setItem('javelit-settings', JSON.stringify(this.config));
// Emit config change event for other components to listen
this.dispatchEvent(new CustomEvent('config-change', {
detail: { config: this.config },
bubbles: true,
composed: true
}));
} catch (e) {
console.error('Failed to save settings to localStorage:', e);
}
}
handleWideModeToggle(e) {
this.config = {
...this.config,
appearance: {
...this.config.appearance,
wideMode: e.target.checked
}
};
this.saveConfig();
}
render() {
return html`
<div class="settings-container">
<div class="settings-section">
<div class="section-title">Appearance</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Wide mode</div>
<div class="setting-description">
Turn on to make this app occupy the entire width of the screen.
</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input
type="checkbox"
class="toggle-input"
.checked="${this.config.appearance.wideMode}"
@change="${this.handleWideModeToggle}"
>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
`;
}
}
customElements.define('jt-settings-content', JtSettingsContent);
</script>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20style%3D%22transform%3A%20scale%28-1%2C1%29%22%3E%0A%20%20%3Ctext%20y%3D%2232%22%20font-size%3D%2232%22%3E%F0%9F%9A%A1%3C%2Ftext%3E%0A%3C%2Fsvg%3E">
</head>
<body>
<div id="root">
<div id="app" class="app-wrapper">
<div id="full-sidebar" class="full-sidebar">
<div id="sidebar-tooling" class="sidebar-tooling">
<button id="collapse-btn" class="collapse-btn" aria-label="Toggle sidebar">
<span class="material-symbols-rounded">keyboard_double_arrow_left</span>
</button>
</div>
<div id="sidebar-container" class="sidebar-content" data-container="sidebar"></div>
</div>
<div class="layout-container">
<div class="toolbar">
<jt-status-widget id="status-widget"></jt-status-widget>
<jt-deploy-button id="deploy-button"></jt-deploy-button>
<jt-toolbar-menu id="toolbar-menu"></jt-toolbar-menu>
</div>
<div id="main-container" class="main-layout" data-container="main">
<div id="loading-placeholder" class="loading-state">
<div class="loading-spinner"></div>
<div class="loading-text">Loading...</div>
</div>
</div>
</div>
</div>
</div>
<jt-connection-error-modal id="connection-modal" prevent-close>
<span slot="title">Connection Error</span>
<p>Unable to connect to the Javelit server.</p>
<p>If you accidentally stopped Javelit, just restart it in your terminal:</p>
<jt-internal-code body="javelit run YourApp.java" language="bash"></jt-internal-code>
</jt-connection-error-modal>
<jt-connection-error-modal id="other-error-modal">
<span id="other-error-title" slot="title">Compilation error</span>
<p id="other-error-paragraph">Fix the compilation errors below and save the file to continue:</p>
<jt-internal-code id="other-error-code" body=""></jt-internal-code>
</jt-connection-error-modal>
<jt-connection-error-modal id="settings-modal" top-aligned>
<span slot="title">Settings</span>
<jt-settings-content></jt-settings-content>
</jt-connection-error-modal>
<jt-connection-error-modal id="deploy-modal" top-aligned>
<h3 slot="title">Deploy this app using...</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--jt-spacing-lg); margin-top: var(--jt-spacing-md);">
<div style="padding: var(--jt-spacing-md); border: 1px solid #A020F0; border-radius: var(--jt-border-radius); box-shadow: 0 0 8px rgba(160, 32, 240, 0.4), 0 0 16px rgba(160, 32, 240, 0.2);">
<h3 style="margin: 0 0 var(--jt-spacing-sm) 0;">
Railway
</h3>
<p style="margin: 0 0 var(--jt-spacing-md) 0;">Deploy your app in one click - free tier included and scales down to zero automatically.</p>
<a href="https://railway.com/new/template/javelit-app?referralCode=NFgD4z&utm_medium=integration&utm_source=template&utm_campaign=devmode&APP_URL=https://github.com/devrel-workshop/ai-as-lib-workshop/tree/javelit/workshop/audio/java" target="_blank" rel="noopener noreferrer">
<img src="https://railway.com/button.svg" alt="Deploy on Railway">
</a>
<details style="font-size: var(--jt-font-size-sm)">
<summary><i>Deploying with Railway supports the Javelit project!</i></summary>
<i>The interactive examples in the documentation are hosted on Railway. <br>Deploying your Javelit apps with this Railway template sends 25% of your spend back to Javelit to help fund the hosting.</i>
</details>
</div>
<div style="padding: var(--jt-spacing-md); border: 1px solid var(--jt-border-color); border-radius: var(--jt-border-radius);">
<h3 style="margin: 0 0 var(--jt-spacing-sm) 0;">Custom Deployment</h3>
<p style="margin: 0;">For other platforms and custom deployments, <a href="https://docs.javelit.io/deploy" target="_blank">refer to the documentation</a></p>
</div>
</div>
</jt-connection-error-modal>
<a href="https://github.com/javelit/javelit" class="javelit-attribution" target="_blank" rel="noopener">♥ Built with Javelit ♥</a>
<script>
const app = document.getElementById('app');
const mainContainer = document.getElementById('main-container');
const fullSidebar = document.getElementById('full-sidebar');
const sidebarContainer = document.getElementById('sidebar-container');
const collapseBtn = document.getElementById('collapse-btn');
const connectionModal = document.getElementById('connection-modal');
const otherErrorModal = document.getElementById('other-error-modal');
const statusWidget = document.getElementById('status-widget');
const settingsContent = document.querySelector('jt-settings-content');
const mainLayout = document.querySelector('.main-layout');
// Track if this is the first message to clear connecting text
let isFirstDelta = true;
// Localhost detection and XSRF token - available globally
window.javelit = {
isDevMode: true,
isStandaloneMode: true,
xsrfToken: "7kTGgAhxe08FqavdpfZzPfkrA3KG-U3xYxYWRMd2wEs"
};
// Embed mode detection and setup
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('embed') === 'true') {
// Activate embed mode styling
document.body.classList.add('embed-mode');
// Create and inject embed footer
const footer = document.createElement('div');
footer.className = 'embed-footer';
// Get current URL without embed parameter for fullscreen link
const fullscreenUrl = window.location.href
.replace(/[?&]embed=true/g, '')
.replace(/\?&/, '?') // Fix malformed query string
.replace(/\?$/, ''); // Remove trailing ?
footer.innerHTML = `
<span>Built with <a href="https://javelit.io" target="_blank"><span class="link-text">Javelit</span></a></span>
<a href="${fullscreenUrl}" target="_blank">
<span class="link-text">Fullscreen</span>
<span class="material-symbols-rounded">open_in_new</span>
</a>
`;
// Insert footer inside the root container (after the app div)
app.parentElement.appendChild(footer);
}
if (window.javelit.isDevMode || urlParams.get('embed') === 'true') {
document.querySelector('.javelit-attribution').style.display = 'none';
}
// Connection management
let ws = null;
let connectionState = 'disconnected'; // 'connected', 'disconnected', 'reconnecting'
// Sidebar collapse state management
let sidebarCollapsed = localStorage.getItem('javelit-sidebar-collapsed') === 'true';
// Sidebar resize state
let isResizing = false;
let startX = 0;
let startWidth = 0;
let animationFrameId = null;
const MIN_SIDEBAR_WIDTH = 150;
const MAX_SIDEBAR_WIDTH = 500;
const DEFAULT_SIDEBAR_WIDTH = 256;
function initializeSidebar() {
if (sidebarCollapsed) {
fullSidebar.classList.add('collapsed');
} else {
// Restore saved width if not collapsed
const savedWidth = localStorage.getItem('javelit-sidebar-width');
if (savedWidth) {
fullSidebar.style.width = savedWidth + 'px';
}
}
collapseBtn.addEventListener('click', toggleSidebarCollapse);
// Enable resize functionality
enableSidebarResize();
}
function enableSidebarResize() {
// Create resize handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'sidebar-resize-handle';
fullSidebar.appendChild(resizeHandle);
// Mouse events
resizeHandle.addEventListener('mousedown', initResize);
// Touch events for mobile
resizeHandle.addEventListener('touchstart', initResize, { passive: false });
}
function initResize(e) {
if (sidebarCollapsed) return;
isResizing = true;
startX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
startWidth = parseInt(getComputedStyle(fullSidebar).width, 10);
// Disable transition during drag to prevent lag
fullSidebar.style.transition = 'none';
document.body.classList.add('resizing');
// Add listeners to document for drag/end
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize);
document.addEventListener('touchmove', doResize, { passive: false });
document.addEventListener('touchend', stopResize);
e.preventDefault();
}
function doResize(e) {
if (!isResizing) return;
// Cancel previous frame if pending
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
// Use requestAnimationFrame for smooth updates
animationFrameId = requestAnimationFrame(() => {
const currentX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + currentX - startX));
fullSidebar.style.width = newWidth + 'px';
animationFrameId = null;
});
e.preventDefault();
}
function stopResize(e) {
if (!isResizing) return;
isResizing = false;
document.body.classList.remove('resizing');
// Cancel any pending animation frame
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
// Re-enable transition after drag
fullSidebar.style.transition = '';
// Save width
if (!sidebarCollapsed) {
const currentWidth = fullSidebar.offsetWidth;
localStorage.setItem('javelit-sidebar-width', currentWidth);
}
// Remove document listeners
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
document.removeEventListener('touchmove', doResize);
document.removeEventListener('touchend', stopResize);
}
function toggleSidebarCollapse() {
sidebarCollapsed = !sidebarCollapsed;
if (sidebarCollapsed) {
// Save current width before collapsing
const currentWidth = fullSidebar.offsetWidth;
if (currentWidth > 24) {
localStorage.setItem('javelit-sidebar-expanded-width', currentWidth);
}
fullSidebar.classList.add('collapsed');
fullSidebar.style.width = '24px';
} else {
// Restore saved width when expanding
fullSidebar.classList.remove('collapsed');
const savedWidth = localStorage.getItem('javelit-sidebar-expanded-width') ||
localStorage.getItem('javelit-sidebar-width') ||
DEFAULT_SIDEBAR_WIDTH;
fullSidebar.style.width = savedWidth + 'px';
}
// Store state in localStorage
localStorage.setItem('javelit-sidebar-collapsed', sidebarCollapsed.toString());
}
window.javelit = {...window.javelit,
sendComponentUpdate: function(componentKey, value) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'component_update',
componentKey: componentKey,
value: value
}));
} else {
console.log("Failed to send update to backend. Connection to backend is not available.");
}
},
sendPathUpdate: function() {
// Parse query parameters using URLSearchParams
const params = new URLSearchParams(window.location.search);
const queryObj = {};
for (const key of params.keys()) {
queryObj[key] = params.getAll(key);
}
// Send path_update message to backend to trigger app re-run
window.javelit.sendMessage({
type: 'path_update',
path: window.location.pathname,
queryParameters: queryObj,
});
},
sendMessage: function(message) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
} else {
console.log("Failed to send message to backend. Connection to backend is not available.");
}
},
};
// Connection management functions
function createWebSocketConnection() {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// contains hostname + port
const host = window.location.host;
ws = new WebSocket(`${protocol}//${host}/_/ws`);
ws.onclose = () => {
console.log('WebSocket closed');
handleConnectionLoss();
};
ws.onmessage = handleWebSocketMessage;
ws.onopen = () => {
window.javelit.sendPathUpdate();
connectionState = 'connected';
console.log('WebSocket connected');
connectionModal.show = false;
otherErrorModal.show = false;
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
handleConnectionLoss();
}
}
function handleConnectionLoss() {
if (connectionState === 'connected') {
// First time losing connection - show the connection lost modal
// wait 1s to avoid modal flickerings when a page is reloaded
setTimeout( () => {connectionModal.show = true;}, 1000)
}
connectionState = 'reconnecting';
setTimeout(() => {
console.log('Attempting to reconnect...');
createWebSocketConnection();
}, 5000);
// Clear websocket references
ws = null;
}
async function handleWebSocketMessage(event) {
const message = JSON.parse(event.data);
if (message.type === 'session_init') {
// Store WebSocket session ID for POST/PUT requests - eg file uploads
window.javelit.sessionId = message.sessionId;
} else if (message.type === 'delta') {
handleDeltaMessage(message);
} else if (message.type === 'modal_error') {
handleModalErrorMessage(message)
} else if (message.type === 'status') {
if (statusWidget) {
// TODO: This await is a workaround for a race condition. The custom element
// (jt-status-widget) is defined asynchronously via type="module" script, but
// this code runs before the element is upgraded. A better fix would be to load
// the custom element synchronously or restructure the script loading order.
await customElements.whenDefined('jt-status-widget');
statusWidget.updateStatus(message.status);
}
if (message.status === "BEGIN") {
otherErrorModal.show = false;
}
if (message.status === "END" && isFirstDelta) {
const loadingPlaceholder = document.getElementById('loading-placeholder');
if (loadingPlaceholder) {
loadingPlaceholder.innerHTML = '<p>This app seems empty!<br>Get started by adding the following code:</p><jt-internal-code body="Jt.text("Hello World!").use();" language="java"></jt-internal-code>';
}
}
} else {
alert(`Unknown message type ${message.type}. Please reach out to support.`);
}
if (window.javelit.isDevMode && message.toastDuration) {
const toast = document.createElement('jt-internal-toast');
toast.body = message.toastBody ? message.toastBody : "";
toast.icon = message.toastIcon ? message.toastIcon : "";
toast.duration = message.toastDuration;
toast.position = "bottom"
document.body.appendChild(toast);
}
}
// Layout management
const layoutContainers = new Map([
['main', mainContainer],
['sidebar', sidebarContainer] // Backend content goes to the inner container
]);
// DOM mutation batching
let pendingMutations = [];
let mutationTimeout = null;
let firstMutationTime = null;
// mutations received at less than 4ms (~1 frame at 240fps) of space are batched together to avoid flickering
// every time a mutation is received the 4ms time is reset ...
const MUTATION_BATCH_WINDOW = 4;
// ... so we set a hard limit to flush the batch of 16ms ~ 1 frame at 60fps
// FIXME this means animation will only run at max 60fps (assuming 0 latency network) so we may want to make this value configurable
// in any case streamlit does not ensure constant frame rate because of the delivery via ws
const MAX_BATCH_WAIT_TIME = 16;
function createElementFromHTML(htmlString) {
if (!htmlString) {
return null;
}
const template = document.createElement('template');
template.innerHTML = htmlString.trim();
return template.content.firstChild;
}
function handleComponentRegistrations(registrations) {
if (!registrations || registrations.length === 0) return;
// Ensure a hidden container is present
let container = document.getElementById('javelit-registrations');
if (!container) {
container = document.createElement('div');
container.id = 'javelit-registrations';
container.style.display = 'none';
document.body.appendChild(container);
}
registrations.forEach((registration) => {
// Parse the registration HTML safely
const template = document.createElement('template');
template.innerHTML = registration;
const fragment = template.content;
// Execute all <script> tags inside the fragment
fragment.querySelectorAll('script').forEach((script) => {
const newScript = document.createElement('script');
if (script.type) newScript.type = script.type;
if (script.src) {
newScript.src = script.src;
} else {
newScript.textContent = script.textContent;
}
document.head.appendChild(newScript);
});
// Inject the rest of the HTML (custom elements etc.)
container.appendChild(fragment);
});
}
function handleDeltaMessage(message) {
const {
index = null, // null means append
html,
container, // should always be set
clearBefore = false // Default to no clearing
} = message;
// Clear initial "Connecting..." message on first delta
if (isFirstDelta) {
mainContainer.innerHTML = '';
isFirstDelta = false;
}
// Close compilation modal since we're receiving successful deltas
otherErrorModal.show = false;
// Handle component registrations immediately
handleComponentRegistrations(message.registrations);
// Create element immediately (outside batch)
const newElement = createElementFromHTML(html);
// Queue DOM mutations
pendingMutations.push({
type: 'delta',
index,
element: newElement,
container: container,
clearBefore
});
// Track first mutation time
if (!firstMutationTime) {
firstMutationTime = Date.now();
}
// Check if we've waited too long
const elapsed = Date.now() - firstMutationTime;
if (elapsed >= MAX_BATCH_WAIT_TIME) {
// Force immediate processing to prevent starvation
if (mutationTimeout) {
clearTimeout(mutationTimeout);
mutationTimeout = null;
}
processMutations();
firstMutationTime = null;
} else {
// Normal batch processing
if (mutationTimeout) {
clearTimeout(mutationTimeout);
}
mutationTimeout = setTimeout(() => {
processMutations();
mutationTimeout = null;
firstMutationTime = null;
}, MUTATION_BATCH_WINDOW);
}
}
function processMutations() {
if (pendingMutations.length === 0) return;
requestAnimationFrame(() => {
// Group mutations by container
const mutationsByContainer = new Map();
pendingMutations.forEach(mutation => {
// Get target container for this container
if (!mutationsByContainer.has(mutation.container)) {
mutationsByContainer.set(mutation.container, []);
}
mutationsByContainer.get(mutation.container).push(mutation);
});
// Process mutations for each container separately
mutationsByContainer.forEach((mutations, container) => {
const containerEl = document.querySelector(`[data-container="${container}"]`);
if (!containerEl) {
// If all mutations are page resets (clearBefore=true and index=0), skip silently
// this can happen when a container is fully removed (eg because of a page change in a multipage app)
const allArePageResets = mutations.every(m => m.clearBefore === true && m.index === 0);
if (allArePageResets) {
return; // Skip this container
}
throw new Error(`Nested container not found for path: ${container}. Implementation error. Please reach out to support.`);
}
mutations.forEach(mutation => {
if (mutation.clearBefore && mutation.index !== null) {
while (containerEl.children.length > mutation.index) {
containerEl.removeChild(containerEl.lastChild);
}
}
// in the clearing case, it can happen that the element is null to trigger a full cleanup - so checking here but not below
if (mutation.element) {
if (mutation.index !== null && mutation.index < containerEl.children.length) {
containerEl.children[mutation.index].replaceWith(mutation.element);
} else {
containerEl.appendChild(mutation.element);
}
}
});
});
pendingMutations = [];
});
}
function handleModalErrorMessage(message) {
document.getElementById('other-error-title').textContent = message.title;
document.getElementById('other-error-paragraph').textContent = message.paragraph;
// hotfix - the jt-internal-code component does not support body updates - recreating a new jt-internal-code manually
const oldCodeElement = document.getElementById('other-error-code');
if (oldCodeElement) {
oldCodeElement.remove();
}
const newCodeElement = document.createElement('jt-internal-code');
newCodeElement.id = 'other-error-code';
newCodeElement.setAttribute('body', message.error);
newCodeElement.setAttribute('language', 'language-none');
// if the modal was already visible, apply animation to make change visible
if (otherErrorModal.show === true) {
newCodeElement.classList.add('error-updated');
// Remove animation class after it completes
setTimeout(() => {
newCodeElement.classList.remove('error-updated');
}, 1800); // Match animation duration (600ms × 3 loops)
}
otherErrorModal.appendChild(newCodeElement);
// Set or remove prevent-close attribute based on message.closable
if (message.closable === false) {
otherErrorModal.setAttribute('prevent-close', '');
} else {
otherErrorModal.removeAttribute('prevent-close');
}
otherErrorModal.show = true;
}
// Settings management
function applySettings(config) {
if (config.appearance && config.appearance.wideMode !== undefined) {
if (config.appearance.wideMode) {
mainLayout.style.maxWidth = 'none';
} else {
mainLayout.style.maxWidth = '768px';
}
}
}
function loadAndApplySettings() {
try {
const stored = localStorage.getItem('javelit-settings');
if (stored) {
const config = JSON.parse(stored);
applySettings(config);
}
} catch (e) {
console.warn('Failed to load settings on startup:', e);
}
}
// Listen for settings changes
if (settingsContent) {
settingsContent.addEventListener('config-change', (e) => {
applySettings(e.detail.config);
});
}
// Initialize sidebar, settings, and connection
loadAndApplySettings();
initializeSidebar();
createWebSocketConnection();
</script>
</body>
</html>