add link embedding in chat, update tailwind, update icons, tweak column layout. (8000 files changed because tailwind changed stuff in the update)

This commit is contained in:
legop3
2026-09-20 15:41:45 -04:00
parent 69c5bfe544
commit ff8b0c530e
151 changed files with 5331 additions and 1787 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card-header{top:var(--configuration-sticky-top, 0px)}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-card-body{padding:.125rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-key-label{font-size:1rem;line-height:1.5rem;font-weight:600;line-height:1.375;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity, 1));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity, 1))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.configuration-secret{min-width:0px}.configuration-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
import{r as g,R as a}from"./index-DCtVfg7-.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
//# sourceMappingURL=Preview-O6d1niaS.js.map
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-B0P8L1jr.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-DBQeHfaN.js.map
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-DCtVfg7-.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-CFFNNLJ4.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
function u(i){return class extends i{_playedRanges=[];_currentPlayedRange=null;_RANGE_EPSILON=.5;_seeking=!1;connectedCallback(){super.connectedCallback&&super.connectedCallback(),this.addEventListener("play",()=>this.onPlaybackStart({time:this.currentTime})),this.addEventListener("pause",()=>this.onPlaybackStop({time:this.currentTime})),this.addEventListener("ended",()=>this.onPlaybackStop({time:this.currentTime})),this.addEventListener("seeking",()=>this.onSeeking()),this.addEventListener("seeked",()=>this.onSeeked({time:this.currentTime}))}onPlaybackStart(e={}){this._seeking=!1;const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._currentPlayedRange||(this._currentPlayedRange={start:n,end:n})}onSeeking(){this._seeking=!0,this._commitCurrentRange()}onSeeked(e={}){this._seeking=!1;const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._currentPlayedRange={start:n,end:n}}onPlaybackStop(e={}){const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._commitCurrentRange(n)}_commitCurrentRange(e){if(!this._currentPlayedRange)return;typeof e=="number"&&(this._currentPlayedRange.end=e);const{start:t,end:n}=this._currentPlayedRange;this._currentPlayedRange=null,this.addPlayedRange(t,n)}addPlayedRange(e,t){if(e>=t)return;const n=this._RANGE_EPSILON,c=[...this._playedRanges,{start:e,end:t}];c.sort((a,s)=>a.start-s.start);const r=[];for(const a of c){if(!r.length){r.push({...a});continue}const s=r[r.length-1];a.start<=s.end+n?(s.start=Math.min(s.start,a.start),s.end=Math.max(s.end,a.end)):r.push({...a})}this._playedRanges=r}get played(){const e=this.currentTime;return!this.paused&&!this._currentPlayedRange&&typeof e=="number"&&(this._currentPlayedRange={start:e,end:e}),this._currentPlayedRange&&typeof e=="number"&&(e>this._currentPlayedRange.end&&(this._currentPlayedRange.end=e),this.addPlayedRange(this._currentPlayedRange.start,this._currentPlayedRange.end)),this._playedRanges.length?l(this._playedRanges.map(t=>[t.start,t.end])):l([[0,0]])}}}function l(i){return Object.defineProperties(i,{start:{value:d=>i[d][0]},end:{value:d=>i[d][1]}}),i}export{u as M};
//# sourceMappingURL=index-B4nclofW.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
import{_ as N,R as A}from"./index-DCtVfg7-.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
`,i+=`${e.text}
`;const n=new Blob([i],{type:"text/vtt"});return URL.createObjectURL(n);function o(e){const u=String(Math.floor(e/3600)).padStart(2,"0"),r=String(Math.floor(e%3600/60)).padStart(2,"0"),d=(e%60).toFixed(3).padStart(6,"0");return`${u}:${r}:${d}`}}globalThis.customElements&&!globalThis.customElements.get("dash-video")&&globalThis.customElements.define("dash-video",P);var B=P,H=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),F={className:"class",htmlFor:"for"};function I(t){return t.toLowerCase()}function k(t){if(typeof t=="boolean")return t?"":void 0;if(typeof t!="function"&&!(typeof t=="object"&&t!==null))return t}function W({react:t,tagName:i,elementClass:n,events:o,displayName:e,defaultProps:u,toAttributeName:r=I,toAttributeValue:d=k}){const m=Number.parseInt(t.version)>=19,v=t.forwardRef((g,h)=>{var E,_;const l=t.useRef(null),T=t.useRef(new Map),w={},M={},f={},y={};for(const[a,s]of Object.entries(g)){if(H.has(a)){f[a]=s;continue}const c=r(F[a]??a);if(n.prototype&&a in n.prototype&&!(a in(((E=globalThis.HTMLElement)==null?void 0:E.prototype)??{}))&&!((_=n.observedAttributes)!=null&&_.some(b=>b===c))){y[a]=s;continue}if(a.startsWith("on")){w[a]=s;continue}const p=d(s);if(c&&p!=null&&(M[c]=String(p),m||(f[c]=p)),c&&m){const b=k(s);p!==b?f[c]=p:f[c]=s}}if(typeof window<"u"){for(const a in w){const s=w[a],c=a.endsWith("Capture"),p=(o?.[a]??a.slice(2).toLowerCase()).slice(0,c?-7:void 0);t.useLayoutEffect(()=>{const b=l?.current;if(!(!b||typeof s!="function"))return b.addEventListener(p,s,c),()=>{b.removeEventListener(p,s,c)}},[l?.current,s])}t.useLayoutEffect(()=>{if(l.current===null)return;const a=new Map;for(const s in y)L(l.current,s,y[s]),T.current.delete(s),a.set(s,y[s]);for(const[s,c]of T.current)L(l.current,s,void 0);T.current=a})}if(typeof window>"u"&&n?.getTemplateHTML&&n?.shadowRootOptions){const{mode:a,delegatesFocus:s}=n.shadowRootOptions,c=t.createElement("template",{shadowrootmode:a,shadowrootdelegatesfocus:s,dangerouslySetInnerHTML:{__html:n.getTemplateHTML(M,g)},key:"ce-la-react-ssr-template-shadow-root"});f.children=[c,f.children]}return t.createElement(i,{...u,...f,ref:t.useCallback(a=>{l.current=a,typeof h=="function"?h(a):h!==null&&(h.current=a)},[h])},f.children)});return v.displayName=e??n.name,v}function L(t,i,n){var o;t[i]=n,n==null&&i in(((o=globalThis.HTMLElement)==null?void 0:o.prototype)??{})&&t.removeAttribute(i)}var V=W({react:A,tagName:"dash-video",elementClass:B,toAttributeName(t){return t==="muted"?"":t==="defaultMuted"?"muted":I(t)}});export{V as default};
//# sourceMappingURL=react-BBapMaLq.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
import{R as W}from"./index-DCtVfg7-.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
<style>
:host {
display: inline-block;
line-height: 0;
}
video,
audio {
max-width: 100%;
max-height: 100%;
min-width: 100%;
min-height: 100%;
}
</style>
<slot></slot>
`);const N=(i,{tag:t,is:s})=>{const d=globalThis.document?.createElement(t,{is:s}),f=d?S(d):[];return class g extends i{static Events=$;static template=A;static skipAttributes=[];static#t;static get observedAttributes(){return g.#l(),[...d?.constructor?.observedAttributes??[],"autopictureinpicture","disablepictureinpicture","disableremoteplayback","autoplay","controls","controlslist","crossorigin","loop","muted","playsinline","poster","preload","src"]}static#l(){if(this.#t)return;this.#t=!0;const e=new Set(this.observedAttributes);e.delete("muted");for(let o of f){if(o in this.prototype)continue;if(typeof d[o]=="function")this.prototype[o]=function(...r){this.#e();const a=()=>this.call?this.call(o,...r):this.nativeEl[o].apply(this.nativeEl,r);return this.loadComplete&&!this.isLoaded?this.loadComplete.then(a):a()};else{let r={get(){this.#e();let a=o.toLowerCase();if(e.has(a)){const p=this.getAttribute(a);return p===null?!1:p===""?!0:p}return this.get?.(o)??this.nativeEl?.[o]??this.#s[o]}};o!==o.toUpperCase()&&(r.set=async function(a){this.#e();let p=o.toLowerCase();if(e.has(p)){a===!0||a===!1||a==null?this.toggleAttribute(p,!!a):this.setAttribute(p,a);return}if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.set){this.set(o,a);return}this.nativeEl[o]=a}),Object.defineProperty(this.prototype,o,r)}}}#o;#n;#r=!1;#i=!1;#a;#s;constructor(){super(),this.shadowRoot||(this.attachShadow({mode:"open"}),this.shadowRoot.append(this.constructor.template.content.cloneNode(!0))),this.load!==g.prototype.load&&(this.loadComplete=new O)}get loadComplete(){return this.#n}set loadComplete(e){this.#i=!1,this.#n=e,e?.then(()=>{this.#i=!0})}get isLoaded(){return this.#i}get nativeEl(){return this.#a??this.shadowRoot.querySelector(t)??this.querySelector(t)}set nativeEl(e){this.#a=e}get defaultMuted(){return this.hasAttribute("muted")}set defaultMuted(e){this.toggleAttribute("muted",!!e)}get src(){return this.getAttribute("src")}set src(e){this.setAttribute("src",`${e}`)}get preload(){return this.getAttribute("preload")??this.nativeEl?.preload}set preload(e){this.setAttribute("preload",`${e}`)}async#e(){if(this.#o)return;this.#o=!0,this.#u(),this.#c();for(let u of f)this.#d(u);const e=new Map,o=this.shadowRoot.querySelector("slot:not([name])");o?.addEventListener("slotchange",()=>{const u=new Map(e);o.assignedElements().filter(r=>["track","source"].includes(r.localName)).forEach(async r=>{u.delete(r);let a=e.get(r);a||(a=r.cloneNode(),e.set(r,a)),this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.nativeEl.append?.(a)}),u.forEach(r=>r.remove())});for(let u of this.constructor.Events)this.shadowRoot.addEventListener?.(u,r=>{r.target===this.nativeEl&&this.dispatchEvent(new CustomEvent(r.type,{detail:r.detail}))},!0)}#d(e){if(Object.prototype.hasOwnProperty.call(this,e)){const o=this[e];delete this[e],this[e]=o}}#u(){const e=document.createElement(t,{is:s});e.muted=this.hasAttribute("muted");for(let{name:o,value:u}of this.attributes)e.setAttribute(o,u);this.#s={};for(let o of S(e))this.#s[o]=e[o];e.removeAttribute("src"),e.load()}async#c(){if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,!this.nativeEl){const e=document.createElement(t,{is:s});e.part=t,this.shadowRoot.append(e)}this.nativeEl.muted=this.hasAttribute("muted")}attributeChangedCallback(e,o,u){this.#e(),e==="src"&&this.load!==g.prototype.load&&this.#h(),this.#p(e,o,u)}async#h(){this.#r&&(this.loadComplete=new O),this.#r=!0,await Promise.resolve(),await this.load(),this.loadComplete?.resolve(),await this.loadComplete}async#p(e,o,u){this.loadComplete&&!this.isLoaded&&await this.loadComplete,!["id","class",...this.constructor.skipAttributes].includes(e)&&(u===null?this.nativeEl.removeAttribute?.(e):this.nativeEl.setAttribute?.(e,u))}connectedCallback(){this.#e()}}};function S(i){let t=[];for(let s=Object.getPrototypeOf(i);s&&s!==HTMLElement.prototype;s=Object.getPrototypeOf(s))t.push(...Object.getOwnPropertyNames(s));return t}class O extends Promise{constructor(t=()=>{}){let s,d;super((f,c)=>{t(f,c),s=f,d=c}),this.resolve=s,this.reject=d}}const B=globalThis.document?N(HTMLElement,{tag:"video"}):class{};globalThis.document&&N(HTMLElement,{tag:"audio"});var H,R;const L=(H=globalThis.document)==null?void 0:H.createElement("template");L&&(L.innerHTML=`
<div class="wistia_embed"></div>
`);const P=(R=globalThis.document)==null?void 0:R.createElement("template");P&&(P.innerHTML=`
<style>
:host {
display: inline-block;
min-width: 300px;
min-height: 150px;
position: relative;
}
::slotted(.wistia_embed) {
position: absolute;
width: 100%;
height: 100%;
}
</style>
<slot></slot>
`);function D(i,t){if(Object.hasOwn(i,t)){const s=i[t];delete i[t],i[t]=s}}class q extends B{static template=P;static skipAttributes=["src"];#t=null;constructor(){super(),D(this,"config")}get config(){return this.#t}set config(t){this.#t=t}get nativeEl(){var t;return((t=this.api)==null?void 0:t.elem())??this.querySelector("video")}async load(){var t;if((t=this.querySelector(".wistia_embed"))==null||t.remove(),!this.src)return;await new Promise(o=>setTimeout(o,50));const s=/(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/i,d=this.src.match(s)[1],f={autoPlay:this.autoplay,silentAutoPlay:this.autoplay?"allow":void 0,preload:this.preload??"metadata",playsinline:this.playsInline,endVideoBehavior:this.loop?"loop":void 0,chromeless:!this.controls,playButton:this.controls,muted:this.defaultMuted,...this.#t??{}};this.append(L.content.cloneNode(!0));const c=this.querySelector(".wistia_embed");c.id||(c.id=z(d)),c.classList.add(`wistia_async_${d}`),await F("https://fast.wistia.com/assets/external/E-v1.js","Wistia"),this.api=await new Promise(o=>{globalThis._wq.push({id:c.id,onReady:o,options:f})})}async attributeChangedCallback(t,s,d){if(t==="controls"){switch(await this.loadComplete,t){case"controls":this.api.bigPlayButtonEnabled(this.controls),this.controls?this.api.releaseChromeless():this.api.requestChromeless();break}return}super.attributeChangedCallback(t,s,d)}get duration(){var t;return(t=this.api)==null?void 0:t.duration()}async play(){return await this.loadComplete,this.api.play(),new Promise(t=>this.addEventListener("playing",t))}}const T={};async function F(i,t){if(T[i])return T[i];if(self[t])return self[t];const s=new Promise((d,f)=>{const c=document.createElement("script");c.defer=!0,c.src=i,c.onload=()=>d(self[t]),c.onerror=f,document.head.append(c)});return T[i]=s,s}let U=0;function z(i){const t=++U;return`${i}${t}`}globalThis.customElements&&!globalThis.customElements.get("wistia-video")&&globalThis.customElements.define("wistia-video",q);var V=q,G=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),J={className:"class",htmlFor:"for"};function I(i){return i.toLowerCase()}function j(i){if(typeof i=="boolean")return i?"":void 0;if(typeof i!="function"&&!(typeof i=="object"&&i!==null))return i}function K({react:i,tagName:t,elementClass:s,events:d,displayName:f,defaultProps:c,toAttributeName:e=I,toAttributeValue:o=j}){const u=Number.parseInt(i.version)>=19,r=i.forwardRef((a,p)=>{var M,k;const m=i.useRef(null),E=i.useRef(new Map),C={},_={},v={},w={};for(const[n,l]of Object.entries(a)){if(G.has(n)){v[n]=l;continue}const h=e(J[n]??n);if(s.prototype&&n in s.prototype&&!(n in(((M=globalThis.HTMLElement)==null?void 0:M.prototype)??{}))&&!((k=s.observedAttributes)!=null&&k.some(b=>b===h))){w[n]=l;continue}if(n.startsWith("on")){C[n]=l;continue}const y=o(l);if(h&&y!=null&&(_[h]=String(y),u||(v[h]=y)),h&&u){const b=j(l);y!==b?v[h]=y:v[h]=l}}if(typeof window<"u"){for(const n in C){const l=C[n],h=n.endsWith("Capture"),y=(d?.[n]??n.slice(2).toLowerCase()).slice(0,h?-7:void 0);i.useLayoutEffect(()=>{const b=m?.current;if(!(!b||typeof l!="function"))return b.addEventListener(y,l,h),()=>{b.removeEventListener(y,l,h)}},[m?.current,l])}i.useLayoutEffect(()=>{if(m.current===null)return;const n=new Map;for(const l in w)x(m.current,l,w[l]),E.current.delete(l),n.set(l,w[l]);for(const[l,h]of E.current)x(m.current,l,void 0);E.current=n})}if(typeof window>"u"&&s?.getTemplateHTML&&s?.shadowRootOptions){const{mode:n,delegatesFocus:l}=s.shadowRootOptions,h=i.createElement("template",{shadowrootmode:n,shadowrootdelegatesfocus:l,dangerouslySetInnerHTML:{__html:s.getTemplateHTML(_,a)},key:"ce-la-react-ssr-template-shadow-root"});v.children=[h,v.children]}return i.createElement(t,{...c,...v,ref:i.useCallback(n=>{m.current=n,typeof p=="function"?p(n):p!==null&&(p.current=n)},[p])},v.children)});return r.displayName=f??s.name,r}function x(i,t,s){var d;i[t]=s,s==null&&t in(((d=globalThis.HTMLElement)==null?void 0:d.prototype)??{})&&i.removeAttribute(t)}var X=K({react:W,tagName:"wistia-video",elementClass:V,toAttributeName(i){return i==="muted"?"":i==="defaultMuted"?"muted":I(i)}});export{X as default};
//# sourceMappingURL=react-Bqsex64l.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-B0P8L1jr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CsoONCTh.css">
<script type="module" crossorigin src="/assets/index-DCtVfg7-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CfkQC3hX.css">
</head>
<body>
<div id="root"></div>
+1121 -1256
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -15,12 +15,15 @@
"@rjsf/core": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"linkify-react": "^4.3.3",
"linkifyjs": "^4.3.3",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-icons": "^5.7.0",
"react-joystick-component": "^6.2.1",
"react-player": "^3.4.0",
"react-router-dom": "^7.9.6",
"three": "^0.184.0",
"uplot": "^1.6.32",
@@ -28,18 +31,17 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"autoprefixer": "^10.4.22",
"baseline-browser-mapping": "^2.11.25",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14",
"tailwindcss": "^4.3.3",
"vite": "^7.2.2",
"vitest": "^5.0.0"
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -137,7 +137,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
return (
<div ref={editorRef} className="configuration-editor">
<CardFrame title="Configuration" meta={`revision ${revision}`} clipOverflow={false} bodyClassName="p-0.5">
<div ref={toolbarRef} className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
<div ref={toolbarRef} className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur-sm">
<p className="text-xs text-slate-400">Saving applies the complete revision immediately and reloads each affected service.</p>
{/* All document actions stay together at the start of the toolbar. The
editor may use a wide canvas, but width is never used to separate a
@@ -21,7 +21,7 @@ export default function PasswordConfirmationDialog({ open, busy, error, onCancel
}
return (
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/75 p-2">
<div className="fixed inset-0 z-1000 flex items-center justify-center bg-black/75 p-2">
<form className="w-full max-w-sm border border-neutral-500/70 bg-neutral-900 p-1 text-slate-100 shadow-2xl" onSubmit={submit}>
<h2 className="text-base font-semibold">Confirm password</h2>
<p className="mt-0.5 text-xs text-slate-400">This sensitive action requires recent confirmation of your administrator password.</p>
+4 -1
View File
@@ -3,6 +3,9 @@
* Scope: Keeps route-specific hierarchy and RJSF integration out of the shared global stylesheet.
*/
/* Reuse the shared theme without emitting a second copy of Tailwind. */
@reference '../index.css';
.configuration-tree {
/* The large gap is intentionally limited to root siblings. It makes each
top-level configuration section read as a separate YAML block while the
@@ -91,7 +94,7 @@
.configuration-value input:not([type='checkbox']),
.configuration-value select,
.configuration-value textarea {
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-sky-500;
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-hidden focus:ring-1 focus:ring-sky-500;
}
.configuration-value input[type='checkbox'] {
@@ -504,7 +504,7 @@ export default function AdminPanelContent() {
value={reasonDraft}
onChange={(event) => setReasonDraft(event.target.value)}
placeholder="Set an admin mode reason"
className="field-input text-sm min-h-[3.5rem]"
className="field-input text-sm min-h-14"
/>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleReasonSave} className="button-dark">
@@ -558,7 +558,7 @@ export default function AdminPanelContent() {
{updateStates[rover.id] ? 'Updating...' : 'Update'}
</button>
{rover?.private?.enabled ? (
<div className="w-full rounded border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
<div className="w-full rounded-sm border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
<div className="text-slate-300">Private safety</div>
<label className="flex items-center gap-0.5">
<input
@@ -74,28 +74,28 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
{statPills.map((pill) => (
<span
key={pill.label}
className="rounded border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
className="rounded-sm border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
>
{pill.label}: {pill.value}
</span>
))}
</div>
{errors.message ? (
<div className="surface text-xs text-red-300 break-words">Error: {errors.message}</div>
<div className="surface text-xs text-red-300 wrap-break-word">Error: {errors.message}</div>
) : null}
{errors.details ? (
<details className="surface text-xs text-red-200">
<summary className="cursor-pointer select-none text-red-300">Failure details</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-red-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-red-200">
{JSON.stringify(errors.details, null, 2)}
</pre>
</details>
) : null}
{output.generated ? (
<div className="surface text-xs text-slate-200 break-words">Generated: {output.generated}</div>
<div className="surface text-xs text-slate-200 wrap-break-word">Generated: {output.generated}</div>
) : null}
{output.posted ? (
<div className="surface text-xs text-emerald-200 break-words">Posted: {output.posted}</div>
<div className="surface text-xs text-emerald-200 wrap-break-word">Posted: {output.posted}</div>
) : null}
<div className="grid gap-0.5 md:grid-cols-2">
<div className="space-y-0.5">
@@ -120,7 +120,7 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
</div>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Full Monitor Payload</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(state, null, 2)}
</pre>
</details>
@@ -154,7 +154,7 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
{selectedRun ? (
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Run #{selectedRun.runId} details</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(selectedRun, null, 2)}
</pre>
</details>
@@ -58,49 +58,49 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Latest Context</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{input.stateUpdate || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Exact Model Input (messages[])</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{renderModelMessages()}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Exact System Prompt</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{input.systemPrompt || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Exact Transcript Rows</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(input.transcript || [], null, 2)}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Tool Availability</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify({ available: input.availableTools, blocked: input.blockedTools }, null, 2)}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Exact Model Output</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{output.raw || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Live Tool Calls</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(output.liveToolCalls || [], null, 2)}
</pre>
</details>
@@ -115,7 +115,7 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Recent Runs</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(state.history || [], null, 2)}
</pre>
</details>
@@ -124,7 +124,7 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
{showPayload ? 'Hide Full Payload' : 'Show Full Payload'}
</button>
{showPayload ? (
<pre className="surface whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
<pre className="surface whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
) : null}
</div>
);
+4 -4
View File
@@ -384,7 +384,7 @@ function AlertToastShell({ toast, onDismiss, onPause, onResume }) {
const genericBackgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.5)` : 'rgba(33, 150, 243, 0.18)';
const genericBorderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)` : 'rgba(33, 150, 243, 0.45)';
const shellClass = [
'pointer-events-auto w-fit max-w-[80vw] overflow-hidden rounded-md shadow-sm shadow-black/40',
'pointer-events-auto w-fit max-w-[80vw] overflow-hidden rounded-md shadow-xs shadow-black/40',
generic ? '' : 'border border-white/10 bg-black/75',
].join(' ');
@@ -429,7 +429,7 @@ function AlertToastShell({ toast, onDismiss, onPause, onResume }) {
>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-sm text-xs leading-none text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/10 focus:text-white focus:outline-none focus:ring-1"
className="flex h-4 w-4 items-center justify-center rounded-xs text-xs leading-none text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/10 focus:text-white focus:outline-hidden focus:ring-1"
style={{ '--tw-ring-color': accent }}
onClick={() => onDismiss(toast.key)}
title="Dismiss alert"
@@ -507,7 +507,7 @@ function AlertToast({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload;
return (
<div className="pointer-events-none w-[12.5rem]">
<div className="pointer-events-none w-50">
<ButtonBoxTile
buttonId={payload.buttonId}
count={payload.count}
@@ -549,7 +549,7 @@ function AlertToast({ alert }) {
continuous surface while this renderer stays responsible for one line of
compact title/message text.
*/
className="pointer-events-auto min-w-0 max-w-[24rem] px-1.5 py-0.5 text-left text-[0.72rem] leading-tight text-slate-100"
className="pointer-events-auto min-w-0 max-w-96 px-1.5 py-0.5 text-left text-[0.72rem] leading-tight text-slate-100"
>
<p className="truncate text-slate-100">
<span className="font-semibold text-slate-300">{alert.title || 'Alert'}</span>
@@ -57,7 +57,7 @@ function centerOfPressure(corners) {
function CornerReading({ className, label, value }) {
return (
<div className={`surface absolute min-w-[5.5rem] text-center ${className}`}>
<div className={`surface absolute min-w-22 text-center ${className}`}>
<div className="text-[0.62rem] text-slate-400">{label}</div>
<div className="text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
</div>
@@ -83,7 +83,7 @@ function GameChoice({ game, disabled, onVote }) {
type="button"
disabled={disabled || game.active}
onClick={() => onVote(game.id)}
className="surface flex min-h-[3.5rem] flex-col items-start justify-between border-l-4 border-l-neutral-600 px-1 py-0.75 text-left transition-colors hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-70"
className="surface flex min-h-14 flex-col items-start justify-between border-l-4 border-l-neutral-600 px-1 py-0.75 text-left transition-colors hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-70"
style={style}
>
<span className="flex w-full items-start justify-between gap-1">
@@ -154,7 +154,7 @@ function DisplaySections({ sections }) {
{section.items.slice(0, 8).map((item, itemIndex) => (
<p
key={`${item.text}-${itemIndex}`}
className={`break-words text-xs leading-snug ${
className={`wrap-break-word text-xs leading-snug ${
item.status === 'active'
? 'font-semibold text-neutral-50'
: item.status === 'complete'
@@ -302,14 +302,14 @@ function BarcodeGamesPanelContent() {
fit its fixed timer and participant blocks without crushing the title. */}
<div className="grid items-stretch gap-0.5 @[28rem]:grid-cols-[minmax(0,1fr)_9rem_10rem]">
<div className="surface min-w-0 border-l-4 border-neutral-700 px-1 py-0.75" style={activeTheme.titleBoxStyle}>
<p className="break-words text-xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
<p className="wrap-break-word text-xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
{display.title || activeGame?.title || 'No game active'}
</p>
<p className="mt-0.5 break-words text-sm font-semibold leading-tight text-neutral-200">
<p className="mt-0.5 wrap-break-word text-sm font-semibold leading-tight text-neutral-200">
{display.primary || 'Vote for a game to start'}
</p>
{display.secondary ? (
<p className="mt-0.5 break-words text-xs leading-snug text-neutral-300">{display.secondary}</p>
<p className="mt-0.5 wrap-break-word text-xs leading-snug text-neutral-300">{display.secondary}</p>
) : null}
<DisplaySections sections={display.sections} />
</div>
+2 -2
View File
@@ -96,7 +96,7 @@ function BatteryBar({
}
const barShellClass = classNames(
'relative overflow-hidden rounded-sm border border-slate-800',
'relative overflow-hidden rounded-xs border border-slate-800',
isUrgent || isWarn ? baseToneStrong : 'bg-zinc-900/90',
isUrgent && 'battery-urgent-flash',
isVertical
@@ -132,7 +132,7 @@ function BatteryBar({
isVertical ? (compact ? 'text-[0.55rem]' : 'text-[0.65rem]') : 'text-xs',
);
const labelPillClass = classNames(
'rounded px-1.5 py-0.5',
'rounded-sm px-1.5 py-0.5',
isBackground ? 'bg-black/30 text-slate-100' : 'bg-black/50 text-slate-100',
);
@@ -10,7 +10,7 @@ function formatDegrees(value) {
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">{label}</span>;
return <span className="rounded-sm bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">{label}</span>;
}
export default function CameraTiltControl({
@@ -147,7 +147,7 @@ export default function CameraTiltControl({
<div className={`flex items-center justify-between ${labelRowClass}`.trim()}>
<span className={labelClass}>{label}</span>
{showValue ? (
<span className={`rounded bg-slate-800 px-1 py-0.5 text-[0.7rem] font-semibold ${valueClass}`.trim()}>
<span className={`rounded-sm bg-slate-800 px-1 py-0.5 text-[0.7rem] font-semibold ${valueClass}`.trim()}>
{formatDegrees(value)}
</span>
) : null}
+2 -2
View File
@@ -82,7 +82,7 @@ export default function CardFrame({
// sidebars. Making the card itself a query container lets its contents
// respond to the space they actually receive instead of the viewport,
// which may be wide while a sidebar card is only a few hundred pixels.
'panel-section @container border border-neutral-500/60 bg-neutral-900/100',
'panel-section @container border border-neutral-500/60 bg-neutral-900',
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
fillHeight && 'flex h-full min-h-0 flex-col',
className,
@@ -99,7 +99,7 @@ export default function CardFrame({
// on the shared component gives long cards a consistent title bar
// without changing the layout of existing callers.
stickyHeader && 'sticky top-0 z-10',
// 'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
// 'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-linear-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
headerClassName,
)}
style={headerStyle}
@@ -0,0 +1,46 @@
import { useState } from 'react';
import { find } from 'linkifyjs';
import ReactPlayer from 'react-player';
function ChatEmbed({ url }) {
const [failed, setFailed] = useState(false);
if (failed) return null;
// Only direct image paths are identifiable without fetching page metadata.
const isImage = /\.(avif|gif|jpe?g|png|webp|svg)$/i.test(new URL(url).pathname);
if (isImage) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="block max-w-full">
<img
src={url}
alt="Shared image"
loading="lazy"
className="max-h-60 max-w-full rounded-sm object-contain"
onError={() => setFailed(true)}
/>
</a>
);
}
if (!ReactPlayer.canPlay(url)) return null;
return (
<div className="aspect-video w-full max-w-sm overflow-hidden rounded-sm">
<ReactPlayer
src={url}
controls
playing={false}
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</div>
);
}
export default function ChatEmbeds({ text }) {
const urls = [...new Set(find(String(text || ''), 'url')
.map((link) => link.href)
.filter((url) => /^https?:\/\//i.test(url)))];
return urls.map((url) => <ChatEmbed key={url} url={url} />);
}
+19 -8
View File
@@ -3,8 +3,16 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useState } from 'react';
import { FaDiscord } from 'react-icons/fa';
import Linkify from 'linkify-react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import RoverLabel from '../RoverLabel/index.jsx';
import ChatEmbeds from './ChatEmbeds.jsx';
const LINK_OPTIONS = {
target: '_blank',
rel: 'noopener noreferrer',
className: 'text-sky-300 underline break-all',
};
function roleColors(role) {
switch (role) {
@@ -37,7 +45,7 @@ function displayName(message) {
// height, while the matching calc() size keeps the avatar square after adding that
// top/bottom coverage.
const CHAT_ROW_FULL_HEIGHT_AVATAR_CLASS =
'my-[-0.125rem] flex h-[calc(1rem+0.25rem)] w-[calc(1rem+0.25rem)] shrink-0 overflow-hidden rounded-none';
'-my-0.5 flex h-5 w-5 shrink-0 overflow-hidden rounded-none';
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
if (!guildIconUrl && !userAvatarUrl) return null;
@@ -120,7 +128,7 @@ export function ChatIdentity({ message, toolsToggle = null }) {
/>
) : null}
{isBot ? (
<span className="shrink-0 rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
<span className="shrink-0 rounded-sm bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
bot
</span>
) : null}
@@ -185,7 +193,7 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
hasToolCalls ? (
<button
type="button"
className="shrink-0 rounded border border-slate-600/70 bg-slate-800/60 px-1 py-[1px] text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
className="shrink-0 rounded-sm border border-slate-600/70 bg-slate-800/60 px-1 py-px text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
onClick={() => {
if (isSpectator) return;
setOpen((v) => !v);
@@ -198,10 +206,12 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
<div className={chatRowClass(message, variant)}>
<div className="flex w-full items-start gap-0.5">
<span
className={`min-w-0 flex-1 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
className={`min-w-0 flex-1 wrap-break-word leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
>
<ChatIdentity message={message} toolsToggle={toolsToggle} />
{!isOpen && (hasText || isTyping) ? ` ${rowText}` : ''}
{!isOpen && (hasText || isTyping) ? (
<Linkify options={LINK_OPTIONS}>{` ${rowText}`}</Linkify>
) : null}
</span>
{!isTyping ? (
<span className="shrink-0 text-[0.65rem] text-slate-400/60">
@@ -210,7 +220,7 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
) : null}
</div>
{isOpen && hasToolCalls ? (
<div className="w-full rounded border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
<div className="w-full rounded-sm border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
{toolCalls.map((entry, idx) => {
const status = String(entry?.status || 'unknown');
const statusLabel = status === 'ok' ? 'ok' : status === 'blocked' ? 'blocked' : status === 'error' ? 'error' : 'unknown';
@@ -225,10 +235,11 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
</div>
) : null}
{isOpen && hasText ? (
<div className={`w-full break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
{message.text}
<div className={`w-full wrap-break-word leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
<Linkify options={LINK_OPTIONS}>{message.text}</Linkify>
</div>
) : null}
{!isTyping && hasText ? <ChatEmbeds text={message.text} /> : null}
</div>
);
}
+5 -5
View File
@@ -159,7 +159,7 @@ function TtsControls({
: voice;
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
}}
className={`${compactSelectClass} w-[5.5rem] shrink`}
className={`${compactSelectClass} w-22 shrink`}
>
<option value="flite">flite</option>
<option value="espeak">espeak</option>
@@ -173,7 +173,7 @@ function TtsControls({
const next = event.target.value;
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
}}
className={`${compactSelectClass} w-[3.25rem] shrink`}
className={`${compactSelectClass} w-13 shrink`}
>
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
<option key={v} value={v}>
@@ -189,7 +189,7 @@ function TtsControls({
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), googlePitch: next }));
}}
className={`${compactSelectClass} w-[5rem] shrink`}
className={`${compactSelectClass} w-20 shrink`}
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`pitch-${value}`} value={value}>
@@ -203,7 +203,7 @@ function TtsControls({
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), googleSpeed: next }));
}}
className={`${compactSelectClass} w-[5rem] shrink`}
className={`${compactSelectClass} w-20 shrink`}
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`speed-${value}`} value={value}>
@@ -221,7 +221,7 @@ function TtsControls({
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
}}
className={`${compactSelectClass} w-[4.5rem] shrink`}
className={`${compactSelectClass} w-18 shrink`}
>
{ESPEAK_PITCHES.map((p) => (
<option key={p} value={p}>
@@ -31,7 +31,7 @@ function StatusRow({ value, tone = 'neutral' }) {
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
return <span className="rounded-sm border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
}
function ActionPill({ label, tone }) {
@@ -186,7 +186,7 @@ export default function DriveDockAction({
// tapped repeatedly; attach mobile touch suppression directly to the card so
// Safari does not select labels or open callouts during those interactions.
const baseCardClasses =
'mobile-touch-control flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 select-none no-touch-select';
'mobile-touch-control flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-hidden focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 select-none no-touch-select';
const ctaText = 'text-center';
const ctaLayout = 'items-center justify-between';
const compactLayout = 'items-center justify-center';
@@ -352,9 +352,9 @@ function StepList({ steps, tone = 'emerald' }) {
? 'text-amber-200'
: 'text-indigo-200';
return (
<div className={`space-y-0.5 rounded border px-0.5 py-0.35 text-left ${container}`}>
<div className={`space-y-0.5 rounded-sm border px-0.5 py-0.35 text-left ${container}`}>
{steps.map((step, idx) => (
<div key={step} className="text-[0.85rem] leading-snug break-words">
<div key={step} className="text-[0.85rem] leading-snug wrap-break-word">
<div className="flex items-start">
<span className={`mr-0 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
<span className="align-top">{step}</span>
+2 -2
View File
@@ -23,7 +23,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
<CardFrame hideHeader className="shrink-0">
{/* The legacy page keeps its existing muted 4:3 frame while sharing
the removal-message behavior with the current video surface. */}
<div className="panel-muted aspect-[4/3]">
<div className="panel-muted aspect-4/3">
<EmptyDriverVideoNotice />
</div>
</CardFrame>
@@ -34,7 +34,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
return (
<CardFrame hideHeader className="shrink-0">
<div className="flex flex-col gap-0.5">
<div className="relative w-full overflow-hidden bg-black aspect-[4/3]">
<div className="relative w-full overflow-hidden bg-black aspect-4/3">
<RoverMediaPlayer roverId={roverId} videoMode={videoMode} />
<TurnsOverlay mobileHud={mobileHud} />
<RoverDescriptionOverlay
@@ -16,7 +16,7 @@ export default function DuplicateIdentityOverlay() {
: 'This driver session is already active in another tab.';
return (
<div className="pointer-events-auto fixed inset-0 z-[2000] flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
<div className="pointer-events-auto fixed inset-0 z-2000 flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
<section className="surface w-full max-w-md space-y-0.5 text-center shadow-2xl">
<div className="space-y-0.5">
<h1 className="text-lg font-semibold text-white">Another driver tab is already open</h1>
@@ -24,13 +24,13 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
<p className="text-xs border-t border-b border-slate-700 p-0.5 text-blue-300 text-center">This will only show once just to let you know. There is a fullscreen button in the bottom right for later use.</p>
<div className="flex justify-end gap-0.5 pt-1 text-sm">
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
<button type="button" className="rounded-sm border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
{isIOSMode ? 'Got it' : 'Not now'}
</button>
{!isIOSMode && (
<button
type="button"
className="rounded bg-cyan-500 px-3 py-1 font-semibold text-black hover:bg-cyan-400"
className="rounded-sm bg-cyan-500 px-3 py-1 font-semibold text-black hover:bg-cyan-400"
onClick={onEnterFullscreen}
>
Enter fullscreen
@@ -131,12 +131,12 @@ export default function GPIOToggleControl({
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span className="text-sm font-semibold">{label}</span>
{keyLabel ? (
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
<span className="rounded-sm bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
{keyLabel}
</span>
) : null}
</span>
<span className={`rounded px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
<span className={`rounded-sm px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
{statusLabel}
</span>
</button>
@@ -48,13 +48,13 @@ function actionDriveMode(actionId) {
function CurveField({ label, value, onChange }) {
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
<span className="font-semibold">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="linear">Linear</option>
<option value="expo">Fine center control</option>
@@ -78,7 +78,7 @@ function MappingRow({
// Mapping rows are constrained to a readable width so the source text and buttons remain
// visually connected. Buttons wrap on very narrow panes instead of forcing tiny text.
return (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
<div className="min-w-0">
<div className="flex items-center gap-1">
<p className="font-semibold leading-snug text-white">{action.label}</p>
@@ -471,7 +471,7 @@ export default function GamepadMappingSettings() {
bodyClassName="space-y-2 p-1 text-sm"
>
{captureAction && (
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded-sm bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
<span>Release controls, then move or press the input for {captureAction.label}.</span>
<button type="button" onClick={() => setCaptureAction(null)} className="button-dark px-1 py-0.5 text-xs">
Cancel
@@ -482,7 +482,7 @@ export default function GamepadMappingSettings() {
<div className="space-y-1">
<SettingsGroupLabel>Connected controller</SettingsGroupLabel>
{hubState.error ? (
<p className="mx-auto w-full max-w-lg rounded border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
<p className="mx-auto w-full max-w-lg rounded-sm border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
Controller access failed: {hubState.error}
</p>
) : hubState.pads.length === 0 ? (
@@ -492,11 +492,11 @@ export default function GamepadMappingSettings() {
: 'No controller detected. Connect it, focus this page, then press a button.'}
</p>
) : (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
<select
value={activePad?.instanceKey ?? ''}
onChange={(event) => setActiveInstanceKey(event.target.value)}
className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="min-w-0 rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
{hubState.pads.map((pad) => (
<option key={pad.instanceKey} value={pad.instanceKey}>
@@ -504,7 +504,7 @@ export default function GamepadMappingSettings() {
</option>
))}
</select>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
<span className="rounded-sm bg-neutral-900 px-1 py-0.5 text-xs text-white">
{activePad?.mapping ?? 'unknown'}
</span>
<p className="col-span-full truncate text-xs text-slate-300" title={activePad?.id}>
@@ -519,13 +519,13 @@ export default function GamepadMappingSettings() {
{/* Calibration controls stay in one stacked column because range inputs become harder to
tune when squeezed into multiple narrow columns. */}
<div className="grid gap-1">
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
<span className="font-semibold">Button prompts</span>
<select
value={activeProfile.promptStyle ?? 'auto'}
onChange={(event) => updateProfile({ promptStyle: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="auto">Automatic</option>
<option value="xbox">Xbox</option>
@@ -536,7 +536,7 @@ export default function GamepadMappingSettings() {
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
</label>
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">Steering mode</span>
<select
@@ -545,7 +545,7 @@ export default function GamepadMappingSettings() {
updateCalibration({ driveMode: event.target.value });
setCaptureAction(null);
}}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="single">Single stick</option>
<option value="tank">Tank sticks</option>
@@ -641,7 +641,7 @@ export default function GamepadMappingSettings() {
onChange={(value) => updateCalibration({ precisionSpeed: value })}
/>
{driveMode === 'single' && (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
{/* Camera mode is styled like the sliders so calibration controls read as one group
even though this specific setting is a select instead of a range input. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
@@ -649,7 +649,7 @@ export default function GamepadMappingSettings() {
<select
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="absolute">Absolute</option>
<option value="velocity">Velocity</option>
@@ -714,7 +714,7 @@ export default function GamepadMappingSettings() {
{!activePad ? (
null
) : (
<details className="mx-auto w-full max-w-lg rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
<details className="mx-auto w-full max-w-lg rounded-sm bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
<summary className="cursor-pointer text-sm font-semibold text-white">Advanced diagnostics</summary>
<div className="mt-1 space-y-1">
<p className="text-white">Raw axes</p>
@@ -5,12 +5,12 @@ import { NUMBER_FORMAT } from './constants.js';
export default function SliderField({ label, description, min, max, step, value, onChange }) {
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
{/* The formatted value is kept close to the setting name because calibration usually
happens by nudging a slider and watching the number, not by reading the full panel row. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">{label}</span>
<span className="rounded bg-neutral-900 px-1 py-0.5 font-mono text-xs text-white">
<span className="rounded-sm bg-neutral-900 px-1 py-0.5 font-mono text-xs text-white">
{NUMBER_FORMAT.format(value)}
</span>
</div>
@@ -110,7 +110,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
[
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
!isMobile ? '@container' : '',
isMobile ? 'rounded-none' : 'rounded',
isMobile ? 'rounded-none' : 'rounded-sm',
'px-1 py-1 text-center font-semibold tracking-tight',
className,
]
@@ -163,7 +163,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
className={
isMobile
? 'flex-1 overflow-hidden text-slate-100'
: 'flex-1 whitespace-normal break-words text-slate-100 @sm:overflow-hidden @sm:whitespace-nowrap'
: 'flex-1 whitespace-normal wrap-break-word text-slate-100 @sm:overflow-hidden @sm:whitespace-nowrap'
}
>
<span ref={textRef} className="block">
@@ -8,7 +8,7 @@ import { getHelpContent } from '../../help/content.js';
function KeyPill({ actionId }) {
return (
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
<span className="rounded-sm border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
<ControlHint actionId={actionId} />
</span>
);
@@ -52,7 +52,7 @@ function Hero({ hero, keymap }) {
{hero.chips && (
<div className="flex flex-wrap gap-0.5 text-[0.7rem] text-slate-200">
{hero.chips.map((chip) => (
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
<span key={chip} className="rounded-sm border border-slate-700 px-1 py-[2px]">
{chip}
</span>
))}
@@ -19,7 +19,7 @@ export default function NumberControl({ entity, disabled, onChange }) {
<input type="number" aria-label={entity.name} min={entity.min ?? undefined} max={entity.max ?? undefined} step={entity.step || 'any'}
value={value} disabled={blocked} onChange={(event) => control.edit(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }}
className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
className="w-16 min-w-0 rounded-sm border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
{entity.unit ? <span className="text-[0.65rem] text-slate-400">{entity.unit}</span> : null}
</div>
{!limitsKnown ? <span className="text-xs text-amber-200">Waiting for number limits</span> : null}
@@ -1,6 +1,6 @@
// Preserve raw sensor text and units; a sensor's non-on value is never "off".
export default function ReadOnlyControl({ entity }) {
return <span className="break-words text-xs text-slate-200">
return <span className="wrap-break-word text-xs text-slate-200">
{entity.available ? (entity.password ? '••••••' : `${entity.state}${entity.unit ? ` ${entity.unit}` : ''}`) : 'Unavailable'}
</span>;
}
@@ -5,7 +5,7 @@ export default function SelectControl({ entity, disabled, onChange }) {
return <>
<select aria-label={entity.name} value={entity.state} disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50">
className="w-full min-w-0 rounded-sm border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50">
{!entity.options.includes(entity.state) ? <option value={entity.state} disabled>{entity.state}</option> : null}
{entity.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
@@ -14,6 +14,6 @@ export default function TextControl({ entity, disabled, onChange }) {
onCompositionStart={() => { composing.current = true; control.cancel(); }}
onCompositionEnd={(event) => { composing.current = false; control.edit(event.currentTarget.value); }}
onKeyDown={(event) => { if (event.key === 'Enter' && !composing.current) control.commit(event.currentTarget.value); }}
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
className="w-full min-w-0 rounded-sm border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
</>;
}
@@ -6,7 +6,7 @@ export default function ToggleControl({ entity, disabled, onChange }) {
return <>
<button type="button" aria-label={`Toggle ${entity.name}`} aria-pressed={on}
disabled={disabled} onClick={() => onChange(on ? 'off' : 'on')}
className={`w-full rounded border px-1 py-0.5 text-xs font-semibold disabled:opacity-50 ${on ? 'border-emerald-700/70 bg-emerald-900 text-white' : 'border-neutral-700 bg-neutral-950 text-slate-300'}`}>
className={`w-full rounded-sm border px-1 py-0.5 text-xs font-semibold disabled:opacity-50 ${on ? 'border-emerald-700/70 bg-emerald-900 text-white' : 'border-neutral-700 bg-neutral-950 text-slate-300'}`}>
{on ? 'On' : 'Off'}
</button>
</>;
@@ -26,7 +26,7 @@ function ActivityTile({ entity, connected, allowed, admin }) {
const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6';
const Control = controls[entity.type] || ReadOnlyControl;
const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin);
return <div className="flex min-w-0 flex-col gap-0.5 rounded border px-0.5 py-0.5"
return <div className="flex min-w-0 flex-col gap-0.5 rounded-sm border px-0.5 py-0.5"
style={{ borderColor: entity.locked ? '#b45309' : `${color}aa`, backgroundColor: `${color}33` }}>
<div className="-mx-0.5 -mt-0.5 flex min-h-5 min-w-0 items-center gap-0.5 rounded-t px-0.5 py-0.5 text-white" style={{ backgroundColor: `${color}66` }}>
<Icon className="shrink-0 text-xs" aria-hidden="true" />
@@ -52,7 +52,7 @@ export default function HomeAssistantActivitiesPanel() {
// Reuse the room-control auto-fit grid and compact tile rhythm, while keeping
// all data and commands in the Activities namespace on both device layouts.
return <CardFrame title="Activity Controls" bodyClassName="space-y-0.5 text-sm"
actions={<span className={`rounded px-1 py-0.5 text-xs font-semibold leading-none ${connected ? 'bg-emerald-900 text-emerald-100' : 'bg-amber-900 text-amber-100'}`}>{connected ? 'Connected' : 'Offline'}</span>}>
actions={<span className={`rounded-sm px-1 py-0.5 text-xs font-semibold leading-none ${connected ? 'bg-emerald-900 text-emerald-100' : 'bg-amber-900 text-amber-100'}`}>{connected ? 'Connected' : 'Offline'}</span>}>
{!connected ? <p className="px-0.5 text-xs text-amber-200">{socketConnected ? 'Home Assistant is offline.' : 'Server disconnected.'} Values may be out of date.</p> : null}
{!allowed ? <p className="px-0.5 text-xs text-slate-400">Controls are read-only with your current access.</p> : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(9rem,100%),1fr))] gap-0.5">
@@ -24,7 +24,7 @@ function StatusBadge({ label, tone = 'muted' }) {
? 'bg-amber-900 text-amber-100'
: 'bg-slate-800 text-slate-200';
return (
<span className={`rounded px-1 py-0.5 text-xs font-semibold leading-none ${styles}`}>
<span className={`rounded-sm px-1 py-0.5 text-xs font-semibold leading-none ${styles}`}>
{label}
</span>
);
@@ -82,7 +82,7 @@ function LampSwatch({ entity, swatch, disabled, onSetColor, onSetWhite }) {
className={cx(
// The tile background already shows the Home Assistant-reported color,
// so swatches are plain presets rather than a selected-state indicator.
'h-5 w-6 shrink-0 rounded-sm border border-white/80 transition-transform disabled:cursor-not-allowed disabled:opacity-40',
'h-5 w-6 shrink-0 rounded-xs border border-white/80 transition-transform disabled:cursor-not-allowed disabled:opacity-40',
!disabled && 'hover:scale-110 hover:border-white',
)}
style={{ backgroundColor: swatch.hex }}
@@ -135,7 +135,7 @@ function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onS
// never sprawls wider than three lamps per row. The tile only keeps a
// zero minimum lets a pair of tiles share a narrow desktop card. The
// internal title truncation and wrapping swatches preserve readability.
'flex min-w-0 flex-col gap-0.5 rounded border px-0.5 py-0.5 transition-colors',
'flex min-w-0 flex-col gap-0.5 rounded-sm border px-0.5 py-0.5 transition-colors',
disableToggle ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:border-white/60',
tileTone,
)}
@@ -235,7 +235,7 @@ function HomeAssistantControlsContent() {
return (
<CardFrame title="Room Lights" actions={actions} bodyClassName="space-y-0.5 text-base">
{lightPolicyLocked ? (
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
<p className="rounded-sm border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
{adminCanControlLockedLights
? lockState === 'off'
? 'Lights are locked off. Admin room lights remain available.'
@@ -267,5 +267,5 @@ function HomeAssistantControlsContent() {
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded border border-slate-600 px-1 text-[0.65rem] text-slate-300">{label}</span>;
return <span className="rounded-sm border border-slate-600 px-1 text-[0.65rem] text-slate-300">{label}</span>;
}
+6 -6
View File
@@ -164,7 +164,7 @@ export default function HornControl({
}}
// The settings toggle is a nested press target, so it gets the same touch
// suppression as the larger horn surface instead of relying on inheritance.
className="mobile-touch-control rounded bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white/90 hover:text-white"
className="mobile-touch-control rounded-sm bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white/90 hover:text-white"
>
{showSettings ? 'Hide' : 'Settings'}
</button>
@@ -212,7 +212,7 @@ export default function HornControl({
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span>Horn</span>
{keyLabel ? (
<span className="rounded bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white">
<span className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white">
{keyLabel}
</span>
) : null}
@@ -234,7 +234,7 @@ export default function HornControl({
<select
value={formattedWaveform}
onChange={updateWaveform}
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
className="rounded-sm border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
>
<option value="saw">Saw</option>
<option value="sine">Sine</option>
@@ -252,7 +252,7 @@ export default function HornControl({
value={freq}
data-horn-freq-index={idx}
onChange={(event) => updateFreq(idx, event.target.value)}
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
className="w-full rounded-sm border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
/>
</label>
))}
@@ -268,7 +268,7 @@ export default function HornControl({
<select
value={formattedWaveform}
onChange={updateWaveform}
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
className="rounded-sm border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
>
<option value="saw">Saw</option>
<option value="sine">Sine</option>
@@ -285,7 +285,7 @@ export default function HornControl({
value={freq}
data-horn-freq-index={idx}
onChange={(event) => updateFreq(idx, event.target.value)}
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
className="w-full rounded-sm border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
/>
</label>
))}
@@ -99,10 +99,10 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
]);
const newDrivePresentation = variant === 'newdrive';
const containerClass = newDrivePresentation
? `absolute left-1/2 top-1/2 z-[70] flex w-[min(36rem,82%)] -translate-x-1/2 -translate-y-1/2 items-stretch gap-1 rounded-md border border-neutral-700 bg-neutral-900 p-1 transition-opacity duration-100 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`
? `absolute left-1/2 top-1/2 z-70 flex w-[min(36rem,82%)] -translate-x-1/2 -translate-y-1/2 items-stretch gap-1 rounded-md border border-neutral-700 bg-neutral-900 p-1 transition-opacity duration-100 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`
: compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-36 max-w-[70vw] items-center gap-0.5 rounded-sm bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-48 max-w-[70vw] items-center gap-0.5 rounded-sm bg-black/70 px-0.5 py-0.25';
const isSafari = useMemo(() => detectSafari(), []);
/*
Safari zooms focused inputs below 16px. Keep that workaround Safari-only so
@@ -118,16 +118,16 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
// The current HUD composer is a physically larger instance of the normal
// chat-panel entry field, not a separately styled translucent overlay.
const inputClass = newDrivePresentation
? `field-input h-12 min-w-0 flex-1 px-2 ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`
: `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`;
? `field-input h-12 min-w-0 flex-1 px-2 ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-hidden`
: `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-hidden`;
// The submit button is still a touch target even though the adjacent input must
// remain editable, so it gets press suppression without inheriting input text
// selection behavior.
const buttonClass = newDrivePresentation
? 'button-dark min-h-12 shrink-0 px-4 text-base font-semibold disabled:opacity-50'
: compact
? 'mobile-touch-control rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'mobile-touch-control rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
? 'mobile-touch-control rounded-sm bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'mobile-touch-control rounded-sm bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
async function handleSend(event) {
event.preventDefault();
@@ -227,7 +227,7 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
blurChat();
setTypingActive(false);
}}
className="flex min-h-12 w-10 shrink-0 items-center justify-center rounded border border-neutral-600 bg-neutral-800 text-xl leading-none text-slate-200 hover:bg-neutral-700 hover:text-white"
className="flex min-h-12 w-10 shrink-0 items-center justify-center rounded-sm border border-neutral-600 bg-neutral-800 text-xl leading-none text-slate-200 hover:bg-neutral-700 hover:text-white"
>
×
</button>
@@ -32,7 +32,7 @@ export default function HudMapOverlay({
};
return (
<div className="pointer-events-none absolute rounded" style={mapStyle}>
<div className="pointer-events-none absolute rounded-sm" style={mapStyle}>
<TopDownMap roverId={roverId} sensors={sensors} size={240} overlay />
</div>
);
@@ -37,11 +37,11 @@ export default function SpectatorTelemetryOverlay({ sensors, telemetry = null, m
>
<div className="space-y-0.5 leading-tight">
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
<span className={`rounded-sm px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
<span className={`rounded-sm px-1.5 py-0.5 ${chargingTone}`}>
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
</span>
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
<span className={`rounded-sm px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
</div>
{telemetryEntries.map(([labelText, value]) => (
<span key={labelText} className="flex items-center justify-between gap-0.5">
@@ -38,7 +38,7 @@ function LightBumpBars({ roverId = null, sensors }) {
const fill = `${t * 100}%`;
const color = hueFor(v);
return (
<div key={idx} className="relative flex-1 min-w-[0]" style={{ height: `${barHeight}px` }}>
<div key={idx} className="relative flex-1 min-w-0" style={{ height: `${barHeight}px` }}>
<div className="h-full w-full overflow-hidden bg-slate-800" style={{ borderRadius: 0 }}>
<div
className="h-full"
@@ -51,10 +51,10 @@ function OvercurrentOverlay({ roverId = null, overcurrentLimiter = null, compact
? `${labels.join(', ')} · output ${Math.round(driveCap * 100)}%`
: labels.join(', ');
const containerClass = bypassed
? 'h-[3.5rem] w-[14rem]'
? 'h-14 w-56'
: compact
? 'h-[3.5rem] w-[14rem]'
: 'h-[7rem] w-[22rem]';
? 'h-14 w-56'
: 'h-28 w-88';
const titleClass = compact || bypassed ? 'text-base' : 'text-3xl';
const detailClass = compact || bypassed ? 'text-xs' : 'text-base';
const backgroundClass = bypassed ? 'bg-amber-950/75' : 'bg-red-950/70';
@@ -224,7 +224,7 @@ function TurnsOverlay({
{showTurnCue ? (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
<div
className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${cuePadClass}`}
className={`flex flex-col items-center gap-0.5 rounded-sm border border-amber-300/80 bg-black/70 ${cuePadClass}`}
>
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
@@ -27,7 +27,7 @@ function VerticalBatteryOverlay({ show = false, roverId = null, sensors, battery
if (!show || !batteryVisual?.available) return null;
return (
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded-sm bg-black/60 px-0.5 pb-1 pt-1">
<BatteryBar
visual={batteryVisual}
orientation="vertical"
@@ -27,7 +27,7 @@ export default function AccessoriesExpansion({ roverId }) {
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
onClick={() => setOpen(!open)}
hud
className="!h-28"
className="h-28!"
/>
{open ? (
<RoverAccessoryControls
@@ -69,15 +69,15 @@ export default function BottomRightPod({ roverId }) {
return (
<>
{cameraPodOpen ? (
<div className="pointer-events-auto absolute bottom-0 right-0 z-20 flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-tl-[4.25rem] bg-black/60">
<div className="pointer-events-auto absolute bottom-0 right-0 z-20 flex h-34 w-34 items-center justify-center rounded-tl-[4.25rem] bg-black/60">
{/* The pod shell and its visibility toggle stay usable while waiting, but
every camera mutation is blocked and visibly muted until control returns. */}
<svg viewBox="0 0 144 144" aria-disabled={!canControl} className={`h-[8.5rem] w-[8.5rem] touch-none ${canControl ? '' : 'pointer-events-none opacity-40'}`} onPointerDown={updateFromPointer} onPointerMove={(event) => { if (event.buttons) updateFromPointer(event); }}>
<svg viewBox="0 0 144 144" aria-disabled={!canControl} className={`h-34 w-34 touch-none ${canControl ? '' : 'pointer-events-none opacity-40'}`} onPointerDown={updateFromPointer} onPointerMove={(event) => { if (event.buttons) updateFromPointer(event); }}>
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#064e3b" strokeWidth="12" strokeLinecap="round" />
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#34d399" strokeWidth="12" strokeLinecap="round" strokeDasharray={`${fraction} 1`} />
<circle cx={knob.x} cy={knob.y} r="7" fill="#ecfdf5" stroke="#059669" strokeWidth="3" />
</svg>
<button type="button" aria-label="Reset camera tilt" disabled={!canControl} onClick={() => setServoAngle(0)} className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5 rounded bg-black/55 px-1.5 py-1 font-bold text-white disabled:cursor-not-allowed disabled:opacity-40">
<button type="button" aria-label="Reset camera tilt" disabled={!canControl} onClick={() => setServoAngle(0)} className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5 rounded-sm bg-black/55 px-1.5 py-1 font-bold text-white disabled:cursor-not-allowed disabled:opacity-40">
{/* The icon/value stack mirrors the battery pod and clarifies that this
circular gauge changes the live rover camera's tilt angle. */}
<FaVideo className="text-base" aria-hidden="true" />
@@ -43,7 +43,7 @@ export default function ChatExpansion({ podOpen }) {
aria-label={open ? 'Close chat' : 'Open chat'}
aria-pressed={open}
onClick={toggleChat}
className={`pointer-events-auto absolute z-20 flex h-8 items-center justify-center gap-1 bg-black/60 px-1.5 text-sm text-white transition hover:bg-black/80 ${podOpen ? 'bottom-[8.5rem] right-0 rounded-tl-lg' : 'bottom-0 right-10 rounded-t-lg'}`}
className={`pointer-events-auto absolute z-20 flex h-8 items-center justify-center gap-1 bg-black/60 px-1.5 text-sm text-white transition hover:bg-black/80 ${podOpen ? 'bottom-34 right-0 rounded-tl-lg' : 'bottom-0 right-10 rounded-t-lg'}`}
>
<FaComment aria-hidden="true" />
{/* The pill reflects the live keymap so remapping chat focus updates this
@@ -7,24 +7,24 @@ const CORNERS = {
button: 'left-0 top-0 [clip-path:polygon(0_0,100%_0,0_100%)]',
icon: 'left-1 top-1',
collapseRotation: '-rotate-45',
expandRotation: 'rotate-[135deg]',
expandRotation: 'rotate-135',
},
'top-right': {
button: 'right-0 top-0 [clip-path:polygon(0_0,100%_0,100%_100%)]',
icon: 'right-1 top-1',
collapseRotation: 'rotate-45',
expandRotation: '-rotate-[135deg]',
expandRotation: 'rotate-[-135deg]',
},
'bottom-left': {
button: 'bottom-0 left-0 [clip-path:polygon(0_0,0_100%,100%_100%)]',
icon: 'bottom-1 left-1',
collapseRotation: '-rotate-[135deg]',
collapseRotation: 'rotate-[-135deg]',
expandRotation: 'rotate-45',
},
'bottom-right': {
button: 'bottom-0 right-0 [clip-path:polygon(100%_0,0_100%,100%_100%)]',
icon: 'bottom-1 right-1',
collapseRotation: 'rotate-[135deg]',
collapseRotation: 'rotate-135',
expandRotation: '-rotate-45',
},
};
@@ -56,7 +56,7 @@ export default function HornSettingsExpansion({ open, onOpenChange }) {
<select
value={waveform}
onChange={(event) => save((current) => ({ ...(current || {}), waveform: event.target.value === 'sine' ? 'sine' : 'saw' }))}
className="rounded bg-slate-900 px-2 py-1 text-white ring-1 ring-slate-600"
className="rounded-sm bg-slate-900 px-2 py-1 text-white ring-1 ring-slate-600"
>
<option value="saw">Saw</option>
<option value="sine">Sine</option>
@@ -72,7 +72,7 @@ export default function HornSettingsExpansion({ open, onOpenChange }) {
max={HORN_MAX_FREQUENCY}
value={frequency}
onChange={(event) => updateFrequency(index, event.target.value)}
className="min-w-0 flex-1 rounded bg-slate-900 px-1.5 py-1 text-right font-mono text-white ring-1 ring-slate-600"
className="min-w-0 flex-1 rounded-sm bg-slate-900 px-1.5 py-1 text-right font-mono text-white ring-1 ring-slate-600"
/>
</label>
))}
@@ -105,7 +105,7 @@ export default function TopLeftPod({ roverId }) {
: `${seconds}s`;
return (
<div className={`pointer-events-auto absolute left-0 top-0 flex items-start ${showLargeTimer ? 'z-[100]' : 'z-20'}`}>
<div className={`pointer-events-auto absolute left-0 top-0 flex items-start ${showLargeTimer ? 'z-100' : 'z-20'}`}>
{showTimer ? (
<div
className={`relative flex items-center justify-center transition-[width,height,border-radius,background-color] duration-500 ease-out motion-reduce:transition-none ${
@@ -113,8 +113,8 @@ export default function TopLeftPod({ roverId }) {
// The waiting/handoff state replaces the old full-screen turn cue.
// It must be opaque and above every other in-video HUD surface so
// sensor graphics, chat, and docking controls cannot muddy the text.
? 'h-[25.5rem] w-[25.5rem] rounded-br-[12.75rem] bg-black'
: 'h-[8.5rem] w-[8.5rem] rounded-br-[4.25rem] bg-black/60'
? 'h-102 w-102 rounded-br-[12.75rem] bg-black'
: 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
}`}
>
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
@@ -61,7 +61,7 @@ function WifiTile({ signal, ssid }) {
<div className="mt-1 truncate text-[0.68rem] font-semibold leading-none text-white">Wi-Fi: {ssid || '--'}</div>
<div className="mt-1 flex h-2 items-end gap-0.5" aria-hidden="true">
{[1, 2, 3, 4].map((bar) => (
<span key={bar} className={`flex-1 rounded-sm ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} />
<span key={bar} className={`flex-1 rounded-xs ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} />
))}
</div>
</div>
@@ -128,10 +128,10 @@ export default function TopRightPod({ roverId }) {
<>
<div className="pointer-events-auto absolute right-0 top-0 z-20 flex flex-col items-end">
{batteryOpen ? (
<div className="relative flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-bl-[4.25rem] bg-black/60">
<div className="relative flex h-34 w-34 items-center justify-center rounded-bl-[4.25rem] bg-black/60">
{/* Let the gauge geometry define the visible inset so this pod does not carry an
extra layer of shell padding that the camera pod does not have. */}
<svg className="h-[8.5rem] w-[8.5rem] -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<svg className="h-34 w-34 -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="42" fill="none" stroke="#334155" strokeWidth="9" />
<circle cx="50" cy="50" r="42" fill="none" stroke={batteryTone} strokeWidth="9" strokeLinecap="round" strokeDasharray={batteryDash} />
<circle cx="50" cy="50" r="32" fill="none" stroke="#334155" strokeWidth="5" />
@@ -158,7 +158,7 @@ export default function TopRightPod({ roverId }) {
<ExpansionPanel
open={powerOpen}
onOpenChange={setPowerOpen}
anchorClassName={`absolute right-0 ${batteryOpen ? 'top-[8.5rem]' : 'top-10'}`}
anchorClassName={`absolute right-0 ${batteryOpen ? 'top-34' : 'top-10'}`}
panelAlign="right"
panelClassName="w-56 rounded-bl-xl bg-black/60 p-1.5 pt-4 text-white"
openDirection="left"
@@ -185,7 +185,7 @@ export default function TopRightPod({ roverId }) {
animation; severity comes from its size and solid color rather than visual noise. */}
{lowBattery && !docked ? (
<div
className={`pointer-events-none absolute left-1/2 top-[58%] z-[55] flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 whitespace-nowrap px-4 py-2 font-bold text-white shadow-xl ${
className={`pointer-events-none absolute left-1/2 top-[58%] z-55 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 whitespace-nowrap px-4 py-2 font-bold text-white shadow-xl ${
urgentBattery ? 'bg-red-950 text-xl' : 'bg-amber-950 text-base'
}`}
role="alert"
@@ -38,14 +38,14 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
{/* The docked shield is owned by the dismissible action so hiding the
prompt also reveals the video and ordinary HUD instead of leaving an
unexplained dark, input-blocking layer behind. */}
<div className="pointer-events-auto absolute inset-0 z-[25] bg-black/75" aria-hidden="true" />
<div className="pointer-events-auto absolute inset-0 z-25 bg-black/75" aria-hidden="true" />
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
<div className="relative w-[min(32rem,80%)]">
<button
type="button"
disabled={pending || controlsDisabled}
onClick={onUndock}
className={`pointer-events-auto flex w-full flex-col items-center gap-2 px-8 py-7 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${mainToneClass}`}
className={`pointer-events-auto flex w-full flex-col items-center gap-2 px-8 py-7 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${mainToneClass}`}
>
<strong className="text-3xl leading-tight">{pending ? 'Undocking…' : 'Your rover is docked'}</strong>
{pending ? (
@@ -73,7 +73,7 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
<button
type="button"
onClick={() => setHidden(true)}
className={`pointer-events-auto absolute left-1/2 top-full -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${hideToneClass}`}
className={`pointer-events-auto absolute left-1/2 top-full -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${hideToneClass}`}
>
Hide
</button>
@@ -93,7 +93,7 @@ function AutoDockingAction({ driveKeyLabel, pending, controlsDisabled, error, on
type="button"
disabled={pending || controlsDisabled}
onClick={onResumeDriving}
className={`pointer-events-auto flex w-[min(30rem,80%)] flex-col items-center gap-2 px-7 py-6 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${
className={`pointer-events-auto flex w-[min(30rem,80%)] flex-col items-center gap-2 px-7 py-6 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${
waitingForTurn
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
: 'bg-amber-950/90 ring-amber-300/80 hover:bg-amber-900/95 focus-visible:ring-amber-200 disabled:cursor-wait disabled:opacity-75'
@@ -134,17 +134,17 @@ function DockAssistAction({ active, pending, controlsDisabled, error, dockKeyLab
const dockPositionClass = cornerOffsetClass === 'right-0' ? 'right-10' : cornerOffsetClass;
if (active) {
return (
<div className="pointer-events-none absolute inset-0 z-[60] flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 z-60 flex items-center justify-center">
{/* Once assist is active, the camera image is the user's task context. Centering this
one-line instruction connects it to that view instead of leaving guidance beside the
corner button that already completed its action. */}
<div className="pointer-events-auto flex items-center gap-2 rounded bg-cyan-950/60 p-1.5 text-cyan-50 shadow-xl ring-2 ring-cyan-200/90">
<div className="pointer-events-auto flex items-center gap-2 rounded-sm bg-cyan-950/60 p-1.5 text-cyan-50 shadow-xl ring-2 ring-cyan-200/90">
<strong className="whitespace-nowrap text-sm">Dock assist is active, drive forward onto the dock.</strong>
<button
type="button"
onClick={onCancel}
disabled={controlsDisabled}
className="bg-slate-800 px-2 py-1 text-xs font-bold text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 disabled:cursor-not-allowed disabled:opacity-40"
className="bg-slate-800 px-2 py-1 text-xs font-bold text-white transition hover:bg-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-cyan-200 disabled:cursor-not-allowed disabled:opacity-40"
>
Cancel
</button>
@@ -172,7 +172,7 @@ function DockAssistAction({ active, pending, controlsDisabled, error, dockKeyLab
aria-label="Start rover docking assist"
disabled={pending || controlsDisabled}
onClick={onDock}
className={`flex items-center gap-1.5 rounded-bl-xl px-4 pb-2 pt-4 text-base font-bold shadow-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 disabled:cursor-wait disabled:opacity-75 ${
className={`flex items-center gap-1.5 rounded-bl-xl px-4 pb-2 pt-4 text-base font-bold shadow-xl ring-1 transition focus-visible:outline-hidden focus-visible:ring-2 disabled:cursor-wait disabled:opacity-75 ${
batteryUrgent
? 'bg-red-950 text-red-50 ring-red-300/80 hover:bg-red-900 focus-visible:ring-red-200'
: batteryLow
@@ -286,7 +286,7 @@ export default function DockingHud({ roverId }) {
const batteryPodOpen = podSettings?.battery !== false;
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
const cornerOffsetClass = batteryPodOpen ? 'right-[8.5rem]' : 'right-0';
const cornerOffsetClass = batteryPodOpen ? 'right-34' : 'right-0';
const previousDockedRef = useRef(docked);
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
@@ -344,7 +344,7 @@ export default function DockingHud({ roverId }) {
docked shield lives inside DockedAction because the new Hide control
must dismiss the prompt and its dimming as one coherent surface. */}
<div
className={`absolute inset-0 z-[25] transition-all duration-300 ${
className={`absolute inset-0 z-25 transition-all duration-300 ${
autoDocking || pendingAction === 'resuming'
? 'pointer-events-auto bg-black/55 opacity-100'
: 'pointer-events-none opacity-0'
@@ -13,7 +13,7 @@ export default function InitialSessionOverlay() {
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black text-slate-200"
className="fixed inset-0 z-1000 flex items-center justify-center bg-black text-slate-200"
role="status"
aria-live="polite"
aria-label={connected ? 'Loading session' : 'Connecting'}
@@ -57,7 +57,7 @@ function InstanceStatus({ remote }) {
const availability = getRemoteAvailability(remote);
return (
<div className="flex shrink-0 flex-wrap items-center gap-0.5 text-[0.7rem]">
<span className={classNames('rounded px-1.5 py-0.5 text-xs font-semibold', statusClass(availability.tone))}>
<span className={classNames('rounded-sm px-1.5 py-0.5 text-xs font-semibold', statusClass(availability.tone))}>
{availability.label}
</span>
</div>
@@ -108,7 +108,7 @@ function InstancePanel({ remote, children = null }) {
{online && features.length ? (
<div className="flex flex-wrap justify-center gap-0.5">
{features.map((feature) => (
<span key={feature} className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
<span key={feature} className="rounded-sm bg-slate-800 px-1 text-[0.7rem] text-slate-200">
{feature}
</span>
))}
@@ -185,11 +185,11 @@ export function ExternalInstancesCompact({ onBrowse = null }) {
export function InterInstancePopup({ onClose }) {
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5">
<div className="fixed inset-0 z-70 flex items-center justify-center bg-black/80 p-0.5">
<InterInstanceBrowserFrame
onClose={onClose}
scaledOverlay
className="inter-instance-overlay-frame w-fit [container-type:normal]"
className="inter-instance-overlay-frame w-fit @container-normal"
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
/>
</div>
@@ -127,7 +127,7 @@ function SettingsGroupLabel({ children }) {
function SpeedField({ label, description, value, onChange, min = 0, max = 500, step = 5 }) {
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
{/* The value field sits beside the label because users tune these settings by comparing
the name and the numeric value together. Keeping them in one row prevents the number
input from drifting across a wide settings panel. */}
@@ -139,7 +139,7 @@ function SpeedField({ label, description, value, onChange, min = 0, max = 500, s
max={max}
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-right text-xs font-mono text-white"
className="w-full rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-right text-xs font-mono text-white"
/>
</div>
{description && <p className="mt-0.5 text-xs leading-snug text-white">{description}</p>}
@@ -164,7 +164,7 @@ function BindingRow({ action, value, isActive, onCaptureStart, onCancel, onCaptu
// change button are one interaction unit, so proximity matters more than stretching to fill
// every available pixel in the surrounding sidebar.
return (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
<div className="min-w-0">
<p className="font-semibold leading-snug text-white">{action.label}</p>
<p className="mt-0.5 text-xs leading-snug text-white">{formatKeyLabel(value)}</p>

Some files were not shown because too many files have changed in this diff Show More