The shell is plain HTML, CSS and JavaScript with no build step. The Python
API (services/) serves the folder as static files, replaces
__IMURUN_TOKEN__ in index.html with the per-run bearer token, and sends a
strict Content Security Policy:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; font-src 'self'; connect-src 'self'Consequences for anyone editing the frontend:
- No CDN, no remote font, no remote image, no inline
<script>. Every script is a file loaded with<script src>fromindex.html. - Inline
style="..."attributes andelement.styleare allowed ('unsafe-inline'for styles). - Network calls go only to the same origin (
/,api/ * /).health - Sound must be synthesised (WebAudio); there is no
media-src. - Remote images in model output cannot load; the Markdown renderer turns them into link chips.
Related documents in this folder:
| File | What it holds |
|---|---|
CHANGELOG.md |
Everything changed in the frontend overhaul, and why |
BUGS-FIXED.md |
Each bug: symptom, root cause, fix, file:line |
API-REQUESTS.md |
Changes the frontend needs from the server |
VOICE.md |
Voice input/output today and the planned server engine |
Running it#
- Production:
python start.py(orpython -m services.server) serves the shell with the token injected. The app may also be shown inside a native window (pywebview); the layout needs no browser chrome (see "Native window"). - Design preview without the API:
python -m http.server 8731insidefrontend/, then opendesign-lab http:/. Without a server the shell shows its honest "server unreachable" states./ 127.0.0.1:8731/ api.jsprobes/and then falls back tohealth http:/./ <host>:8732
Useful links (query parameters):
| Parameter | Effect |
|---|---|
?demo=responses |
Response gallery: every kind of reply and reply state, rendered by the real chat code from canned frames, no API calls |
?intro=1 |
Play the intro even when "Play the intro on launch" is off |
?page=sessions (connect, skills, persona, settings) |
Open a page |
?settings=customisation (voice, models, server, permissions, tools, agent, search, connectors, mcp, tuning, ...) |
Open a Settings section; ?settings=1 opens Customisation |
?style=imutechz-dark / ?font=inter |
Preview a theme or font without saving it |
?notify=success (error, neutral), ?popup=1, ?rename=1, ?menu=1 |
Demo surfaces |
Files and responsibilities#
Scripts load in this order (index.html). Order only matters at start-up:
after that, modules talk through window.IMURUN at call time.
| File | Lines | Responsibility | Public surface |
|---|---|---|---|
mockup.js |
~510 | Builds the whole DOM (shell, pages, Settings sections, dialogs) as one HTML string into #app. Pure markup, no behaviour. |
none |
api.js |
~1040 | All HTTP and SSE. Token handling, base URL resolution, streamTurn, subscribeEvents, every REST call (including tools, approvals, permissions, agent, connector config/test/doc, MCP). EVENT_TYPES must list every frame name the server publishes (EventSource drops unlisted names). |
window.__imurun_api |
markdown.js |
~450 | Safe Markdown renderer (escape first, then structure) and splitReasoning for <think> blocks. |
window.__imurun_markdown |
core.js |
~300 | Namespace, utilities, event bus, shared state, / store and polling. |
IMURUN.util, .store, .bus, .state, .status |
toast.js |
~390 | Notifications: create, update by id, dismiss, hover-pause; keep cards are never pushed out; the Gmail demo approval and previews. |
IMURUN.toast, window.showNotification |
boot.js |
~140 | Start-up overlay: polls / until the engine is ready or a grace period passes. |
window.__imurun_boot |
theme.js |
~440 | Theme system and all presentation preferences; owns Settings > Customisation controls. | IMURUN.theme |
settings.js |
~360 | Loads/saves server settings sections; binds data-setting inputs and Save buttons. Never PUTs the whole permissions section (NOT_PUT). |
window.__imurun_settings |
aperture.js |
~365 | The ImuTechz Aperture: a byte-identical copy of the brand kit's brand/ (edit the kit, copy here): the website's Mark.jsx math verbatim, markup / createMark / mount / intro, one shared clock, a hydrator that starts every svg[data-mark-live] (see "Brand"). Loads right after mockup.js. |
window.ImuAperture = IMURUN.aperture |
sound.js |
~225 | The website's synthesised sound palette and click tick (lib/sound.js); one remembered toggle; silent under reduced motion. | IMURUN.sound |
brand.js |
~180 | Marks in the page (logo(), [data-logo]), the "ImuTechz" wordmark, the landing intro (Intro.jsx port), "Play the intro on launch"; BRAND can switch the mark to a file. |
IMURUN.brand |
dotfield.js |
~180 | The website's DotField background (ImuTechz light theme). | IMURUN.dotfield |
app.js |
~1440 | Shell: navigation, drawer, sessions, rename/context menu, home, Connect page (OAuth, "Set up" -> Settings > Connectors), skills, persona, settings actions, resources, clients, usage, server-event routing, start-up. Shares the connector icons as IMURUN.shell.connectorIcons. |
IMURUN.shell |
chat.js |
~1490 | Transcript, composer, the streaming Turn controller (per-step trace blocks, tool steps by kind), stop/queue, the search indicator, the 503 "Choose a model" state, copy, response-gallery hooks. At the size limit: put new reply features in activity.js or a new module. |
IMURUN.chat |
activity.js |
~545 | Tool rows in the thinking panel (tool.*, agent.*, skill.used), the reply footer ("Used ..." and skill chips), verification badge, numbered sources row (up to 12), stripNotes / stripCalls. Hands research.* frames to research.js. |
IMURUN.activity |
research.js |
~500 | The research engine in a reply: plan ("Looking up"), one lane per source family, pages being read, closing line; [n] citations -> superscript links to source chip n; gallery states. |
IMURUN.research |
approvals.js |
~600 | Approval cards: inline in the live reply or as a kept toast, countdown, Enter/Esc, restore from GET /, tab-title flag. Connector tools: " |
IMURUN.approvals |
slash.js |
~220 | / autocomplete in the composer and chips for the skills a message names. |
IMURUN.slash |
agent-settings.js |
~615 | Settings > Permissions, Tools (schemas, manual runs), Agent, Search providers (keys under the server's IMURUN_* names); loaded when the section is first shown. |
IMURUN.agentSettings |
connectors-settings.js |
~930 | Settings > Connectors to docs/connectors/REQUESTS.md F1: auth_mode picks an OAuth card (one Connect that has the server open the system browser, device code for GitHub, "Not available yet" + registration_todo, credentials under a collapsed Advanced) or a token card (Verify & save / Forget token); storage badge, Test, Disconnect, tool levels, "How it works" from the doc route. |
IMURUN.connectors |
feedback.js |
~235 | Thumbs up / down / Teach on every assistant reply that has a turn id (POST /), the one-time consent notice, sync from tuning.feedback. |
IMURUN.feedback |
tuning-settings.js |
~530 | Settings > Tuning (stack install, plan, samples, Train now with the 409 confirm / allow_slow round trip, the job state machine and Cancel, automation settings, history) and the "Use it" card on tuning.ready. |
IMURUN.tuning |
mcp-settings.js |
~510 | Settings > MCP servers: list with live status, add/edit form, connect/disconnect/remove, discovered tools with auto-approve, templates. | IMURUN.mcp |
models.js |
~1320 | Models tab, downloads with progress modal and pill, model switching, composer model picker. | IMURUN.models, window.__imurun_models |
panels.js |
~805 | Performance drawer and model card, command palette (session search), shortcuts dialog, sidebar filter, global shortcuts, response gallery (including the agentic states). | IMURUN.panels |
voice-server.js |
~615 | Voice on the local server (/): MediaRecorder dictation with first-use download and retry, the spoken language for the next turn, sentence-by-sentence read aloud with prefetch and stop, Settings > Voice "Speech on this computer". Takes over the mic and Read aloud (capture-phase clicks) only when the server can do it; voice.js is the browser fallback. |
IMURUN.voiceServer |
voice.js |
~500 | Dictation (Web Speech recognition), read aloud (speechSynthesis), Settings > Voice. | IMURUN.voice |
Every file stays under ~1500 lines; when one grows past that, split it and
add a <script src> in index.html after its dependencies.
Stylesheets (index.html order; later wins on equal specificity):
| File | Holds |
|---|---|
fonts.css |
Vendored fonts (fonts/, SIL OFL) and body[data-font] |
base.css |
Layout and components (original design) |
styles/ |
One theme: colour/radius/font custom properties plus flourishes (#theme-link, swapped at runtime). Default: imutechz.css; imutechz-dark.css imports it and only changes tokens |
styles/ |
Theme-independent scales: spacing, type, motion, density defaults, the mark's palette (--logo-ray, --logo-accent, --logo-shadow, wordmark ink/steel; light and data-scheme="dark") |
styles/, landing.css, skeleton.css, thinking.css, markdown.css, boot.css |
Feature styles (original, lightly fixed) |
styles/ |
Chat details (enter animation, streaming caret, actions, errors), states, Markdown extras, Customisation controls, mobile drawer |
styles/ |
Topbar actions, performance drawer, palette, shortcuts dialog |
styles/ |
Mark placement, the "ImuTechz" wordmark, the landing intro (the website's home.css rules), the Home rise and top-bar drop behind it |
styles/ |
Settings > Connectors and MCP servers, the research block in the thinking panel, citation superscripts and numbered source chips |
styles/ |
Tool rows, approval cards, reply footer, verification badge, sources, search indicator, / menu, Permissions / Tools / Agent / Search panels. Resets the legacy .tool-row / .thinking summary / .thinking p rules from base.css for the new rows. Two extra hues: --ok-hue, --warn-hue (dark variants under body[data-scheme="dark"]). |
styles/ |
Preferences: text size, density, motion, ambient, glass, custom accent, reduced motion. Loaded last. |
add_models_tab.py is a dead one-off patch script, see the note at the end.
Brand (ImuTechz)#
The brand is the company website's, ported 1:1. The website is a local
Next.js project at C:\Users\shubh\Desktop\ImuTechz\WebSite\frontend\
(read-only for this repo: code is ported, assets are copied, nothing is
linked). The repository-level brand kit (brand/ at the repo root, see its
README) is the brand source for the website and other products too.
Rule: edit the kit, copy to design-lab. frontend/
is brand/ byte for byte: the UI's static root cannot
reach brand/, so it serves a copy. After changing the kit file, copy it over
and run py -3.15 frontend/ (exit 1
while the two differ); the frontend unit checks fail on drift too. The one
file serves both APIs: the kit's (ImuAperture.mount / intro, Node
require for scripts/) and the app's (markup,
createMark, the live-mark hydrator; IMURUN.aperture is the same
object). Colours: --imu-accent / --imu-ink, which styles/
maps to --logo-accent / --logo-ray on body so the dark themes follow.
Port record#
| Website source | App file | Identical | Adapted (and why) |
|---|---|---|---|
components/ (the Aperture) |
brand/ = aperture.js (byte-identical) |
superR (M = 6), bladePaths (6 blades x 40 segments, gap, cen slide, rotation), pupilPath (R 5.9, m2 = 1 + (n2-1)0.5, counter-turn -rot0.6), law(t) (frequencies 1 : PHI : PHI^2 : PHI^3, phases 0.4 / 2.1 / 4.0 / 1.0, n1 0.3->6, n2 0.55->1.5, gap 5->22, cen ease-in-out of u4, rot = t*14), REST {n1 .38, n2 1.2, gap 11, cen .5, rot 0}, TEMPO 1.8, one EPOCH per page (every live mark in lockstep), stroke 7.6 / 6.4 / 5.2 by size, blade k = 0 and the pupil in the accent, pupil shadow translate(1.1 1.3) at .16, tiles (red: accent tile, white blades; any other tile: ink tile keeping the red blade; scale .78, rx 14), mono, centre, ~30 fps for marks <= 40 px, stop off screen (IntersectionObserver) and in a hidden tab, resting frame only under reduced motion |
React effect -> a hydrator: markup() returns the same <svg> as a string (so innerHTML callers work) and a MutationObserver starts any svg[data-mark-live] added to the page. var(--color-accent) -> var(--imu-accent) (= --logo-accent: brand red in every theme, #ff5c35 on dark like the website's dark palette). The thinking indicator (live: 'thinking') breathes only while its reply is thinking. The in-app "Reduce motion" switch counts as reduced motion too. |
components/ + app/ (.intro, .intro-in, .intro-ring, .intro-ring-2, .intro-mark, .intro-go, .has-intro, keyframes) + the html:has(.intro) nav rule of app/ |
brand.js (playIntro), styles/, the curtain <div id="intro"> in index.html |
Every duration, delay, easing and keyframe: whoosh intro-zoom 1.05 s at 0.1 s cubic-bezier(0.22, 1.2, 0.3, 1) (scale .14 -> 1.1 at 78 % -> 1, blur 16 px -> 0 by 55 %); ripples intro-ripple 1.1 s at 0.45 s (ink, 35 %) and 0.62 s (red), .45 -> 2.4; lift intro-up 0.72 s at 1.3 s cubic-bezier(0.8, 0, 0.2, 1) to -104 % with the intro-edge shadow line; mark 250 px live at speed 3, clamp(160px, 24vw, 250px); node removed at 2050 ms (350 ms and a 0.3 s fade under reduced motion); has-intro while it runs; content rises 18 px from 1.55 s + n x 0.09 s (0.75 s), the second block fades in from 1.6 s (0.9 s); the top bar waits above the viewport and drops in with nav-drop 0.55 s. Plays on every real load or reload, never on in-app navigation. |
Selectors: .hero3d-copy -> .home-hero, .hero3d-stage -> .home-actions, header.site-head -> .topbar. z-index 1200 (website 70) to sit above the start-up card. The curtain is in index.html, so the first paint is already cream (the website's server render does the same). "Play the intro on launch" (Settings > Customisation, default on, kept in localStorage) and "Replay intro" were added; the website has neither. The old once-per-install splash flag is gone. customise.css shortens every animation under reduced motion, so the curtain's 0.3 s fade is exempted. |
lib/ + initSound in components/ |
sound.js |
tick, open, close, success, error, blip, swell with the same oscillator types, frequencies, sweeps, gains, durations and delays; the filtered-noise "paper" burst (bandpass, Q .9, cubic decay); master gain .3; unlock on the first gesture; one remembered toggle (switching on answers with success); the click tick on button, a, summary, label, [role='tab'], 60 ms throttle, skipping .sound-toggle and disabled controls |
Storage key imurun.sound (website imutechz sound). The website's comment says sounds are silent under reduced motion but only the click tick checks; here every sound does. Where they play: open/close on the palette, shortcuts, performance drawer and mobile drawer (the website: menu, dropdowns); success / error when such a notice appears; swell as the intro curtain lifts. The old welcome chime is removed. The toggle is Settings > Voice "Interface sounds". |
components/ |
dotfield.js |
GAP 24 (30 under 700 px), REACH 200, PUSH 10, BASE_R 1.35, BASE_A .34, the smoothstep bulge, accent at the centre, eased pointer, dirty-square repaint, loop stops when settled, dpr <= 2, still grid under reduced motion | Shown with the ImuTechz theme (light) unless Customisation "Ambient" is "none"; rebuilt on theme changes. Not on ImuTechz Console: the website's dark palette makes its dots black on black, so the effect is off there. |
components/ Wordmark + .wordmark/ |
brand.js wordmark(), .imu-wordmark in styles/ |
Inter 700, -0.03em, red I and T, steel "echz" | Shown on Home as "IMURUN | by ImuTechz". |
app/ |
logo.svg, brand/ |
Byte for byte; it is exactly bladePaths(REST) / pupilPath(REST) at 7.6 px (checked) |
- |
components/ |
not ported | - | No page of the website uses it (no import anywhere). |
components/ |
not ported | - | Repository avatars for the agents directory, not part of the brand or the landing page. |
components/ |
Settings > Voice "Interface sounds" | toggles the same remembered state | The app has no top bar slot for it; the switch lives in Settings. |
app/ tokens |
styles/, styles/ |
see "Token mapping" | - |
| Fraunces (site build, OFL) | fonts/ |
- | - |
Live marks in the app: sidebar 26 px, Home 40 px, the start-up card 84 px,
the thinking indicator 18 px (the website's loader size), the intro 250 px.
BRAND.mark = 'file' in brand.js swaps every mark for <img src="logo.svg">.
Token mapping#
Website -> shell: canvas --color-canvas #f3ede2 -> --bg;
--color-surface #faf7f0 -> --surface; surface-2 #ebe4d6 -> --surface-2;
surface-3 #e1d9c8 -> --surface-3; line rgba(28,25,20,.10/ ->
--line / --line-strong; fg #1c1915 -> --text; fg-muted #58524a ->
--muted; fg-dim #847c70 -> --muted-soft; accent #d9481e (hi #b93a14,
soft rgba(217,72,30,.12)) -> --accent, --accent-hi, --accent-soft, and
--logo-accent for the mark; ok #1f8a46 -> --success, --ok-hue; warn #9a6a12 -> --warn-hue; err #b2352a -> --danger; teal #15858d, steel #7e858d -> --teal, --steel, --logo-word-steel; --ink-rgb 28,25,20
kept; radius 14 / 9 / 20 px -> --radius, --radius-sm, --radius-lg;
--shadow-card / --shadow-pop -> --shadow-card / --shadow; ease
cubic-bezier(.2,.8,.2,1) -> --ease-std; Inter / JetBrains Mono / Fraunces
500 as on the site; eyebrows mono 11 px uppercase .14em; paper grain at 6 %.
Raster icons#
logo.svg is the resting frame, so the raster files never show a moment of
the breath. After the website's app/ changes:
copy WebSite\frontend\app\icon.svg frontend\design-lab\logo.svg
copy WebSite\frontend\app\icon.svg frontend\design-lab\brand\icon.svg
py -3.15 frontend\design-lab\brand\make_icons.pyThis writes logo.ico (16, 24, 32, 48, 64, 128, 256 px, 32-bit BMP entries,
so Win32 LoadImage and System.Drawing load them; PNG entries crashed an
icon loader before) and logo-512.png, each size rendered separately by
headless Edge (Playwright + Pillow, installed for py -3.15 here). If
Mark.jsx changes, port the change into aperture.js; the V8 check below
fails until the two agree. Restart the app to see a new taskbar icon.
State model#
All shared state lives in IMURUN.state (core.js):
| Field | Written by | Meaning |
|---|---|---|
sessions |
app.js | Array from GET / ({id, name, created_at, renamed_at}) |
activeSessionId |
app.js (setActiveSession) |
The session shown on Chat, or null |
status |
core.js (IMURUN.status.refresh) |
Last / payload, null when unreachable |
statusError |
core.js | Error from the last failed status call |
events |
app.js | SSE state: connecting, open, reconnecting, offline |
turn |
chat.js | The live Turn while a reply streams, else null |
models, modelsDownloaded, selectedModel |
models.js (publish) |
Model catalogue, count of runnable downloaded models, the active one |
Engine state shown to the user is derived, never invented
(IMURUN.status.engineState): unreachable (no answer), provisioning
(runtime !== 'ready'), ready. "No model downloaded" comes from the model
catalogue, because some compute paths never report a selected model.
Persistent client-side keys (localStorage, all wrapped in try/catch):
| Key | Owner | Content |
|---|---|---|
imurun.prefs.customisation.v1 |
theme.js | Presentation preferences (first-paint copy) |
imurun.shell.settings.v1 |
settings.js | Last copy of every server settings section |
imurun.intro.onLaunch.v1 |
brand.js | off when "Play the intro on launch" is unticked |
imurun.sound |
sound.js | off when interface sounds are muted |
imurun.tuning.consent.v1 |
feedback.js | The rating consent notice was shown |
imurun.stats.history.v1 |
panels.js | Last 40 per-reply stats |
imurun.stats.open, imurun.design.sidebar, imurun.chat.mode |
panels/app/chat | UI conveniences |
imurun.voice.consent.v1 |
voice.js | User acknowledged that browser dictation uses the vendor's service |
imurun.models.guided |
models.js | Models guide dismissed |
Server-side settings sections: profile, permissions, server,
customisation, resources, voice (/, cached
in localStorage by settings.js), and search (agent-settings.js only;
holds API keys, so it is never cached). permissions is loaded by
settings.js for the first paint but written through POST /
(and sendEmail alone through a merge-only PUT); the agent step budget goes
through GET/ (stored by the server in section agent).
Per-turn state on the chat.js Turn (not in IMURUN.state): kinds
(step -> tool | answer from the step frame), blocks (step -> trace
block), activity (activity.js controller), verification, sources,
searchResults.
Internal bus events (IMURUN.bus)#
| Event | Emitted by | Payload |
|---|---|---|
status |
core.js | / payload or null |
models |
models.js | model list |
sessions |
app.js | session list |
session.active |
app.js | session id |
page |
app.js | page name |
settings.section |
app.js | Settings section shown |
theme |
theme.js | effective preferences |
turn.stats |
chat.js | per-reply stats object |
turn.settled |
chat.js | (none) the live turn ended |
events.status |
app.js | SSE state |
server:<type> |
app.js | every SSE frame from /, e.g. server:model.switch.done. approvals.js listens to server:tool.approval_needed, server:tool.approved, server:tool.denied, server:permissions; agent-settings.js to server:permissions; connectors-settings.js to server:connector; feedback.js to server:tuning.feedback; tuning-settings.js to server:tuning.setup, server:tuning.job, server:tuning.ready; mcp-settings.js to server:mcp.connected, server:mcp.disconnected, server:mcp.error |
Event flow#
user presses Enter in the composer
|
chat.js submit()
| (while typing "/...": slash.js lists skills; Enter/Tab insert "/<id> ")
|-- "/rename" ............................. app.js handleCommand -> rename dialog
|-- no session ............................ app.js createSession -> POST /api/sessions
|-- a reply is running .................... chat.js queue() -> POST /api/prompts (shown as "Queued")
v
chat.js send(): append user bubble, new Turn (assistant node exists NOW)
|
api.js streamTurn()
1. new EventSource(/api/events?token=...) one stream per turn
2. wait for `open` (max 2.5 s) so the first tokens are not lost
3. POST /api/turns {session_id, text, mode} -> {turn_id}
503 -> chat.js provisionAndRetry: no model downloaded -> "No model
downloaded"; /api/status.selected_model empty -> "Choose a
model" (inline, Open Models / Try again, no toast); else wait
for the engine (stack ensure, up to 3 min) and retry once
4. replay frames buffered before the reply, keeping only turn_id == ours
(tool.*, agent.*, skill.used must carry exactly our turn_id: manual
runs publish them with an empty one)
5. dispatch in arrival order:
turn.start / step.start / step -> Turn.onEvent (step.kind -> Turn.kinds;
step.verification/sources kept)
tool.call / tool.result /
tool.approval_needed / tool.approved / tool.denied /
agent.start / agent.end / skill.used
-> Turn.onEvent -> activity.js (rows in
the step's trace block; approval
frames also -> approvals.js)
research.plan / .search / .fetch / .done
-> Turn.onEvent -> activity.js ->
research.js (block at the top of the
thinking panel; frames without a
turn_id are kept)
token -> Turn.onToken (per step)
turn.end -> Turn.finish(event) then stream closes
turn.error -> Turn.fail then stream closes
6. stream drops mid-turn -> poll GET /api/turns/{id} until not running
|
Turn.render() (at most once per animation frame; backs off to ~30/~16 fps
when a render costs more than 5/12 ms)
last step text -> markdown.splitReasoning -> thinking panel + answer bubble
(unless that step's kind is "tool": then it is trace, the bubble is
cleared and a folded pill unfolds)
earlier steps -> one trace block per step (Turn.traceBlock), its
reasoning text first, then its tool rows; "Using `tool` {...}" lines
are dropped once rows exist
answer HTML -> patchHtml(): unchanged blocks kept, the growing block
updated in place (code: only <code> text; tables: only <tbody>), new
blocks fade in. Never assign innerHTML to a streaming bubble.
panel open/close/fold -> animateDetails / collapseAway (height animations)
|
Turn.finish: final text cleaned (activity.stripNotes drops "[note: unable
to verify]"-style notes), stats line (engine counters if turn.end carries
`stats`, otherwise measured in the browser), Copy / Read aloud, then
activity.finish: sources row, "Used ..." footer with skill chips,
verification badge next to the stats; announce "Imurun replied"
|
bus 'turn.stats' -> panels.js performance drawer and sparkline
Separately, app.js subscribeEvents(/api/events) receives every frame:
usage -> Usage panel
compaction.* -> one toast, updated in place (id "compaction")
model.switch.* -> models.js toast "model-switch", refresh catalogue
web_search.* -> chat.js: "Searching the web…" (dots + query) under the
pill, then "Found N sources" (results kept as sources)
connector -> connector card + toast
turn.* -> session list "replying" chip
tool.approval_needed -> approvals.js add(): card inline in the live reply
when it is on screen, else a kept toast; countdown;
tab title "(N) Approval needed"
tool.approved / tool.denied -> approvals.js: card shows the outcome, leaves
permissions -> Settings > Permissions and Tools refresh
events 'open' -> approvals.js restore(): GET /api/tools/approvals
Answering a card: Approve / Approve and remember / Deny (Enter, Esc) ->
POST /api/tools/approvals/{id} {approved, remember}; the server then
publishes tool.approved / tool.denied and the waiting turn continues.
Settings > Tools run form: POST /api/tools/run {name, args}
200 -> result; 403 {decision: "ask"} -> "Run it once" -> same POST with
approved: true; 403 {decision: "deny"} -> the reasons.Stop: the Stop button (or Esc on Chat) calls POST /.
If the turn id is not known yet, the cancel is sent as soon as the POST
replies. The reply ends when the server sends turn.end with
status: "stopped"; if it never does, the UI gives up after 10 s.
Theme system#
A theme is styles/ that defines, on :root, at least:
--bg --surface --surface-2 --text --muted --line --line-strong --accent --accent-text --accent-soft --accent-line --code-bg --success --success-soft --danger --danger-line --danger-text --backdrop --focus-ring --shadow --radius --radius-sm --font --mono, plus optional flourishes (body background,
glass on .bubble, .send gradient).
The default theme is imutechz (see "Brand"). Preferences carry a chosen
flag set when the user picks a theme or font; without it the pre-2026-11
defaults (liquid-paper, manrope) count as "no choice" and become the
current defaults (BUGS-FIXED 66).
theme.js swaps #theme-link by loading the new sheet next to the old one
and removing the old one on load (no unstyled flash), sets
body[data-theme] and body[data-scheme] (light/dark, from the registry,
used for the logo palette and callout colours), and applies preferences:
| Preference | Mechanism | Styled in |
|---|---|---|
| Accent colour | inline --accent, --accent-soft, --accent-line, --focus-ring, --accent-text on <body> (only when accentCustom) |
everywhere + customise.css for theme gradients |
| Chat text size | --font-scale (0.9 to 1.25) |
customise.css |
| Density | body[data-density] compact / comfortable / spacious, via --msg-gap --bubble-pad --row-py --page-pad --card-pad --composer-pad |
tokens.css, customise.css |
| Reduce motion | body[data-reduced-motion="1"]; the OS setting is always honoured too |
customise.css |
| Background motion / ambient / glass | body[data-motion], [data-ambient], [data-glass] |
customise.css |
| Notice duration | window.__imurun_notify_timeout |
toast.js |
Persistence: every change is written to localStorage at once and pushed
(debounced) to the server customisation section. When the server copy
arrives (imurun:settings-hydrated), it is adopted unless the user already
changed something on this page, in which case the local copy is pushed.
To add a theme: copy styles/ to styles/, change the
variables, and add {id, label, note, swatch: [bg, surface, accent], dark}
to THEMES in theme.js. It appears in the picker and the command palette.
Adding a panel (Settings section or page)#
Settings section:
- In
mockup.js, add a tab button<button class="settings-tab" type="button" data-settings-section="mysection">My section</tobutton> .settings-subnav, and a panel<section class="settings-section" data-settings-panel="mysection" hidden>...</insidesection> .settings-content. app.js wires roles, arrow keys and the crumb. - For values stored on the server, give inputs
data-setting="mysection.key"and addmysectionwith defaults toSECTIONS/defaultsinsettings.js. The server accepts any section name matching^[a-z0-9][a-z0-9-]{0,63}$. - For behaviour, add a module (new file +
<script src>), and react toIMURUN.bus.on('settings.section', fn)if it should load lazily. - Add a palette entry in
actions()inpanels.js.
Page: add a nav-item with data-page="x" and a
<div class="page-view page-scroll" id="page-x" hidden> in mockup.js;
IMURUN.shell.setPage('x') then works, including Alt+number if you extend
PAGES in panels.js.
Conventions: render lists with innerHTML and handle clicks with ONE
delegated listener on the container, bound once; escape all server text with
IMURUN.util.escapeHtml; report failures with IMURUN.toast({tone: 'error', ...}) saying what happened and what to do; never show a number the server
did not give without saying it is an estimate.
Accessibility#
- Skip link, landmarks (
nav,main, labelled regions),aria-currenton the active page and session, Settings as a verticaltablist(arrow keys, Home/End). - Dialogs (rename, palette, shortcuts, download, guide, drawer) trap focus and return it on close; Esc closes the innermost surface first.
- Sidebar sessions: Enter/Space open, F2 rename, Delete delete, arrows move, Shift+F10 or the Menu key opens the context menu (arrow keys inside).
- The transcript is
role="log"witharia-live="off": tokens are not read one by one; completion is announced once through#sr-announcer. - Toast cards carry their own
status/alertrole (the container is no longer a second live region). Timers pause on hover and focus. - Every animation stops under reduced motion (OS or the Customisation toggle); spinners slow down rather than vanish.
Keyboard shortcuts#
Ctrl/ search, Enter send (queues while a reply runs), Shift Enter
new line, Esc stop or close, / focus the message box, Alt N new chat,
Ctrl/ sidebar, Ctrl/ performance panel, Alt 1-7 pages,
? or Ctrl / the list.
Approval cards: Enter approves when the card has focus (ignored for
0.7 s after it appears), Esc denies the focused card, or the card in the
reply on screen when focus is on the page or the composer. That Esc runs in
the capture phase, so with a card waiting Esc denies instead of stopping the
reply (a second Esc then stops it). In the composer's / list: arrows,
Enter/Tab insert, Esc closes.
Native window (pywebview)#
Title:
start.pynames the window "IMURUN"; the page title is "IMURUN". The taskbar/window icon is set by the launcher fromlogo.ico(item 14 inAPI-REQUESTS.md, done); it now shows the ImuTechz Aperture (resting frame).OAuth sign-in:
window.openmay be blocked in the native window; the Connectors panel then offers "Open sign-in page" / "Copy link" and finishes by polling (API-REQUESTS 31 asks the launcher to open such links in the system browser).No history/back-forward use anywhere; navigation is in-app state.
Works from 720px wide: below 860px the sidebar becomes a drawer (hamburger in the topbar, scrim, focus trap). Window resizes reflow the composer, close menus and drop the drawer when widening.
window.confirm/window.promptare used for destructive confirmations and quick inputs; WebView2 shows them natively.Links in replies use
target="_blank"; whether they open in the system browser depends on the pywebview host configuration.WebView2 exposes
webkitSpeechRecognitionbut the service usually refuses; dictation then explains why and disables itself (seeVOICE.md).
Verification tooling used#
No Node on the build machine. Checks used during this work (scripts were kept out of the repo):
- Syntax: every
*.jsparsed withesprima(Python package, ES2017). Code is written to ES2017 (no optional catch binding, no object spread, no?.). On this machine esprima, playwright and mini-racer are installed for the Python 3.15 interpreter (py -3.15), not in.venv. - Agentic UI (2026-10-31): headless Edge with the production CSP, every
/route mocked in-process andapi/ * EventSourcereplaced by a fake the test drives; 49 checks: gallery states, a live turn (tool step preamble cleared, manual-run frame kept out, inline card, toast when Chat is hidden and gone when it is back, countdown, title flag, Enter approves, Esc denies without stopping, delete card has no "remember"), finish (row latency, footer, verified badge, note stripped, title restored,n/has no badge), 503 -> "Choose a model" without a toast, Permissions (POST, never PUT), Tools 403 ask -> approved re-post, Agent, Search (key never ina localStorage), plus light, dark and narrow screenshots under reduced motion.stripNotes/stripCalls/argSummaryunit-checked in V8. - Connectors / MCP / research / brand (2026-11-01): headless Edge, same
set-up (production CSP, mocked
/including the connector config, test, connect, device and import routes and the MCP routes, fakeapi/ * EventSource,window.openreturning null as in the native window); 148 checks (2026-11-02; feedback, Tuning and server voice added; Edge runs with a fake microphone): ImuTechz default theme, canvas colour, the Aperture in the sidebar (6 blades + pupil, live, red 12 o'clock blade; resting frame under reduced motion, breathing otherwise), home lockup, Fraunces loads; the intro measured against Intro.jsx / home.css (curtainintro-up 0.72sat 1.3 scubic-bezier(0.8, 0, 0.2, 1), markintro-zoom 1.05sat 0.1 scubic-bezier(0.22, 1.2, 0.3, 1), ripples 1.1 s at 0.45 / 0.62 s, the edge shadow, cream, 250 px at speed 3,has-intro, Home rise delays, top bar hidden then dropped in, the mark breathing across frames, curtain removed at ~2050 ms (measured 2032), not replayed on in-app navigation, the launch toggle persists; reduced motion: 0.3 s fade, no ripples, removed at ~350 ms, measured 336) with frame screenshots at 120 / 400 / 700 / 1000 / 1300 / 1600 / 1900 ms; the dot field on (light) and off (dark); a press ticks (oscillator count), muted and reduced motion stay silent; search keys PUT underIMURUN_*names and never inlocalStorage; 8 connector cards with the right status, levels ("reads", "always asks"), tool toggle POST, Test output, client id + secret save (secret cleared from the field and never inlocalStorage), exact-port note, Gmail import posts only the path, blocked pop-up -> sign-in link -> poll finishes "Connected as", theconnectorframe updates a card; MCP built-in entry, tools with destructive auto-approve disabled, enable POST keeps********,mcp.connected/mcp.errorframes, form validation and parsed args/env, templates; a live turn driven by engine-shapedresearch.*frames (lanes wait, a refused source says failed, a page chip fills in, unreported lanes go quiet, verified badge, 3 citation links to numbered chips, the panel survives the fold, citation click highlights the chip without navigating); gallery research states and the Gmail approval card; dark theme blades; no horizontal overflow at 720 px; no CSP violations, no console errors. Screenshots reviewed for light, dark, narrow, intro. - Connectors to the F1 contract of 2026-11-01 (in the 98, including "Choose file…" through
POST /and its 501 fallback): built-in / env / user / none credential sources (one Connect, or "Not available yet" +api/ system/ pick-file registration_todoand "Use your own app"), credential fields hidden until Advanced is opened, Connect posts{open: true}and shows "Finish signing in in your browser" with no status polling, theconnectorframe flips the card and a cancelled sign-in gives exactly one notice, token cards (password field, Create a token link + minimum permissions, 401 keeps the field, 200 shows account + permissions and clears it, Forget token -> DELETE), the storage badge, "How it works" renders the doc route's Markdown, the Gmail import result; approvals honour the nativerememberable/level; the MCP edit form picks auto-approve from the discovered tools. - Tuning (in the 127): the history reply carries
data-turn-idand three buttons; up posts{turn_id, rating, session_id}, a second click postsclear, the consent notice appears exactly once with the local-only wording, Teach is prefilled with the answer and posts the correction, atuning.feedbackframe syncs the buttons, a finished live reply gets the buttons too; the honest copy; Install posts/andapi/ tuning/ setup tuning.setupframes move the bar; the plan for the target and for an 8B ("refused", reason verbatim); samples and skip reasons; Train now: 409 asks first and only the click resends withconfirm: true;tuning.jobdrives the stepper, progress, loss and "paused", Cancel posts the cancel; Settings post only changed keys plus the write-only token; history with metrics on open;tuning.readyshows the card, never switches by itself, and Use it posts/; no overflow at 720 px.api/ models/ select {id} - Aperture vs the website (V8, 21 checks; the three new ones: the served copy
is byte-identical to
brand/, the kit file loads with no DOM and exposes the kit API,vanilla/ aperture.js ImuAperture === IMURUN.aperture): the website's ownMark.jsxmath is extracted and run next toaperture.js; the resting frame andlaw(t)/ blade / pupil paths at 400 instants are identical strings; the resting frame equalslogo.svg(the website'sapp/) and the repo kit'sicon.svg brand/; stroke weights, accent blade, shadow, tiles, mono, centre, live markup; the sound palette and toggle.svg/ aperture-small.svg logo.icoloaded through Win32LoadImage(16, 24, 32, 48, 256 px) and System.Drawing.- Unit:
markdown.jsrun in V8 (mini-racer) against rendering andsplitReasoningcases; 2026-11-01: 24 checks ofresearch.expand(citation groups and ranges),laneOf,phase,mcp.configFrom/parsePairs(ids, stdio/http, redacted env kept, headers, timeout clamp). - Render: headless Microsoft Edge via Playwright with the production CSP injected, screenshots of the splash, gallery, settings, dark theme, narrow window, palette, stats and shortcuts; and an end-to-end chat against a mocked API whose event stream delivers frames before the POST reply and includes another turn's frames.
Dead file: add_models_tab.py#
A one-off patch script that inserted the Models tab into mockup.js. The tab
is already in mockup.js, and the script hardcodes another machine's path
(C:\Users\imu\...). On this machine it fails on the path; pointed at the
right file it would print "Could not find subnav pattern" and then rewrite
mockup.js anyway, reading and writing with the platform default encoding
(cp1252 on Windows), which risks corrupting the file's UTF-8 characters. Do
not run it. It is kept (not deleted) per the task rules; it is safe to
remove.