Domotick commited on
Commit
db44f58
·
1 Parent(s): 8b37198

- The logo comes alive: it winks, blinks, looks around, dozes off, squints and even puts on glasses
- The winking head becomes the logo everywhere, at every size, with an HD version for releases
- Community apps get real store cards: emoji, description, tags and thumbnail
- Update settings fit on one line, with a clear green 'Everything is up to date'
- The mic waveform moves right on page load and stays flat in a silent room
- The dashboard remembers your robot's address and reconnects faster

Source: d2ec4fb35b96f37b4adf7b89702a49857a877201
Auto-sync: reachy_web

VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.0
 
1
+ 0.7.0
changelog.json CHANGED
@@ -1,4 +1,74 @@
1
  [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  {
3
  "version": "0.6.0",
4
  "date": "2026-07-16",
 
1
  [
2
+ {
3
+ "version": "0.7.0",
4
+ "date": "2026-07-17",
5
+ "changes": {
6
+ "en": [
7
+ "The logo comes alive: it winks, blinks, looks around, dozes off, squints and even puts on glasses",
8
+ "The winking head becomes the logo everywhere, at every size, with an HD version for releases",
9
+ "Community apps get real store cards: emoji, description, tags and thumbnail",
10
+ "Update settings fit on one line, with a clear green 'Everything is up to date'",
11
+ "The mic waveform moves right on page load and stays flat in a silent room",
12
+ "The dashboard remembers your robot's address and reconnects faster"
13
+ ],
14
+ "fr": [
15
+ "Le logo prend vie : clin d'œil, clignements, regards, somnolence, il louche et met même ses lunettes",
16
+ "La tête qui cligne devient le logo partout, à toutes les tailles, avec une version HD pour les releases",
17
+ "Les apps communautaires ont de vraies fiches : emoji, description, tags et vignette",
18
+ "Les réglages de mise à jour tiennent sur une ligne, avec un vrai 'Tout est à jour' en vert",
19
+ "L'onde du micro bouge dès le chargement de la page et reste plate dans une pièce silencieuse",
20
+ "Le dashboard retient l'adresse du robot et se reconnecte plus vite"
21
+ ],
22
+ "es": [
23
+ "El logo cobra vida: guiña, parpadea, mira alrededor, se adormece, bizquea y hasta se pone gafas",
24
+ "La cabeza que guiña es ahora el logo en todas partes y en todos los tamaños, con versión HD para releases",
25
+ "Las apps de la comunidad tienen fichas completas: emoji, descripción, etiquetas y miniatura",
26
+ "Los ajustes de actualización caben en una línea, con un claro 'Todo está actualizado' en verde",
27
+ "La onda del micrófono se mueve al cargar la página y queda plana en una sala en silencio",
28
+ "El panel recuerda la dirección del robot y se reconecta más rápido"
29
+ ],
30
+ "de": [
31
+ "Das Logo wird lebendig: es zwinkert, blinzelt, schaut sich um, döst ein, schielt und setzt sogar eine Brille auf",
32
+ "Der zwinkernde Kopf ist jetzt überall das Logo, in jeder Größe, mit einer HD-Version für Releases",
33
+ "Community-Apps bekommen echte Karten: Emoji, Beschreibung, Tags und Vorschaubild",
34
+ "Die Update-Einstellungen passen in eine Zeile, mit einem klaren grünen 'Alles ist aktuell'",
35
+ "Die Mikrofon-Welle bewegt sich direkt beim Laden und bleibt in einem stillen Raum flach",
36
+ "Das Dashboard merkt sich die Adresse des Roboters und verbindet sich schneller"
37
+ ],
38
+ "it": [
39
+ "Il logo prende vita: strizza l'occhio, sbatte le palpebre, si guarda intorno, si assopisce, incrocia gli occhi e mette persino gli occhiali",
40
+ "La testa che strizza l'occhio è ora il logo ovunque, in ogni dimensione, con una versione HD per le release",
41
+ "Le app della community hanno schede complete: emoji, descrizione, tag e miniatura",
42
+ "Le impostazioni di aggiornamento stanno su una riga, con un chiaro 'Tutto è aggiornato' in verde",
43
+ "L'onda del microfono si muove già al caricamento e resta piatta in una stanza silenziosa",
44
+ "La dashboard ricorda l'indirizzo del robot e si riconnette più in fretta"
45
+ ],
46
+ "pt": [
47
+ "O logo ganha vida: pisca o olho, pestaneja, olha em volta, cochila, fica vesgo e até põe óculos",
48
+ "A cabeça que pisca é agora o logo em todo lado e em todos os tamanhos, com versão HD para releases",
49
+ "As apps da comunidade têm fichas completas: emoji, descrição, tags e miniatura",
50
+ "As definições de atualização cabem numa linha, com um claro 'Tudo está atualizado' em verde",
51
+ "A onda do microfone mexe logo ao carregar a página e fica plana numa sala em silêncio",
52
+ "O painel lembra o endereço do robô e reconecta-se mais depressa"
53
+ ],
54
+ "ja": [
55
+ "ロゴが生きています。ウインク、まばたき、よそ見、居眠り、寄り目、メガネまでかけます",
56
+ "ウインクする顔があらゆる場所・あらゆるサイズでロゴになり、リリース用の HD 版も用意",
57
+ "コミュニティアプリに絵文字・説明・タグ・サムネイル付きの完全なカード",
58
+ "アップデート設定を 1 行に整理し、緑の「すべて最新です」を表示",
59
+ "マイク波形はページ読み込み直後から動き、静かな部屋では平らなまま",
60
+ "ダッシュボードがロボットのアドレスを記憶し、再接続が速くなりました"
61
+ ],
62
+ "ar": [
63
+ "الشعار ينبض بالحياة: يغمز ويرمش وينظر حوله ويغفو ويحوّل عينيه بل ويرتدي نظارة",
64
+ "الرأس الغامز أصبح الشعار في كل مكان وبكل الأحجام، مع نسخة HD للإصدارات",
65
+ "تطبيقات المجتمع صارت لها بطاقات كاملة: إيموجي ووصف ووسوم وصورة مصغّرة",
66
+ "إعدادات التحديث في سطر واحد، مع عبارة خضراء واضحة: كل شيء محدّث",
67
+ "موجة الميكروفون تتحرك فور تحميل الصفحة وتبقى مستقيمة في غرفة هادئة",
68
+ "لوحة التحكم تتذكر عنوان الروبوت وتعيد الاتصال أسرع"
69
+ ]
70
+ }
71
+ },
72
  {
73
  "version": "0.6.0",
74
  "date": "2026-07-16",
index.html CHANGED
@@ -17,13 +17,13 @@
17
  <meta property="og:title" content="Reachy Web: the dashboard is back">
18
  <meta property="og:description" content="Control your Reachy Mini from any browser again. The removed web dashboard, back as an installable app.">
19
  <meta property="og:url" content="https://domotick-reachy-web.static.hf.space/">
20
- <meta property="og:image" content="https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png">
21
  <meta property="og:image:width" content="512">
22
  <meta property="og:image:height" content="512">
23
  <meta name="twitter:card" content="summary">
24
  <meta name="twitter:title" content="Reachy Web: the dashboard is back">
25
  <meta name="twitter:description" content="Control your Reachy Mini from any browser again. The removed web dashboard, back as an installable app.">
26
- <meta name="twitter:image" content="https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png">
27
  <script type="application/ld+json">
28
  {
29
  "@context": "https://schema.org",
@@ -33,7 +33,7 @@
33
  "operatingSystem": "Any (web browser)",
34
  "description": "The Reachy Mini web dashboard, back as an installable app. Control your Reachy Mini Lite or Wireless from any browser: live camera, 3D digital twin, moves, app store, volume, no desktop app needed.",
35
  "url": "https://domotick-reachy-web.static.hf.space/",
36
- "image": "https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png",
37
  "author": { "@type": "Organization", "name": "Domotick", "url": "https://huggingface.co/Domotick" },
38
  "keywords": "Reachy Mini, Reachy Mini Lite, Reachy Mini Wireless, dashboard, web dashboard, 3D digital twin, Reachy Mirror, Reachy Eyes, Domotick, Pollen Robotics",
39
  "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
@@ -49,7 +49,7 @@
49
  <div class="flex items-center justify-center gap-2.5 whitespace-nowrap font-display text-lg font-semibold md:justify-start">
50
  <img src="reachy_web/static/assets/logo.png" alt="Reachy Web logo" class="h-9 w-9 rounded-xl">
51
  <span>Reachy Web</span>
52
- <span class="version-pill" id="version-pill">v0.6.0</span>
53
  </div>
54
  <div class="flex flex-wrap items-center justify-center gap-3.5 md:justify-end">
55
  <div class="flex flex-wrap items-center gap-1.5" id="palettes" title="Palette"></div>
 
17
  <meta property="og:title" content="Reachy Web: the dashboard is back">
18
  <meta property="og:description" content="Control your Reachy Mini from any browser again. The removed web dashboard, back as an installable app.">
19
  <meta property="og:url" content="https://domotick-reachy-web.static.hf.space/">
20
+ <meta property="og:image" content="https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=2">
21
  <meta property="og:image:width" content="512">
22
  <meta property="og:image:height" content="512">
23
  <meta name="twitter:card" content="summary">
24
  <meta name="twitter:title" content="Reachy Web: the dashboard is back">
25
  <meta name="twitter:description" content="Control your Reachy Mini from any browser again. The removed web dashboard, back as an installable app.">
26
+ <meta name="twitter:image" content="https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=2">
27
  <script type="application/ld+json">
28
  {
29
  "@context": "https://schema.org",
 
33
  "operatingSystem": "Any (web browser)",
34
  "description": "The Reachy Mini web dashboard, back as an installable app. Control your Reachy Mini Lite or Wireless from any browser: live camera, 3D digital twin, moves, app store, volume, no desktop app needed.",
35
  "url": "https://domotick-reachy-web.static.hf.space/",
36
+ "image": "https://domotick-reachy-web.static.hf.space/reachy_web/static/assets/og.png?v=2",
37
  "author": { "@type": "Organization", "name": "Domotick", "url": "https://huggingface.co/Domotick" },
38
  "keywords": "Reachy Mini, Reachy Mini Lite, Reachy Mini Wireless, dashboard, web dashboard, 3D digital twin, Reachy Mirror, Reachy Eyes, Domotick, Pollen Robotics",
39
  "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
 
49
  <div class="flex items-center justify-center gap-2.5 whitespace-nowrap font-display text-lg font-semibold md:justify-start">
50
  <img src="reachy_web/static/assets/logo.png" alt="Reachy Web logo" class="h-9 w-9 rounded-xl">
51
  <span>Reachy Web</span>
52
+ <span class="version-pill" id="version-pill">v0.7.0</span>
53
  </div>
54
  <div class="flex flex-wrap items-center justify-center gap-3.5 md:justify-end">
55
  <div class="flex flex-wrap items-center gap-1.5" id="palettes" title="Palette"></div>
pyproject.toml CHANGED
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
 
6
  [project]
7
  name = "reachy_web"
8
- version = "0.6.0"
9
  description = "The web dashboard for your Reachy Mini, in any browser"
10
  readme = "README.md"
11
  requires-python = ">=3.10"
 
5
 
6
  [project]
7
  name = "reachy_web"
8
+ version = "0.7.0"
9
  description = "The web dashboard for your Reachy Mini, in any browser"
10
  readme = "README.md"
11
  requires-python = ">=3.10"
reachy_web/static/assets/favicon-180.png CHANGED

Git LFS Details

  • SHA256: 96653fd16c8b650e977a4d1a161d94be6ae9f76e36aa9091b07f3386205c05b8
  • Pointer size: 129 Bytes
  • Size of remote file: 6.5 kB

Git LFS Details

  • SHA256: 32daa662c17cfc4093f53d3704e7a3f5d3ae1644c0fc8551e6a268bd237070c6
  • Pointer size: 129 Bytes
  • Size of remote file: 6.26 kB
reachy_web/static/assets/favicon-64.png CHANGED

Git LFS Details

  • SHA256: 373aa6d46b096a388029ff931a385411c9cfc3ec85e021f751c50e5bd324544f
  • Pointer size: 129 Bytes
  • Size of remote file: 2.55 kB

Git LFS Details

  • SHA256: 87d868eec58109c6e54de791bff80c30441f7d6107291ecfad11f314f45f56d3
  • Pointer size: 129 Bytes
  • Size of remote file: 2.41 kB
reachy_web/static/assets/favicon.svg CHANGED
reachy_web/static/assets/logo-192.png CHANGED

Git LFS Details

  • SHA256: 443975570ce06c4617252fb62a155297562709b8c7a49581f1d4d1ab512aa856
  • Pointer size: 129 Bytes
  • Size of remote file: 7.27 kB

Git LFS Details

  • SHA256: 02bce6ec4932a5f331c947a02919a27287102c4ba048a501678d6de66f872cc2
  • Pointer size: 129 Bytes
  • Size of remote file: 7 kB
reachy_web/static/assets/logo-2048.png CHANGED

Git LFS Details

  • SHA256: 32cd0a4b79289383c4808342b5c5b1962faef69c381139bac44c780525ac21d6
  • Pointer size: 131 Bytes
  • Size of remote file: 106 kB

Git LFS Details

  • SHA256: 09e30e132fcf00403598000f804790e28f7016ba1a544a2fa7ab6d58b13f3c07
  • Pointer size: 130 Bytes
  • Size of remote file: 96.1 kB
reachy_web/static/assets/logo-512.png CHANGED

Git LFS Details

  • SHA256: cbfa271ca0c0bebeba711e69a90867961882857ee49970302c312ad07eb860ac
  • Pointer size: 130 Bytes
  • Size of remote file: 20 kB

Git LFS Details

  • SHA256: 08883bffe056c46b12c598b78f6e4f6b5596d30e57b38856572e76ffd54c977d
  • Pointer size: 130 Bytes
  • Size of remote file: 19.7 kB
reachy_web/static/assets/logo-64.png CHANGED

Git LFS Details

  • SHA256: 373aa6d46b096a388029ff931a385411c9cfc3ec85e021f751c50e5bd324544f
  • Pointer size: 129 Bytes
  • Size of remote file: 2.55 kB

Git LFS Details

  • SHA256: 87d868eec58109c6e54de791bff80c30441f7d6107291ecfad11f314f45f56d3
  • Pointer size: 129 Bytes
  • Size of remote file: 2.41 kB
reachy_web/static/assets/logo.png CHANGED

Git LFS Details

  • SHA256: 8d38f7a3379823e3d19b380d87b1cd7d5efc5994c1cfc448d5f32829137fb56f
  • Pointer size: 130 Bytes
  • Size of remote file: 47.8 kB

Git LFS Details

  • SHA256: 6164ef41ee67a19fccbe1eafb1f2822053509142bf356977da5163e0008bfa8d
  • Pointer size: 130 Bytes
  • Size of remote file: 41.9 kB
reachy_web/static/assets/logo.svg CHANGED
reachy_web/static/assets/og.png CHANGED

Git LFS Details

  • SHA256: cbfa271ca0c0bebeba711e69a90867961882857ee49970302c312ad07eb860ac
  • Pointer size: 130 Bytes
  • Size of remote file: 20 kB

Git LFS Details

  • SHA256: 08883bffe056c46b12c598b78f6e4f6b5596d30e57b38856572e76ffd54c977d
  • Pointer size: 130 Bytes
  • Size of remote file: 19.7 kB
reachy_web/static/css/app.css CHANGED
@@ -198,6 +198,9 @@
198
  .pointer-events-none {
199
  pointer-events: none;
200
  }
 
 
 
201
  .visible {
202
  visibility: visible;
203
  }
@@ -564,8 +567,8 @@
564
  .gap-y-4 {
565
  row-gap: calc(var(--spacing) * 4);
566
  }
567
- .justify-self-start {
568
- justify-self: flex-start;
569
  }
570
  .truncate {
571
  overflow: hidden;
@@ -852,6 +855,9 @@
852
  .text-left {
853
  text-align: left;
854
  }
 
 
 
855
  .font-mono {
856
  font-family: var(--font-mono);
857
  }
@@ -1695,6 +1701,9 @@
1695
  :root[data-palette][data-theme=light] body {
1696
  background: radial-gradient(1200px 600px at 50% -10%, #fff, var(--bg)) fixed;
1697
  }
 
 
 
1698
  html {
1699
  -webkit-font-smoothing: antialiased;
1700
  -moz-osx-font-smoothing: grayscale;
@@ -1744,10 +1753,11 @@ body {
1744
  flex-wrap: wrap;
1745
  }
1746
  .palettes {
1747
- display: flex;
 
 
1748
  align-items: center;
1749
  gap: 6px;
1750
- flex-wrap: wrap;
1751
  }
1752
  .pdot {
1753
  width: 16px;
 
198
  .pointer-events-none {
199
  pointer-events: none;
200
  }
201
+ .invisible {
202
+ visibility: hidden;
203
+ }
204
  .visible {
205
  visibility: visible;
206
  }
 
567
  .gap-y-4 {
568
  row-gap: calc(var(--spacing) * 4);
569
  }
570
+ .justify-self-end {
571
+ justify-self: flex-end;
572
  }
573
  .truncate {
574
  overflow: hidden;
 
855
  .text-left {
856
  text-align: left;
857
  }
858
+ .text-right {
859
+ text-align: right;
860
+ }
861
  .font-mono {
862
  font-family: var(--font-mono);
863
  }
 
1701
  :root[data-palette][data-theme=light] body {
1702
  background: radial-gradient(1200px 600px at 50% -10%, #fff, var(--bg)) fixed;
1703
  }
1704
+ .hidden {
1705
+ display: none !important;
1706
+ }
1707
  html {
1708
  -webkit-font-smoothing: antialiased;
1709
  -moz-osx-font-smoothing: grayscale;
 
1753
  flex-wrap: wrap;
1754
  }
1755
  .palettes {
1756
+ display: grid;
1757
+ grid-template-columns: repeat(7, auto);
1758
+ justify-items: center;
1759
  align-items: center;
1760
  gap: 6px;
 
1761
  }
1762
  .pdot {
1763
  width: 16px;
reachy_web/static/css/logo.css ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Topbar logo animations.
2
+ The SVG is INLINE in base.html (not an <img>: that runs in the browser's secure
3
+ static mode, no scripts, so its timing could only ever be a fixed CSS loop).
4
+ logo_anim.js adds one v-* class at a time, waits out its duration, removes it —
5
+ so every keyframe set here runs ONCE and lands back on the resting pose.
6
+ Y axis only on the eyes (never X); each antenna pivots where it leaves the head. */
7
+
8
+ .lg-ant-l { transform-box: fill-box; transform-origin: 100% 100%; }
9
+ .lg-ant-r { transform-box: fill-box; transform-origin: 0% 100%; }
10
+ .lg-eye, .lg-wink { transform-box: fill-box; transform-origin: center; }
11
+ .lg-wink { opacity: 0; } /* the wink curve only shows while an eye is shut */
12
+
13
+ /* ===== the antennas never go still =====
14
+ A permanent, tiny breath on the PARENT group of each antenna. It has to be the
15
+ parent: a move (lg-ant-*) animates `transform` too, and two animations on one
16
+ element fight — nested, the two rotations simply compose. Both pivot on the
17
+ same point (where the antenna leaves the head), and their periods share no
18
+ factor (7.3s / 11.9s), so the pair never falls into a metronome beat. */
19
+ .lg-sway-l {
20
+ transform-box: fill-box; transform-origin: 100% 100%;
21
+ animation: lgSwayL 7.3s ease-in-out infinite;
22
+ }
23
+ .lg-sway-r {
24
+ transform-box: fill-box; transform-origin: 0% 100%;
25
+ animation: lgSwayR 11.9s ease-in-out infinite;
26
+ }
27
+ @keyframes lgSwayL { 0%, 100% { transform: rotate(-5deg) } 50% { transform: rotate(5deg) } }
28
+ @keyframes lgSwayR { 0%, 100% { transform: rotate(4deg) } 50% { transform: rotate(-4deg) } }
29
+
30
+ /* ================= 1. WINK — the signature (1.1s) ================= */
31
+ .v-wink .lg-eye-r { animation: lgWinkEye 1.1s ease-in-out 1 both; }
32
+ .v-wink .lg-wink { animation: lgWinkCurve 1.1s ease-in-out 1 both; }
33
+ .v-wink .lg-ant-r { animation: lgWinkAnt 1.1s ease-in-out 1 both; }
34
+ @keyframes lgWinkEye {
35
+ 0% { transform: scaleY(1); opacity: 1 }
36
+ 9% { transform: scaleY(.12); opacity: 0 } /* snaps shut, 100ms */
37
+ 64% { transform: scaleY(.12); opacity: 0 } /* held 600ms as the curve */
38
+ 77% { transform: scaleY(1.25); opacity: 1 } /* springs past rest */
39
+ 86% { transform: scaleY(.8); opacity: 1 } /* bounces under it */
40
+ 100% { transform: scaleY(1); opacity: 1 } /* settles */
41
+ }
42
+ @keyframes lgWinkCurve { 0%, 8% { opacity: 0 } 9%, 64% { opacity: 1 } 70%, 100% { opacity: 0 } }
43
+ @keyframes lgWinkAnt { 0% { transform: rotate(0) } 20% { transform: rotate(-6deg) } 60% { transform: rotate(3deg) } 100% { transform: rotate(0) } }
44
+
45
+ /* ================= 2. LOOK — follows someone across the room (2.6s) ================= */
46
+ .v-look .lg-eye-l, .v-look .lg-eye-r { animation: lgLookEye 2.6s ease-in-out 1 both; }
47
+ .v-look .lg-ant-l { animation: lgLookAntL 2.6s ease-in-out 1 both; }
48
+ .v-look .lg-ant-r { animation: lgLookAntR 2.6s ease-in-out 1 both; }
49
+ @keyframes lgLookEye {
50
+ 0%, 8% { transform: translateX(0) }
51
+ 24%, 38% { transform: translateX(3.2px) }
52
+ 56%, 72% { transform: translateX(-3.2px) }
53
+ 92%, 100% { transform: translateX(0) }
54
+ }
55
+ @keyframes lgLookAntL { 0%, 20% { transform: rotate(0) } 40% { transform: rotate(-5deg) } 70% { transform: rotate(2deg) } 100% { transform: rotate(0) } }
56
+ @keyframes lgLookAntR { 0%, 30% { transform: rotate(0) } 55% { transform: rotate(5deg) } 80% { transform: rotate(-2deg) } 100% { transform: rotate(0) } }
57
+
58
+ /* ================= 3. DOUBLE BLINK — both eyes, twice (0.9s) ================= */
59
+ .v-blink .lg-eye-l, .v-blink .lg-eye-r { animation: lgBlinkEye .9s ease-in-out 1 both; }
60
+ .v-blink .lg-ant-l { animation: lgBlinkAnt .9s ease-in-out 1 both; }
61
+ @keyframes lgBlinkEye {
62
+ 0% { transform: scaleY(1) }
63
+ 12% { transform: scaleY(.08) }
64
+ 24% { transform: scaleY(1) }
65
+ 40% { transform: scaleY(.08) }
66
+ 55% { transform: scaleY(1.15) }
67
+ 72%, 100% { transform: scaleY(1) }
68
+ }
69
+ @keyframes lgBlinkAnt { 0% { transform: rotate(0) } 30% { transform: rotate(-4deg) } 60% { transform: rotate(3deg) } 100% { transform: rotate(0) } }
70
+
71
+ /* ================= 5. SLEEPY — lids droop, then a startle (2.4s) ================= */
72
+ .v-sleepy .lg-eye-l, .v-sleepy .lg-eye-r { animation: lgSleepEye 2.4s ease-in 1 both; }
73
+ .v-sleepy .lg-ant-l { animation: lgSleepAntL 2.4s ease-in-out 1 both; }
74
+ .v-sleepy .lg-ant-r { animation: lgSleepAntR 2.4s ease-in-out 1 both; }
75
+ @keyframes lgSleepEye {
76
+ 0% { transform: scaleY(1) }
77
+ 45% { transform: scaleY(.45) } /* the lids slide down… */
78
+ 62% { transform: scaleY(.1) }
79
+ 68% { transform: scaleY(1.3) } /* …startle! */
80
+ 76% { transform: scaleY(.9) }
81
+ 86%, 100% { transform: scaleY(1) }
82
+ }
83
+ @keyframes lgSleepAntL { 0% { transform: rotate(0) } 55% { transform: rotate(9deg) } 68% { transform: rotate(-6deg) } 100% { transform: rotate(0) } }
84
+ @keyframes lgSleepAntR { 0% { transform: rotate(0) } 55% { transform: rotate(-9deg) } 68% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
85
+
86
+ /* ================= 6. EXCITED — antennas buzz, eyes go wide (1.8s) ================= */
87
+ .v-excited .lg-ant-l { animation: lgExAntL 1.8s ease-in-out 1 both; }
88
+ .v-excited .lg-ant-r { animation: lgExAntR 1.8s ease-in-out 1 both; }
89
+ .v-excited .lg-eye-l, .v-excited .lg-eye-r { animation: lgExEye 1.8s ease-in-out 1 both; }
90
+ @keyframes lgExAntL {
91
+ 0% { transform: rotate(0) }
92
+ 8% { transform: rotate(-7deg) } 16% { transform: rotate(7deg) }
93
+ 24% { transform: rotate(-6deg) } 32% { transform: rotate(6deg) }
94
+ 40% { transform: rotate(-5deg) } 48% { transform: rotate(5deg) }
95
+ 56% { transform: rotate(-4deg) } 64% { transform: rotate(4deg) }
96
+ 72% { transform: rotate(-2deg) } 80% { transform: rotate(2deg) }
97
+ 90%, 100% { transform: rotate(0) }
98
+ }
99
+ @keyframes lgExAntR {
100
+ 0% { transform: rotate(0) }
101
+ 8% { transform: rotate(7deg) } 16% { transform: rotate(-7deg) }
102
+ 24% { transform: rotate(6deg) } 32% { transform: rotate(-6deg) }
103
+ 40% { transform: rotate(5deg) } 48% { transform: rotate(-5deg) }
104
+ 56% { transform: rotate(4deg) } 64% { transform: rotate(-4deg) }
105
+ 72% { transform: rotate(2deg) } 80% { transform: rotate(-2deg) }
106
+ 90%, 100% { transform: rotate(0) }
107
+ }
108
+ @keyframes lgExEye { 0% { transform: scale(1) } 18% { transform: scale(1.28) } 70% { transform: scale(1.28) } 100% { transform: scale(1) } }
109
+
110
+ /* ================= 9. BOOT — the eyes flicker on, CRT style (1.6s) ================= */
111
+ .v-boot .lg-eye-l { animation: lgBootL 1.6s steps(1) 1 both; }
112
+ .v-boot .lg-eye-r { animation: lgBootR 1.6s steps(1) 1 both; }
113
+ .v-boot .lg-ant-l { animation: lgBootAnt 1.6s ease-out 1 both; }
114
+ @keyframes lgBootL {
115
+ 0%, 10% { opacity: 0 }
116
+ 14% { opacity: 1 } 18% { opacity: 0 } 24% { opacity: 1 } 28% { opacity: 0 }
117
+ 34%, 100% { opacity: 1 }
118
+ }
119
+ @keyframes lgBootR {
120
+ 0%, 22% { opacity: 0 }
121
+ 26% { opacity: 1 } 30% { opacity: 0 } 38% { opacity: 1 } 42% { opacity: 0 }
122
+ 48%, 100% { opacity: 1 }
123
+ }
124
+ @keyframes lgBootAnt { 0%, 40% { transform: rotate(0) } 52% { transform: rotate(-11deg) } 70% { transform: rotate(4deg) } 100% { transform: rotate(0) } }
125
+
126
+ @media (prefers-reduced-motion: reduce) {
127
+ .topbar-logo * { animation: none !important; }
128
+ }
129
+
130
+ /* ================= 10. SQUINT — goes cross-eyed a moment (2s) ================= */
131
+ .v-squint .lg-eye-l { animation: lgSquintL 2s ease-in-out 1 both; }
132
+ .v-squint .lg-eye-r { animation: lgSquintR 2s ease-in-out 1 both; }
133
+ @keyframes lgSquintL {
134
+ 0%, 8% { transform: translateX(0) }
135
+ 20%, 72% { transform: translateX(4.5px) } /* both eyes dive toward the nose */
136
+ 88%, 100% { transform: translateX(0) }
137
+ }
138
+ @keyframes lgSquintR {
139
+ 0%, 8% { transform: translateX(0) }
140
+ 20%, 72% { transform: translateX(-4.5px) }
141
+ 88%, 100% { transform: translateX(0) }
142
+ }
143
+
144
+ /* ================= 11. GLASSES — puts its glasses on (2.8s) ================= */
145
+ /* the .lg-glasses group sits in the SVG, invisible at rest; the move drops it
146
+ onto the eyes with a tiny overshoot, wears it a moment, lifts it away */
147
+ .lg-glasses { transform-box: fill-box; transform-origin: center; opacity: 0; }
148
+ .v-glasses .lg-glasses { animation: lgGlasses 2.8s ease-in-out 1 both; }
149
+ @keyframes lgGlasses {
150
+ 0% { opacity: 0; transform: translateY(-28px) }
151
+ 10% { opacity: 1; transform: translateY(2.5px) } /* lands, slight overshoot */
152
+ 16% { transform: translateY(0) }
153
+ 72% { opacity: 1; transform: translateY(0) } /* worn ~1.6s */
154
+ 86%, 100% { opacity: 0; transform: translateY(-28px) } /* lifted off */
155
+ }
reachy_web/static/js/locales.js CHANGED
@@ -276,7 +276,7 @@ window.LOCALES = {
276
  "update.ref_placeholder": "e.g. v1.2.7 or develop",
277
  "update.start": "Update",
278
  "update.title": "Update",
279
- "update.up_to_date": "Up to date.",
280
  "update.updating": "Updating...",
281
  "update.version_unknown": "Version unknown",
282
  "volume.microphone": "Microphone level",
@@ -678,7 +678,7 @@ window.LOCALES = {
678
  "update.ref_placeholder": "ex. v1.2.7 ou develop",
679
  "update.start": "Mettre à jour",
680
  "update.title": "Mise à jour",
681
- "update.up_to_date": "À jour.",
682
  "update.updating": "Mise à jour...",
683
  "update.version_unknown": "Version inconnue",
684
  "volume.microphone": "Niveau du micro",
@@ -1080,7 +1080,7 @@ window.LOCALES = {
1080
  "update.ref_placeholder": "p. ej. v1.2.7 o develop",
1081
  "update.start": "Actualizar",
1082
  "update.title": "Actualización",
1083
- "update.up_to_date": "Actualizado.",
1084
  "update.updating": "Actualizando...",
1085
  "update.version_unknown": "Versión desconocida",
1086
  "volume.microphone": "Nivel del micrófono",
@@ -1482,7 +1482,7 @@ window.LOCALES = {
1482
  "update.ref_placeholder": "z. B. v1.2.7 oder develop",
1483
  "update.start": "Aktualisieren",
1484
  "update.title": "Update",
1485
- "update.up_to_date": "Auf dem neuesten Stand.",
1486
  "update.updating": "Aktualisiere…",
1487
  "update.version_unknown": "Version unbekannt",
1488
  "volume.microphone": "Mikrofonpegel",
@@ -1884,7 +1884,7 @@ window.LOCALES = {
1884
  "update.ref_placeholder": "es. v1.2.7 o develop",
1885
  "update.start": "Aggiorna",
1886
  "update.title": "Aggiornamento",
1887
- "update.up_to_date": "Aggiornato.",
1888
  "update.updating": "Aggiornamento...",
1889
  "update.version_unknown": "Versione sconosciuta",
1890
  "volume.microphone": "Livello del microfono",
@@ -2286,7 +2286,7 @@ window.LOCALES = {
2286
  "update.ref_placeholder": "ex.: v1.2.7 ou develop",
2287
  "update.start": "Atualizar",
2288
  "update.title": "Atualização",
2289
- "update.up_to_date": "Atualizado.",
2290
  "update.updating": "A atualizar...",
2291
  "update.version_unknown": "Versão desconhecida",
2292
  "volume.microphone": "Nível do microfone",
@@ -2688,7 +2688,7 @@ window.LOCALES = {
2688
  "update.ref_placeholder": "例: v1.2.7 または develop",
2689
  "update.start": "アップデート",
2690
  "update.title": "アップデート",
2691
- "update.up_to_date": "最新の状態です。",
2692
  "update.updating": "更新中...",
2693
  "update.version_unknown": "バージョン不明",
2694
  "volume.microphone": "マイクレベル",
@@ -3090,7 +3090,7 @@ window.LOCALES = {
3090
  "update.ref_placeholder": "مثلاً v1.2.7 أو develop",
3091
  "update.start": "تحديث",
3092
  "update.title": "تحديث",
3093
- "update.up_to_date": "محدّث.",
3094
  "update.updating": "جارٍ التحديث...",
3095
  "update.version_unknown": "الإصدار غير معروف",
3096
  "volume.microphone": "مستوى الميكروفون",
 
276
  "update.ref_placeholder": "e.g. v1.2.7 or develop",
277
  "update.start": "Update",
278
  "update.title": "Update",
279
+ "update.up_to_date": "Everything is up to date",
280
  "update.updating": "Updating...",
281
  "update.version_unknown": "Version unknown",
282
  "volume.microphone": "Microphone level",
 
678
  "update.ref_placeholder": "ex. v1.2.7 ou develop",
679
  "update.start": "Mettre à jour",
680
  "update.title": "Mise à jour",
681
+ "update.up_to_date": "Tout est à jour",
682
  "update.updating": "Mise à jour...",
683
  "update.version_unknown": "Version inconnue",
684
  "volume.microphone": "Niveau du micro",
 
1080
  "update.ref_placeholder": "p. ej. v1.2.7 o develop",
1081
  "update.start": "Actualizar",
1082
  "update.title": "Actualización",
1083
+ "update.up_to_date": "Todo está actualizado",
1084
  "update.updating": "Actualizando...",
1085
  "update.version_unknown": "Versión desconocida",
1086
  "volume.microphone": "Nivel del micrófono",
 
1482
  "update.ref_placeholder": "z. B. v1.2.7 oder develop",
1483
  "update.start": "Aktualisieren",
1484
  "update.title": "Update",
1485
+ "update.up_to_date": "Alles ist aktuell",
1486
  "update.updating": "Aktualisiere…",
1487
  "update.version_unknown": "Version unbekannt",
1488
  "volume.microphone": "Mikrofonpegel",
 
1884
  "update.ref_placeholder": "es. v1.2.7 o develop",
1885
  "update.start": "Aggiorna",
1886
  "update.title": "Aggiornamento",
1887
+ "update.up_to_date": "Tutto è aggiornato",
1888
  "update.updating": "Aggiornamento...",
1889
  "update.version_unknown": "Versione sconosciuta",
1890
  "volume.microphone": "Livello del microfono",
 
2286
  "update.ref_placeholder": "ex.: v1.2.7 ou develop",
2287
  "update.start": "Atualizar",
2288
  "update.title": "Atualização",
2289
+ "update.up_to_date": "Tudo está atualizado",
2290
  "update.updating": "A atualizar...",
2291
  "update.version_unknown": "Versão desconhecida",
2292
  "volume.microphone": "Nível do microfone",
 
2688
  "update.ref_placeholder": "例: v1.2.7 または develop",
2689
  "update.start": "アップデート",
2690
  "update.title": "アップデート",
2691
+ "update.up_to_date": "すべて最新です",
2692
  "update.updating": "更新中...",
2693
  "update.version_unknown": "バージョン不明",
2694
  "volume.microphone": "マイクレベル",
 
3090
  "update.ref_placeholder": "مثلاً v1.2.7 أو develop",
3091
  "update.start": "تحديث",
3092
  "update.title": "تحديث",
3093
+ "update.up_to_date": "كل شيء محدّث",
3094
  "update.updating": "جارٍ التحديث...",
3095
  "update.version_unknown": "الإصدار غير معروف",
3096
  "volume.microphone": "مستوى الميكروفون",
reachy_web/static/js/logo_anim.js ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The topbar logo, alive.
2
+ //
3
+ // WHY the SVG is inline in base.html rather than <img src="logo.svg">: an SVG
4
+ // loaded through <img> runs in the browser's secure static mode — scripts never
5
+ // execute — so its timing could only ever be a fixed CSS loop, and every visit
6
+ // would replay the exact same beat. Inline, we pick the next move here, at random.
7
+ //
8
+ // One move at a time: add its v-* class, let the (one-shot) CSS play out, remove
9
+ // the class, wait a random pause, pick again. The WINK opens the show on load and
10
+ // keeps the heaviest weight afterwards — it is the logo's signature; the rest is
11
+ // seasoning that should surprise, not compete.
12
+ const logoAnim = {
13
+ // w = how often a move comes up (relative). The wink stays the most frequent
14
+ // single move (~1 round in 3) without drowning the others out. It is played at
15
+ // load regardless of weight — see init().
16
+ MOVES: [
17
+ { cls: 'v-wink', ms: 1100, w: 5 },
18
+ { cls: 'v-look', ms: 2600, w: 3 },
19
+ { cls: 'v-blink', ms: 900, w: 3 },
20
+ { cls: 'v-sleepy', ms: 2400, w: 2 },
21
+ { cls: 'v-squint', ms: 2000, w: 2 },
22
+ { cls: 'v-excited', ms: 1800, w: 1 },
23
+ { cls: 'v-boot', ms: 1600, w: 1 },
24
+ { cls: 'v-glasses', ms: 2800, w: 1 },
25
+ ],
26
+ PAUSE_MIN: 4000,
27
+ PAUSE_MAX: 11000,
28
+
29
+ el: null,
30
+ timer: null,
31
+
32
+ init: () => {
33
+ logoAnim.el = document.querySelector('.topbar-logo');
34
+ if (!logoAnim.el) return;
35
+ // the whole point of the animation is delight; someone who asked the OS for
36
+ // less motion is not asking for delight
37
+ if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
38
+ logoAnim.play(logoAnim.MOVES[0]); // the wink opens the show, always
39
+ },
40
+
41
+ // weighted pick: walk the moves subtracting weights until the dart lands
42
+ pick: () => {
43
+ const total = logoAnim.MOVES.reduce((s, m) => s + m.w, 0);
44
+ let r = Math.random() * total;
45
+ for (const m of logoAnim.MOVES) {
46
+ if ((r -= m.w) < 0) return m;
47
+ }
48
+ return logoAnim.MOVES[0];
49
+ },
50
+
51
+ play: (move) => {
52
+ const el = logoAnim.el;
53
+ if (!el) return;
54
+ el.classList.add(move.cls);
55
+ clearTimeout(logoAnim.timer);
56
+ logoAnim.timer = setTimeout(() => {
57
+ el.classList.remove(move.cls); // back to the resting pose
58
+ const pause = logoAnim.PAUSE_MIN + Math.random() * (logoAnim.PAUSE_MAX - logoAnim.PAUSE_MIN);
59
+ logoAnim.timer = setTimeout(() => logoAnim.play(logoAnim.pick()), pause);
60
+ }, move.ms);
61
+ },
62
+ };
63
+
64
+ document.addEventListener('DOMContentLoaded', logoAnim.init);
reachy_web/static/js/skins.js CHANGED
@@ -301,24 +301,34 @@ const skins = {
301
  spiderman: (c, p) => {
302
  const ink = p.line || '#0a0a0a', white = p.white || '#f2f2f0';
303
  skins.paint.spiderWeb(c, p); // red suit + black web
304
- const eye = (X, m) => {
305
- // shrunk (esp. vertically) so the panel covers the lens but does NOT spill
306
- // below the head onto the body
307
- c.save(); c.translate(X, 96); c.scale(m * 1.0, 0.56);
 
 
 
 
 
 
 
 
 
308
  const outline = (k) => {
309
  c.beginPath();
310
- c.moveTo(52 * k, 30 * k);
311
- c.bezierCurveTo(20 * k, -63 * k, -29 * k, -90 * k, -47 * k, -48 * k);
312
- c.bezierCurveTo(-65 * k, -6 * k, -56 * k, 54 * k, -29 * k, 75 * k);
313
- c.bezierCurveTo(-5 * k, 93 * k, 32 * k, 69 * k, 52 * k, 30 * k);
314
  c.closePath();
315
  };
316
- outline(1); c.lineJoin = 'round'; c.lineWidth = 15; c.strokeStyle = ink; c.stroke();
317
  c.fillStyle = white; c.fill();
318
- outline(0.76); c.lineWidth = 5; c.strokeStyle = ink; c.stroke();
319
  c.restore();
320
  };
321
- eye(185, 1); eye(327, -1);
 
322
  const bx = 256, by = 300, s = 1.7; // on the chest/torso, above the web centre
323
  c.fillStyle = ink;
324
  c.beginPath(); c.ellipse(bx, by - 13 * s, 7 * s, 11 * s, 0, 0, Math.PI * 2); c.fill();
 
301
  spiderman: (c, p) => {
302
  const ink = p.line || '#0a0a0a', white = p.white || '#f2f2f0';
303
  skins.paint.spiderWeb(c, p); // red suit + black web
304
+ // The mask's eye panel, drawn the way the suit actually reads: a POINT at the
305
+ // inner corner (towards the nose, sitting low), sweeping up and out over the top
306
+ // to a wide ROUNDED outer end that rides high, then back along the bottom. Thick
307
+ // black border, white fill, and the thin inner outline the mask always has.
308
+ //
309
+ // Reachy's two lenses are NOT the same size (88 px and 66 px across, see the
310
+ // landmarks), so each panel is scaled to its own lens. Drawn UNSQUASHED (uniform
311
+ // scale) so the border stays even, and centred at y=88 with a ±55 reach, which
312
+ // keeps the whole panel on the head (the body starts around y=150).
313
+ const eye = (X, m, size) => {
314
+ c.save();
315
+ c.translate(X, 88);
316
+ c.scale(m * size, size);
317
  const outline = (k) => {
318
  c.beginPath();
319
+ c.moveTo(56 * k, 24 * k); // inner point, low, at the nose
320
+ c.bezierCurveTo(24 * k, -38 * k, -26 * k, -54 * k, -46 * k, -28 * k); // up and out over the top
321
+ c.bezierCurveTo(-62 * k, -4 * k, -54 * k, 32 * k, -28 * k, 46 * k); // the wide rounded outer end
322
+ c.bezierCurveTo(-4 * k, 56 * k, 34 * k, 44 * k, 56 * k, 24 * k); // back along the bottom
323
  c.closePath();
324
  };
325
+ outline(1); c.lineJoin = 'round'; c.lineWidth = 13; c.strokeStyle = ink; c.stroke();
326
  c.fillStyle = white; c.fill();
327
+ outline(0.72); c.lineWidth = 4; c.strokeStyle = ink; c.stroke();
328
  c.restore();
329
  };
330
+ eye(185, 1, 1.0); // the BIG lens (88 px across)
331
+ eye(327, -1, 0.78); // the smaller lens (66 px across)
332
  const bx = 256, by = 300, s = 1.7; // on the chest/torso, above the web centre
333
  c.fillStyle = ink;
334
  c.beginPath(); c.ellipse(bx, by - 13 * s, 7 * s, 11 * s, 0, 0, Math.PI * 2); c.fill();
reachy_web/static/js/update.js CHANGED
@@ -46,6 +46,18 @@ const updateManager = {
46
  }
47
  },
48
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  // Never leave "Checking for updates…" spinning forever: the daemon can be
50
  // away (502) or the request can fail. Say so plainly, so the line resolves.
51
  checkFailed: () => {
@@ -113,38 +125,39 @@ const updateManager = {
113
  }
114
  },
115
 
 
 
 
 
116
  connectLogsWebSocket: (jobId) => {
117
- const updateModal = document.getElementById('update-modal');
118
- const updateModalTitle = document.getElementById('update-modal-title');
119
- const logsDiv = document.getElementById('update-logs');
120
- const closeButton = document.getElementById('update-modal-close-button');
121
-
122
- updateModalTitle.textContent = t('update.updating');
123
-
124
- closeButton.onclick = () => {
125
- updateModal.classList.add('hidden');
126
- };
127
-
128
- updateModal.classList.remove('hidden');
129
 
130
  const ws = new WebSocket(`ws://${location.host}/update/ws/logs?job_id=${jobId}`);
131
-
132
- ws.onmessage = (event) => {
133
- // console.log('Update log:', event);
134
- logsDiv.innerHTML += event.data + '<br>';
135
- logsDiv.scrollTop = logsDiv.scrollHeight;
136
- };
137
  ws.onclose = async () => {
138
- console.log('Update logs WebSocket closed');
139
- closeButton.classList.remove('hidden');
140
- closeButton.textContent = t('common.close');
141
- updateModalTitle.textContent = t('update.completed');
142
  updateManager.busy = false;
143
- await updateManager.loadInstallSource();
144
- await updateManager.checkForUpdate();
 
 
 
145
  };
146
  },
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  updateUI: async (update) => {
149
  // updateManager.updateMainPage(isUpdateAvailable);
150
  updateManager.updateUpdatePage(update);
@@ -174,13 +187,14 @@ const updateManager = {
174
 
175
  const updateInfo = data.update.reachy_mini;
176
  if (updateInfo.is_available) {
177
- // the newer version, as its own accent pill (just the number)
 
178
  if (availableVersionElem) availableVersionElem.textContent = updateInfo.available_version || '';
179
- statusElem.textContent = t('update.available');
180
  startUpdateBtn.classList.remove('hidden');
181
  } else {
182
  if (availableVersionElem) availableVersionElem.textContent = ''; // up to date → no second pill
183
- statusElem.textContent = t('update.up_to_date');
184
  startUpdateBtn.classList.add('hidden');
185
  }
186
  }
 
46
  }
47
  },
48
 
49
+ // "Everything is up to date" with a green check — the clean, positive verdict
50
+ // when there is nothing to install.
51
+ sayUpToDate: (statusElem) => {
52
+ if (!statusElem) return;
53
+ statusElem.textContent = '';
54
+ const ico = document.createElement('span');
55
+ ico.className = 'ic i-check text-ok';
56
+ const txt = document.createElement('span');
57
+ txt.textContent = t('update.up_to_date');
58
+ statusElem.append(ico, txt);
59
+ },
60
+
61
  // Never leave "Checking for updates…" spinning forever: the daemon can be
62
  // away (502) or the request can fail. Say so plainly, so the line resolves.
63
  checkFailed: () => {
 
125
  }
126
  },
127
 
128
+ // The OTA update streams into the SAME slide-in drawer as an app install —
129
+ // not a mini popin floating inside the settings panel. installPanel.open()
130
+ // closes the settings drawer itself (they share the right edge) and gives us
131
+ // the classified log lines + a completion footer for free.
132
  connectLogsWebSocket: (jobId) => {
133
+ installPanel.open(t('update.updating'));
 
 
 
 
 
 
 
 
 
 
 
134
 
135
  const ws = new WebSocket(`ws://${location.host}/update/ws/logs?job_id=${jobId}`);
136
+ ws.onmessage = (event) => installPanel.line(event.data);
 
 
 
 
 
137
  ws.onclose = async () => {
138
+ installPanel.done(true, t('update.completed'));
 
 
 
139
  updateManager.busy = false;
140
+ // The app has just been reinstalled, so it may need a beat before it can
141
+ // answer again. Asking once landed on "Version unknown" + a still-armed
142
+ // Update button — a lie. Retry until it answers, then the line settles
143
+ // on the real state ("Up to date").
144
+ await updateManager.refreshAfterUpdate();
145
  };
146
  },
147
 
148
+ // re-read the version and re-check, retrying while the app comes back up
149
+ refreshAfterUpdate: async (tries = 6) => {
150
+ for (let i = 0; i < tries; i++) {
151
+ try {
152
+ const r = await fetch('/update/install-source');
153
+ if (r.ok) break;
154
+ } catch (e) { /* not back yet */ }
155
+ await new Promise((res) => setTimeout(res, 1500));
156
+ }
157
+ await updateManager.loadInstallSource();
158
+ await updateManager.checkForUpdate();
159
+ },
160
+
161
  updateUI: async (update) => {
162
  // updateManager.updateMainPage(isUpdateAvailable);
163
  updateManager.updateUpdatePage(update);
 
187
 
188
  const updateInfo = data.update.reachy_mini;
189
  if (updateInfo.is_available) {
190
+ // the newer version, as its own accent pill (just the number). The pill
191
+ // and the button say it all — no sentence.
192
  if (availableVersionElem) availableVersionElem.textContent = updateInfo.available_version || '';
193
+ statusElem.textContent = '';
194
  startUpdateBtn.classList.remove('hidden');
195
  } else {
196
  if (availableVersionElem) availableVersionElem.textContent = ''; // up to date → no second pill
197
+ updateManager.sayUpToDate(statusElem);
198
  startUpdateBtn.classList.add('hidden');
199
  }
200
  }
reachy_web/static/js/waveform.js CHANGED
@@ -11,7 +11,20 @@
11
  // The speaker is a plain slider now: no waveform.
12
  const waveform = {
13
  BARS: 48,
14
- MIC_GAIN: 4, // a raw mic peak is tiny (~0.05); lift it so it visibly MOVES
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  mic: { el: null, bars: [], ctx: null, analyser: null, data: null, raf: null, session: null, retryTimer: null, gestureHooked: false },
17
 
@@ -78,7 +91,7 @@ const waveform = {
78
  s.ws.onmessage = (ev) => {
79
  let peaks; try { peaks = JSON.parse(ev.data); } catch { return; }
80
  if (!Array.isArray(peaks)) return;
81
- waveform.mic.bars = waveform.mic.bars.concat(peaks).slice(-waveform.BARS);
82
  waveform.draw(waveform.mic.el, waveform.mic.bars);
83
  };
84
  s.ws.onclose = () => {
@@ -278,7 +291,7 @@ const waveform = {
278
  });
279
  if (level == null) return; // no audioLevel in this browser → analyser stays
280
  m.statsDriving = true;
281
- const peak = Math.min(1, level * waveform.MIC_GAIN);
282
  m.bars = m.bars.concat(peak).slice(-waveform.BARS);
283
  waveform.draw(m.el, m.bars);
284
  }).catch(() => {});
@@ -300,7 +313,7 @@ const waveform = {
300
  for (let i = 0; i < m.data.length; i++) {
301
  raw = Math.max(raw, Math.abs(m.data[i] - 128) / 128);
302
  }
303
- const peak = Math.min(1, raw * waveform.MIC_GAIN);
304
  m.bars = m.bars.concat(peak).slice(-waveform.BARS); // scroll left
305
  waveform.draw(m.el, m.bars);
306
  },
 
11
  // The speaker is a plain slider now: no waveform.
12
  const waveform = {
13
  BARS: 48,
14
+ MIC_GAIN: 4, // a raw mic peak is tiny (~0.05); lift it so it visibly MOVES
15
+ NOISE_FLOOR: 0.03, // at or under this, it is the room's hiss — not sound
16
+
17
+ // raw level (0..1) → bar factor (0..1). A noise GATE comes FIRST: a silent room
18
+ // still feeds the mic a constant low hiss, and since the drawing curve lifts
19
+ // small values hard (to keep quiet speech visible), that hiss was drawn as a
20
+ // permanent wave — "sound" in a silent room. Anything at or under the floor is
21
+ // flat silence; what is above is rescaled from zero, THEN gained, so real sound
22
+ // still stands out.
23
+ shape: (raw) => {
24
+ const f = waveform.NOISE_FLOOR;
25
+ const v = raw <= f ? 0 : (raw - f) / (1 - f);
26
+ return Math.min(1, v * waveform.MIC_GAIN);
27
+ },
28
 
29
  mic: { el: null, bars: [], ctx: null, analyser: null, data: null, raf: null, session: null, retryTimer: null, gestureHooked: false },
30
 
 
91
  s.ws.onmessage = (ev) => {
92
  let peaks; try { peaks = JSON.parse(ev.data); } catch { return; }
93
  if (!Array.isArray(peaks)) return;
94
+ waveform.mic.bars = waveform.mic.bars.concat(peaks.map(waveform.shape)).slice(-waveform.BARS);
95
  waveform.draw(waveform.mic.el, waveform.mic.bars);
96
  };
97
  s.ws.onclose = () => {
 
291
  });
292
  if (level == null) return; // no audioLevel in this browser → analyser stays
293
  m.statsDriving = true;
294
+ const peak = waveform.shape(level);
295
  m.bars = m.bars.concat(peak).slice(-waveform.BARS);
296
  waveform.draw(m.el, m.bars);
297
  }).catch(() => {});
 
313
  for (let i = 0; i < m.data.length; i++) {
314
  raw = Math.max(raw, Math.abs(m.data[i] - 128) / 128);
315
  }
316
+ const peak = waveform.shape(raw);
317
  m.bars = m.bars.concat(peak).slice(-waveform.BARS); // scroll left
318
  waveform.draw(m.el, m.bars);
319
  },
reachy_web/templates/base.html CHANGED
@@ -13,6 +13,7 @@
13
  <link rel="stylesheet" href="/static/css/icons.css">
14
  <link rel="stylesheet" href="/static/css/logs.css">
15
  <link rel="stylesheet" href="/static/css/audio_eq.css">
 
16
 
17
  <!-- where the robot actually is: the page may be served from a laptop -->
18
  <script>window.REACHY_DAEMON_NAME = "{{ daemon_name }}";</script>
@@ -25,6 +26,8 @@
25
  <script src="/static/js/locales.js"></script>
26
  <script src="/static/js/i18n.js"></script>
27
  <script src="/static/js/loader.js"></script>
 
 
28
  <!-- the controller button lives in the topbar, so it must exist on every page -->
29
  <script src="/static/js/controller.js"></script>
30
  <script src="/static/js/robot_name.js"></script>
@@ -49,7 +52,42 @@
49
  <div class="topbar-inner container px-4 sm:px-8 md:px-8 lg:px-8 xl:px-8 mx-auto w-full md:w-2/3">
50
  <!-- the logo spans the three rows; it and the title go home -->
51
  <a href="/" class="topbar-home" title="Home" data-i18n-title="topbar.home">
52
- <img class="topbar-logo" src="/static/assets/logo.svg" alt="Reachy Web">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  </a>
54
  <button class="burger topbar-iconbtn" id="burgerBtn" aria-label="Menu" data-i18n-title="topbar.menu" onclick="topbar.toggleMenu()">
55
  <span class="ic i-menu"></span>
@@ -64,8 +102,8 @@
64
  <nav class="navmenu-list">
65
  <button onclick="nav.show('reachy')">
66
  <svg class="tab-bot" viewBox="0 0 120 124" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
67
- <path d="M45 46 L37 24"/><circle cx="37" cy="22" r="4.2" fill="currentColor" stroke="none"/>
68
- <path d="M75 46 L83 24"/><circle cx="83" cy="22" r="4.2" fill="currentColor" stroke="none"/>
69
  <rect x="34" y="44" width="52" height="36" rx="13"/>
70
  <circle cx="48.98" cy="62.21" r="8.14" fill="currentColor" stroke="none"/>
71
  <circle cx="72.67" cy="62.5" r="6.98" fill="currentColor" stroke="none"/>
 
13
  <link rel="stylesheet" href="/static/css/icons.css">
14
  <link rel="stylesheet" href="/static/css/logs.css">
15
  <link rel="stylesheet" href="/static/css/audio_eq.css">
16
+ <link rel="stylesheet" href="/static/css/logo.css">
17
 
18
  <!-- where the robot actually is: the page may be served from a laptop -->
19
  <script>window.REACHY_DAEMON_NAME = "{{ daemon_name }}";</script>
 
26
  <script src="/static/js/locales.js"></script>
27
  <script src="/static/js/i18n.js"></script>
28
  <script src="/static/js/loader.js"></script>
29
+ <!-- the topbar logo lives on every page, and so must the thing that animates it -->
30
+ <script src="/static/js/logo_anim.js"></script>
31
  <!-- the controller button lives in the topbar, so it must exist on every page -->
32
  <script src="/static/js/controller.js"></script>
33
  <script src="/static/js/robot_name.js"></script>
 
52
  <div class="topbar-inner container px-4 sm:px-8 md:px-8 lg:px-8 xl:px-8 mx-auto w-full md:w-2/3">
53
  <!-- the logo spans the three rows; it and the title go home -->
54
  <a href="/" class="topbar-home" title="Home" data-i18n-title="topbar.home">
55
+ <!-- INLINE, not <img src="logo.svg">: an <img>-loaded SVG runs in
56
+ the browser's secure static mode (no scripts), so its timing
57
+ could only ever be a fixed CSS loop. Inline, logo_anim.js picks
58
+ each move at random. Asymmetric on purpose: the right eye and
59
+ the right antenna are smaller than their left twin. -->
60
+ <svg class="topbar-logo" viewBox="24 13 72 72" role="img" aria-label="Reachy Web">
61
+ <!-- two nested groups per antenna: the outer one never stops
62
+ breathing (lg-sway), the inner one carries whatever move
63
+ logo_anim.js is playing. Same property on two elements =
64
+ the rotations compose instead of fighting. -->
65
+ <g class="lg-sway lg-sway-l">
66
+ <g class="lg-ant lg-ant-l">
67
+ <path d="M45 46 L37 22" fill="none" stroke="#1b2330" stroke-width="3" stroke-linecap="round"/>
68
+ <circle class="lg-ball lg-ball-l" cx="37" cy="22" r="4" fill="#1b2330"/>
69
+ </g>
70
+ </g>
71
+ <g class="lg-sway lg-sway-r">
72
+ <g class="lg-ant lg-ant-r">
73
+ <path d="M75 46 L81.4 26.4" fill="none" stroke="#1b2330" stroke-width="3" stroke-linecap="round"/>
74
+ <circle class="lg-ball lg-ball-r" cx="81.4" cy="26.4" r="3.4" fill="#1b2330"/>
75
+ </g>
76
+ </g>
77
+ <rect x="34" y="44" width="52" height="36" rx="13" fill="#fff" stroke="#1b2330" stroke-width="3"/>
78
+ <circle class="lg-eye lg-eye-l" cx="48.98" cy="62.21" r="8.14" fill="#1b2330"/>
79
+ <circle class="lg-eye lg-eye-r" cx="71.02" cy="62.21" r="7" fill="#1b2330"/>
80
+ <path class="lg-wink" d="M63.9 61.5 Q71 68.5 78.1 61.5" fill="none" stroke="#1b2330" stroke-width="3.6" stroke-linecap="round"/>
81
+ <!-- the glasses, invisible at rest — the v-glasses move drops
82
+ them on. Two round lenses that touch at the bridge, and
83
+ two little temples running to the head's edges. -->
84
+ <g class="lg-glasses" fill="none" stroke="#1b2330" stroke-width="3" stroke-linecap="round">
85
+ <circle cx="48.98" cy="62.21" r="11.5"/>
86
+ <circle cx="71.02" cy="62.21" r="10.5"/>
87
+ <path d="M37.48 62.21 L34 59.5"/>
88
+ <path d="M81.52 62.21 L86 59.5"/>
89
+ </g>
90
+ </svg>
91
  </a>
92
  <button class="burger topbar-iconbtn" id="burgerBtn" aria-label="Menu" data-i18n-title="topbar.menu" onclick="topbar.toggleMenu()">
93
  <span class="ic i-menu"></span>
 
102
  <nav class="navmenu-list">
103
  <button onclick="nav.show('reachy')">
104
  <svg class="tab-bot" viewBox="0 0 120 124" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
105
+ <path d="M45 46 L37 22"/><circle cx="37" cy="22" r="4.2" fill="currentColor" stroke="none"/>
106
+ <path d="M75 46 L83 22"/><circle cx="83" cy="22" r="4.2" fill="currentColor" stroke="none"/>
107
  <rect x="34" y="44" width="52" height="36" rx="13"/>
108
  <circle cx="48.98" cy="62.21" r="8.14" fill="currentColor" stroke="none"/>
109
  <circle cx="72.67" cy="62.5" r="6.98" fill="currentColor" stroke="none"/>
reachy_web/templates/index.html CHANGED
@@ -11,7 +11,7 @@
11
  base — its face traced from the assets, only monochrome and still here -->
12
  <svg class="tab-bot" viewBox="0 0 120 124" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
13
  <path d="M45 46 L37 24"/><circle cx="37" cy="22" r="4.2" fill="currentColor" stroke="none"/>
14
- <path d="M75 46 L83 24"/><circle cx="83" cy="22" r="4.2" fill="currentColor" stroke="none"/>
15
  <rect x="34" y="44" width="52" height="36" rx="13"/>
16
  <circle cx="48.98" cy="62.21" r="8.14" fill="currentColor" stroke="none"/>
17
  <circle cx="72.67" cy="62.5" r="6.98" fill="currentColor" stroke="none"/>
 
11
  base — its face traced from the assets, only monochrome and still here -->
12
  <svg class="tab-bot" viewBox="0 0 120 124" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
13
  <path d="M45 46 L37 24"/><circle cx="37" cy="22" r="4.2" fill="currentColor" stroke="none"/>
14
+ <path d="M75 46 L83 22"/><circle cx="83" cy="22" r="4.2" fill="currentColor" stroke="none"/>
15
  <rect x="34" y="44" width="52" height="36" rx="13"/>
16
  <circle cx="48.98" cy="62.21" r="8.14" fill="currentColor" stroke="none"/>
17
  <circle cx="72.67" cy="62.5" r="6.98" fill="currentColor" stroke="none"/>
reachy_web/templates/sections/appearance.html CHANGED
@@ -1,10 +1,11 @@
1
  <div class="app-section">
2
  <div class="app-section-title"><span class="ic i-palette"></span> <span data-i18n="settings.appearance">Appearance</span></div>
3
- <!-- a 2-column grid: labels in one column (aligned left), controls in the next
4
- (all starting at the same x) — so every row shares the same left/right gap -->
 
5
  <div class="grid grid-cols-[max-content_1fr] items-center gap-x-6 gap-y-4 p-4">
6
  <span class="text-sm font-medium" data-i18n="settings.theme">Theme</span>
7
- <div class="theme-ctl justify-self-start">
8
  <span class="ic i-weather-night"></span>
9
  <button class="ios" id="themeSw" data-theme-switch role="switch" aria-label="Light theme" data-i18n-aria="settings.light_theme"
10
  data-i18n-title="settings.light_theme"></button>
@@ -12,20 +13,20 @@
12
  </div>
13
 
14
  <span class="text-sm font-medium" data-i18n="settings.palette">Palette</span>
15
- <div class="palettes justify-self-start" id="palettes" title="Palette" data-i18n-title="settings.palette"></div>
16
 
17
  <span class="text-sm font-medium" data-i18n="settings.random_skin">Random skin</span>
18
- <div class="flex items-center gap-2 justify-self-start">
19
- <button class="ios" id="skinRandomSw" role="switch"
 
20
  data-i18n-aria="settings.random_skin_hint" data-i18n-title="settings.random_skin_hint"></button>
21
- <span class="text-xs text-mut" data-i18n="settings.random_skin_hint">A different skin on every start</span>
22
  </div>
23
 
24
  <span class="text-sm font-medium" data-i18n="settings.debug">Debug mode</span>
25
- <div class="flex items-center gap-2 justify-self-start">
26
- <button class="ios" id="debugSw" role="switch"
 
27
  data-i18n-aria="settings.debug_hint" data-i18n-title="settings.debug_hint"></button>
28
- <span class="text-xs text-mut" data-i18n="settings.debug_hint">Mirror the browser console into the logs panel</span>
29
  </div>
30
  </div>
31
  </div>
 
1
  <div class="app-section">
2
  <div class="app-section-title"><span class="ic i-palette"></span> <span data-i18n="settings.appearance">Appearance</span></div>
3
+ <!-- a 2-column grid: labels in one column (aligned left), controls in the next,
4
+ all flushed RIGHT — so every switch lands on the same right edge. Where a
5
+ row carries a hint, the hint sits before the switch to keep it rightmost. -->
6
  <div class="grid grid-cols-[max-content_1fr] items-center gap-x-6 gap-y-4 p-4">
7
  <span class="text-sm font-medium" data-i18n="settings.theme">Theme</span>
8
+ <div class="theme-ctl justify-self-end">
9
  <span class="ic i-weather-night"></span>
10
  <button class="ios" id="themeSw" data-theme-switch role="switch" aria-label="Light theme" data-i18n-aria="settings.light_theme"
11
  data-i18n-title="settings.light_theme"></button>
 
13
  </div>
14
 
15
  <span class="text-sm font-medium" data-i18n="settings.palette">Palette</span>
16
+ <div class="palettes justify-self-end" id="palettes" title="Palette" data-i18n-title="settings.palette"></div>
17
 
18
  <span class="text-sm font-medium" data-i18n="settings.random_skin">Random skin</span>
19
+ <div class="flex items-center gap-2 justify-self-end min-w-0">
20
+ <span class="text-xs text-mut text-right" data-i18n="settings.random_skin_hint">A different skin on every start</span>
21
+ <button class="ios flex-none" id="skinRandomSw" role="switch"
22
  data-i18n-aria="settings.random_skin_hint" data-i18n-title="settings.random_skin_hint"></button>
 
23
  </div>
24
 
25
  <span class="text-sm font-medium" data-i18n="settings.debug">Debug mode</span>
26
+ <div class="flex items-center gap-2 justify-self-end min-w-0">
27
+ <span class="text-xs text-mut text-right" data-i18n="settings.debug_hint">Mirror the browser console into the logs panel</span>
28
+ <button class="ios flex-none" id="debugSw" role="switch"
29
  data-i18n-aria="settings.debug_hint" data-i18n-title="settings.debug_hint"></button>
 
30
  </div>
31
  </div>
32
  </div>
reachy_web/templates/sections/update.html CHANGED
@@ -17,21 +17,22 @@
17
  <span id="available-version"
18
  class="inline-flex items-center px-2.5 py-0.5 rounded-full bg-acc text-bg text-sm font-semibold tabular-nums empty:hidden"></span>
19
 
20
- <label class="flex items-center gap-2 text-sm text-mut cursor-pointer">
 
 
 
 
 
21
  <button type="button" class="ios" id="pre-release-switch" role="switch" aria-checked="false"
22
  data-i18n-title="update.pre_release"></button>
23
  <span data-i18n="update.pre_release">pre-release</span>
24
  </label>
25
 
26
  <button id="start-update-btn" onclick="updateManager.startUpdate()"
27
- class="hidden ml-auto inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg shadow bg-green-400 hover:bg-green-500 text-white text-sm font-semibold"
28
  data-i18n-title="update.start">
29
  <span class="ic i-arrow-up-bold"></span> <span data-i18n="update.start">Update</span>
30
  </button>
31
-
32
- <!-- kept for update.js (it returns early if this is missing) but never
33
- shown: the "update available" sentence was redundant with the pill -->
34
- <span id="update-status" class="hidden"></span>
35
  </div>
36
  </div>
37
 
 
17
  <span id="available-version"
18
  class="inline-flex items-center px-2.5 py-0.5 rounded-full bg-acc text-bg text-sm font-semibold tabular-nums empty:hidden"></span>
19
 
20
+ <!-- the verdict, on the left: a green check + "Everything is up to date"
21
+ when there is nothing to do; emptied when an update IS available (the
22
+ pill and the button already say it) -->
23
+ <span id="update-status" class="flex items-center gap-1.5 text-sm text-mut"></span>
24
+
25
+ <label class="flex items-center gap-2 text-sm text-mut cursor-pointer ml-auto">
26
  <button type="button" class="ios" id="pre-release-switch" role="switch" aria-checked="false"
27
  data-i18n-title="update.pre_release"></button>
28
  <span data-i18n="update.pre_release">pre-release</span>
29
  </label>
30
 
31
  <button id="start-update-btn" onclick="updateManager.startUpdate()"
32
+ class="hidden inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg shadow bg-green-400 hover:bg-green-500 text-white text-sm font-semibold"
33
  data-i18n-title="update.start">
34
  <span class="ic i-arrow-up-bold"></span> <span data-i18n="update.start">Update</span>
35
  </button>
 
 
 
 
36
  </div>
37
  </div>
38
 
tailwindcss/styles/app.css CHANGED
@@ -63,6 +63,12 @@
63
  /* in light mode every palette fades from white (the font stays the palette's) */
64
  :root[data-palette][data-theme=light] body { background: radial-gradient(1200px 600px at 50% -10%, #fff, var(--bg)) fixed; }
65
 
 
 
 
 
 
 
66
  /* ================= utilities → tokens ================= */
67
  /* The recovered UI is written in white/gray/blue/green utilities. Remapping the
68
  scales onto the tokens makes every existing class — templates AND markup
@@ -165,7 +171,15 @@ body {
165
  flex-wrap: wrap;
166
  }
167
 
168
- .palettes { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
 
 
 
 
 
 
 
 
169
 
170
  .pdot {
171
  width: 16px;
 
63
  /* in light mode every palette fades from white (the font stays the palette's) */
64
  :root[data-palette][data-theme=light] body { background: radial-gradient(1200px 600px at 50% -10%, #fff, var(--bg)) fixed; }
65
 
66
+ /* Tailwind emits the display utilities in a fixed order, and .flex / .inline-flex
67
+ land AFTER .hidden — same specificity, so the later one wins and any element
68
+ carrying BOTH (every toggled button, pill or disclosure in this app) simply
69
+ never hid. Make .hidden win outright, whatever it is combined with. */
70
+ .hidden { display: none !important; }
71
+
72
  /* ================= utilities → tokens ================= */
73
  /* The recovered UI is written in white/gray/blue/green utilities. Remapping the
74
  scales onto the tokens makes every existing class — templates AND markup
 
171
  flex-wrap: wrap;
172
  }
173
 
174
+ /* the 13 palettes on two balanced rows (7 + 6) rather than one long wrapping
175
+ line — a grid, so the dots line up in columns instead of drifting */
176
+ .palettes {
177
+ display: grid;
178
+ grid-template-columns: repeat(7, auto);
179
+ justify-items: center;
180
+ align-items: center;
181
+ gap: 6px;
182
+ }
183
 
184
  .pdot {
185
  width: 16px;