Imported from Victozee26/acode-opencode (
src/ui/AGENTS.md). Install upstream withnpx skills add Victozee26/acode-opencode --skill ui. Copyright stays with the author.
src/ui
Purpose
DOM rendering layer. Pure functions that build DOM subtrees — no side effects, no state mutation, no network calls.
Ownership
Owned by the root AGENTS.md. Three subdirectories:
-
index.ts—render(state, context, actions)orchestrator dispatching to one render function perAppState;initUiStyles(baseUrl)loads CSS via<link>elements;initUiPage($page)sets up persistent header and content containers and idempotently init orientation listeners + apply visibility;updateHeader(state, actions)updates header in-place on every render;updateIframeScale(scale)live-updates the active iframe's CSS transform;isLandscape()helper,applyHeaderVisibility()(readsgetHideHeaderInLandscape()+isLandscape()and togglesbodyclassHEADER_LANDSCAPE_HIDDEN_CLASS),initOrientationListener()/destroyOrientationListener()idempotent lifecycle formatchMedia(LANDSCAPE_MEDIA_QUERY)+resize/orientationchangelisteners -
components/— one file per DOM factory function, re-exported throughcomponents/index.ts(barrel) -
styles/— one CSS file per component/domain, loaded via<link>ininitUiStyles()Components: 9 files (8 + barrel
index.ts)container.ts—createContainer(shared root wrapper used byspinneranderrorDisplay)spinner.ts—createSpinner,SpinnerElementtypeiframe.ts—createIframe,setIframeScaleheaderBar.ts—createHeaderBar(legacy — unused)customHeader.ts—createCustomHeader(replaces FAB, hamburger menu with Start/Restart/Stop)floatingActionButton.ts—createFloatingActionButtonplus theFabActioninterface (legacy — unused)errorDisplay.ts—createErrorDisplay
Styles:
base.css— keyframes (fade-in),.opencode-fade-in,.opencode-btnutility classcontainer.css—.opencode-containershared flex wrapperidle.css—.opencode-idle,.opencode-idle-icon,.opencode-idle-textready.css—.opencode-ready-wrapperheaderBar.css—.opencode-header,.opencode-header-left,.opencode-header-wordmark,.opencode-header-hamburger,.opencode-header-menu,.opencode-header-scrim,.opencode-fab-item,.opencode-header-update,.opencode-header-update--installing,.opencode-header-update--error,.opencode-header-update--updated,.opencode-header-update-close,@media (orientation: landscape) { body.opencode-hide-header-in-landscape #opencode-header/.opencode-header { display: none } }gated landscape hide,@keyframes opencode-update-pulsespinner.css—.opencode-spinner-ring(conic arc),.opencode-spinner-label,.opencode-spinner-progress(monospace command output line)errorDisplay.css—.opencode-error-icon,.opencode-error-heading,.opencode-error-log,.opencode-error-retryiframe.css—.opencode-iframefloatingActionButton.css— legacy, not loaded
Consumers (e.g.
main.ts,ui/index.ts) import from./ui/components(resolves to the barrel), never from an individual component file. Tests live intest/ui/components.test.ts.
Local Contracts
initUiPage($page)is called once duringAcodePlugin.init()to set up persistent DOM: it clears$page.body, sets it to a flex column, then appends two containers —#opencode-header(stored inpageHeader) and#opencode-content(stored inpageContent). These containers persist across all subsequent renders. It idempotently callsinitOrientationListener()then synchronouslyapplyHeaderVisibility()before any fade-in, so first paint has correct chrome.render(state, context, actions)no longer takes$page. On first call, it creates the custom header insidepageHeader; on subsequent calls it only swaps the content area (pageContent.innerHTML). Same-state transitions short-circuit — onlyupdateHeader()runs, no content swap.actionsisRenderActionswithstart,restart,stop,back, optionalupdateInfo, optionalupdateStatus, optionalonUpdateClick, optionalonCancelUpdate, and optionalonReinstallfields. Every entry and same-state path callsapplyHeaderVisibility()(viaupdateHeaderand directly) so chrome stays in sync across state changes.updateHeader(state, actions)(exported) updates the header in-place: shows/hides the Start Server menu item, and creates/replaces/removes the update banner. It callsbuildUpdateBanner(actions)to get the banner config and usescreateUpdateBannerElement()to build the DOM.actionsisHeaderActions(defined insrc/types.ts), a structural subset ofRenderActionswithupdateInfo,updateStatus,onUpdateClick,onCancelUpdate, andonReinstall.main.tscallsupdateHeader()directly for header-only visual changes (e.g. update banner status), bypassing the state machine entirely. It always callsapplyHeaderVisibility()first (even whenpageHeaderis null) so body class stays correct.- Header landscape visibility is global chrome (all
AppStatevalues, not justReady): whengetHideHeaderInLandscape()(from../settings) is true andisLandscape()is true,document.bodygets classHEADER_LANDSCAPE_HIDDEN_CLASS(opencode-hide-header-in-landscape); otherwise the class is removed. Gated CSS inheaderBar.css@media (orientation: landscape) { body.opencode-hide-header-in-landscape #opencode-header { display: none } body.opencode-hide-header-in-landscape .opencode-header { display: none } }collapses the flex header so#opencode-contentflexes to full height. DOM is not removed,display:noneis instant with no transition, and static layout stays viaclassName/ external CSS (no<style>injection). isLandscape(): booleanreturnswindow.matchMedia(LANDSCAPE_MEDIA_QUERY).matches || window.innerWidth > window.innerHeight(matchMedia with size fallback for WebView quirks, try/catch guarded).applyHeaderVisibility(): voidreadsgetHideHeaderInLandscape()+isLandscape()and togglesdocument.body.classListadd/removeHEADER_LANDSCAPE_HIDDEN_CLASS. Both are exported for testing/wiring (main.tswiressetOnHideHeaderChange(() => applyHeaderVisibility())for live toggle without restart).initOrientationListener(): voididempotently registers orientation change listeners:MediaQueryListchangeviaaddEventListener('change')withaddListenerfallback (storedorientationMql+orientationHandlerrefs), pluswindowresizeandorientationchange. CallsapplyHeaderVisibility()synchronously on init.destroyOrientationListener(): voidis idempotent cleanup: removes all registered listeners via stored refs, clearsorientationMql/orientationHandler/orientationInitialized, and removesHEADER_LANDSCAPE_HIDDEN_CLASSfrombody.main.tsdestroy()calls it;AcodePlugin.init()wiring viasetOnHideHeaderChangeis single-assignment so re-init does not duplicate.- The custom header (via
createCustomHeader) is created once and persists across state transitions. It includes a wordmark image (asset/opencode-wordmark-dark.png) and a hamburger button on the right. Clicking the hamburger opens a dropdown menu with Start/Restart/Stop/Reinstall OpenCode Server actions, with a scrim backdrop to close on outside tap. The "Start Server" item is hidden when the server is alreadyReady. An optional update banner (.opencode-header-update) is prepended to the menu, built fromactionsviabuildUpdateBanner(). ThecreateCustomHeaderfunction acceptsbaseUrlto resolve the wordmark asset path. - Every state variant has its own render function. Never add inline DOM construction in
render(). - All DOM is vanilla
document.createElement— no framework, nohtml-tag-js. - All static CSS is in external
.cssfiles understyles/, loaded once byinitUiStyles(baseUrl)via<link>elements (called fromAcodePlugin.init()). Dynamic styles (position, opacity toggles, transform, config-dependent values) remain as inlineelement.style.*assignments. - Components use
classNameorclassListinstead ofcssTextfor static layout. Never add static CSS as inline styles. createIframe(src, scale?)accepts a string URL and optional numeric scale factor (1 = 100%);renderReadypassesBASE_URLfrom../config/serverandgetIframeScale()from../settings.setIframeScale(iframe, scale)applies the same CSS-scaling logic ascreateIframeto an existing iframe element — updateswidth,height,transform, andtransformOrigininline styles.updateIframeScale(scale)is an impure export fromindex.tsthat callssetIframeScaleon a module-levelactiveIframereference, stored and cleared byrender()/renderReady(). Use this for live scale updates without destroying the iframe (e.g. on settings change). No-op when not in Ready state (activeIframeis null).- The content container (not
$page.body) usesoverflow: hiddenin Ready state. The iframe is CSS-scaled (layout box100/scale%, thentransform: scale()), which overflows the wrapper box; clipping prevents the parent body from becoming scrollable. The embedded web UI scrolls internally. Do NOT remove this clipping. - Styles use CSS custom properties (
var(--primary-color, fallback)) for Acode theming compatibility. - Global keyframe/utility styles are in
styles/base.css, loaded via<link>byinitUiStyles(). Provides.opencode-fade-in(state transition),.opencode-btn(hover/active button effects). - State transitions fade in: the content container gets
.opencode-fade-inafter every render, triggered with a forced reflow for reliable animation restart. createSpinner()usesrequestAnimationFrame(notsetInterval) for GPU-friendly rotation. The spinner is a conic-gradient arc ring cut with a CSSmask. Returns aSpinnerElementwith astop()method (cancels animation frame) and asetProgressText(text)method (shows/hides a monospace progress line below the status label for real-time command output).setSpinnerProgress(text)(exported fromindex.ts) streams text to the active spinner's progress label — used bymain.tsto display live installation output. No-op when no spinner is rendered.createCustomHeader(actions, isReady, baseUrl, onBack?, updateBanner?)builds a flex header bar with an optional back button, wordmark image, and hamburger toggle. The hamburger opens a dropdown ofFabAction[], preceded by an optional.opencode-header-updatebanner. TheUpdateBannerConfigcarries alabel,status('installing'|'error'|'updated'|null),onClick, and optionalonCancelcallbacks. Whenstatus === 'installing'— pulsing amber banner with a × close button (.opencode-header-update-close) that callsonCancelto revert to the pre-update state; the main label is non-clickable. Whenstatus === 'updated'— green banner, non-interactive<div>(not a button), shows "Updated to X.X". Whenstatus === 'error'— red text, clickable to retry. Whenstatus === null— amber text, clickable to start the update. A scrim overlay (dimmed + blurred) appears behind the menu to catch outside taps and close it. The scrim is a child of the header withz-index: -1so it paints behind the header but above page content. The menu is positioned below the header (top: 100%, right-aligned). The Start Server action is hidden whenisReadyis true. No document-level event listeners are used; the scrimclickhandler closes the menu directly. EachFabActionitem gets adata-action-idattribute matching itsidsoupdateHeader()can query by action id (e.g.[data-action-id="start"]).createHeaderBar()andcreateFloatingActionButton()are legacy components kept for reference but no longer used. The FAB's functionality (Start/Restart/Stop actions) is now served by the hamburger menu increateCustomHeader(). The custom header is created once insidepageHeaderand persists across state transitions;updateHeader()modifies it in-place.createErrorDisplay()unconditionally renders a warning icon, the diagnostics<pre>and a retry button. The diagnostics block always exists: it holdscontext.error.logTail(when present) followed by the node/npm version lines, which start as pending placeholders (seesetErrorVersions). All dynamic strings usetextContent(safe from injection, noescapeHtmlneeded).- The diagnostics
<pre>uses classopencode-error-log; its text is built byformatDiagnostics(logTail, versions)fromsrc/error.ts. The Copy button readspre.textContentat click time so the copied text carries the probed versions once they have filled in. setErrorVersions(versions)(impure export fromindex.ts, alongsidesetSpinnerProgress) fills the mounted error diagnostics block with the probed node/npm versions. It keeps a module-levelactiveErrorLogref (plusactiveErrorLogTail), both cleared when the content area is swapped, and no-ops when no error view is mounted — so a probe finishing after recovery cannot touch a stale element.main.tscalls it oncegetRuntimeVersions()answers.- The error heading
<h3>uses classopencode-error-heading(white-space: pre-wrapfrom CSS) for legible multi-line summaries.messageis a short summary (first line of the error);logTailis the diagnostic detail (remaining lines). - Event handlers (
onRestart,onRetry) are attached viaaddEventListener, never inlineonclickattributes.
Work Guidance
- New UI states: add a case to the switch in
render()and a corresponding render function. - New reusable components: each lives in its own file under
components/and is re-exported fromcomponents/index.ts. Keep the file scoped to a single component. - Keep components pure — no side effects, no state access beyond props.
Verification
npm test runs Vitest with jsdom. Test files: test/ui/components.test.ts and test/ui/headerLandscape.test.ts. components.test.ts covers createErrorDisplay retry button and diagnostics rendering (log tail + pending node/npm version lines), the Copy button writing message + diagnostics (pending and probe-filled versions), setErrorVersions in-place fill and no-op when unmounted, initUiPage container creation, render persistent container behavior (header persists across transitions, content swaps), updateHeader in-place updates (Start Server visibility, same-state short-circuit). headerLandscape.test.ts covers landscape header visibility: headerBar.css @media (orientation: landscape) gated rule, isLandscape() fallback (matchMedia + innerWidth/innerHeight), applyHeaderVisibility() toggling body class by getHideHeaderInLandscape()+landscape, initOrientationListener()/destroyOrientationListener() idempotent lifecycle (matchMedia change via addEventListener/addListener fallback + resize/orientationchange), live toggle via setOnHideHeaderChange → applyHeaderVisibility(), and initUiPage/render/updateHeader integration (header hidden in all AppState when landscape+ON). Stubs window.matchMedia (with trigger), window.innerWidth/innerHeight, and acode.require('settings').
Child DOX Index
None. This directory is a leaf in the DOX hierarchy.
