<!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&#61;Material+Symbols+Rounded:opsz,wght,FILL,[email protected],100..700,0..1,-50..200&amp;display&#61;swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family&#61;Material+Symbols+Rounded:opsz,wght,FILL,[email protected],100..700,0..1,-50..200&amp;display&#61;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&#61;NFgD4z&amp;utm_medium&#61;integration&amp;utm_source&#61;template&amp;utm_campaign&#61;devmode&amp;APP_URL&#61;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">&hearts; Built with Javelit &hearts;</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(&quot;Hello World!&quot;).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>