@olares/lares-mobile 0.15.6 → 0.15.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat/voice.js CHANGED
@@ -1,21 +1,57 @@
1
- import { TranscriptQueue } from "@olares/lares-core/voice/client";
2
1
  import {
3
2
  formatElapsed,
4
3
  isRecordingTooShort,
5
4
  pickRecordingMime,
6
- RECORDING_AUDIO,
7
5
  } from "@olares/lares-core/voice/recorder";
8
6
 
9
7
  export { formatElapsed };
10
8
 
11
- export function createVoiceCapture({ transcribe, getDraft, setDraft, onPhase }) {
12
- const queue = new TranscriptQueue();
9
+ /** Phone mic. Bare booleans are exact and iOS rejects the headset profile. */
10
+ const DEVICE_AUDIO = {
11
+ channelCount: { ideal: 1 },
12
+ echoCancellation: { ideal: true },
13
+ noiseSuppression: { ideal: true },
14
+ autoGainControl: { ideal: true },
15
+ };
16
+
17
+ const CHUNK_MS = 250;
18
+
19
+ function micFailure(err) {
20
+ const name = err?.name || "";
21
+ if (name === "NotAllowedError" || name === "PermissionDeniedError") return "voice_permission_denied";
22
+ if (name === "SecurityError") return "voice_unsupported";
23
+ return "voice_failed";
24
+ }
25
+
26
+ async function openDeviceMic(mediaDevices) {
27
+ try {
28
+ return await mediaDevices.getUserMedia({ audio: DEVICE_AUDIO });
29
+ } catch (err) {
30
+ if (micFailure(err) !== "voice_failed") throw err;
31
+ return mediaDevices.getUserMedia({ audio: true });
32
+ }
33
+ }
34
+
35
+ export function createVoiceCapture({
36
+ transcribe,
37
+ submit,
38
+ onPhase,
39
+ onTrace,
40
+ now = Date.now,
41
+ media,
42
+ }) {
43
+ const mediaDevices = media?.mediaDevices ?? globalThis.navigator?.mediaDevices;
44
+ const Recorder = media?.MediaRecorder ?? globalThis.MediaRecorder;
13
45
  let recorder = null;
14
46
  let stream = null;
15
47
  let chunks = [];
16
48
  let started = 0;
17
49
  let timer = 0;
50
+ let errorTimer = 0;
51
+ let finishTimer = 0;
18
52
  let cancel = false;
53
+ let released = false;
54
+ let finished = false;
19
55
  let dead = false;
20
56
  let abort = null;
21
57
  let phase = "idle";
@@ -25,6 +61,14 @@ export function createVoiceCapture({ transcribe, getDraft, setDraft, onPhase })
25
61
  onPhase?.(next, extra);
26
62
  };
27
63
 
64
+ const trace = (event, fields) => {
65
+ try {
66
+ onTrace?.(event, fields);
67
+ } catch {
68
+ /* a trace must not change the take */
69
+ }
70
+ };
71
+
28
72
  const stopTimer = () => {
29
73
  if (timer) {
30
74
  clearInterval(timer);
@@ -40,96 +84,182 @@ export function createVoiceCapture({ transcribe, getDraft, setDraft, onPhase })
40
84
 
41
85
  const fail = (code) => {
42
86
  if (dead) return;
87
+ if (errorTimer) clearTimeout(errorTimer);
43
88
  emit("error", { error: code, elapsed: 0 });
44
- setTimeout(() => {
89
+ errorTimer = setTimeout(() => {
90
+ errorTimer = 0;
45
91
  if (!dead && phase === "error") emit("idle", { error: "", elapsed: 0 });
46
92
  }, 3200);
47
93
  };
48
94
 
49
95
  const finish = async () => {
96
+ if (finished || dead) return;
97
+ finished = true;
50
98
  stopTimer();
51
- const took = Date.now() - started;
52
- const type = recorder?.mimeType || "audio/webm";
99
+ const took = now() - started;
100
+ const type = recorder?.mimeType || chunks[0]?.type || "audio/webm";
53
101
  const blobChunks = chunks;
54
102
  chunks = [];
55
103
  release();
56
- if (dead) return;
57
104
  if (cancel) {
58
- emit("idle", { elapsed: 0 });
105
+ emit("idle", { error: "", elapsed: 0 });
59
106
  return;
60
107
  }
61
108
  const blob = new Blob(blobChunks, { type });
109
+ trace("blob", { bytes: blob.size, type, ms: took, chunks: blobChunks.length });
62
110
  if (isRecordingTooShort(took, blob.size)) {
111
+ trace("too-short", { bytes: blob.size, ms: took });
63
112
  fail("voice_too_short");
64
113
  return;
65
114
  }
66
- emit("transcribing", { elapsed: took });
115
+ emit("transcribing", { elapsed: took, error: "" });
67
116
  abort = new AbortController();
68
117
  try {
69
- const text = await transcribe(blob, abort.signal);
70
- if (dead) return;
71
- emit("idle", { elapsed: 0 });
72
- queue.apply(text, true, getDraft(), setDraft);
118
+ const text = String(await transcribe(blob, abort.signal) ?? "").trim();
119
+ if (dead || cancel) return;
120
+ if (!text) {
121
+ trace("no-input", {});
122
+ fail("voice_no_input");
123
+ return;
124
+ }
125
+ trace("transcript", { chars: text.length, head: text.slice(0, 40) });
126
+ emit("idle", { error: "", elapsed: 0 });
127
+ submit?.(text);
73
128
  } catch (err) {
74
- if (dead || abort.signal.aborted) return;
75
- fail(err instanceof Error ? err.message : "voice_failed");
129
+ if (dead || abort.signal.aborted || cancel) return;
130
+ const code = err instanceof Error ? err.message : "voice_failed";
131
+ trace("transcribe-error", { code });
132
+ fail(code);
76
133
  } finally {
77
134
  abort = null;
78
135
  }
79
136
  };
80
137
 
138
+ const scheduleFinish = () => {
139
+ if (finishTimer) clearTimeout(finishTimer);
140
+ finishTimer = setTimeout(() => {
141
+ finishTimer = 0;
142
+ void finish();
143
+ }, 30);
144
+ };
145
+
146
+ const endRecorder = () => {
147
+ if (!recorder || recorder.state === "inactive") {
148
+ scheduleFinish();
149
+ return;
150
+ }
151
+ try {
152
+ recorder.requestData?.();
153
+ } catch {
154
+ /* the final dataavailable still arrives from stop() */
155
+ }
156
+ try {
157
+ recorder.stop();
158
+ } catch {
159
+ scheduleFinish();
160
+ }
161
+ };
162
+
81
163
  return {
82
164
  get phase() {
83
165
  return phase;
84
166
  },
85
167
  async start() {
86
- if (dead || phase === "recording" || phase === "transcribing") return;
87
- if (typeof MediaRecorder === "undefined" || !navigator.mediaDevices?.getUserMedia) {
168
+ if (dead || phase === "preparing" || phase === "recording" || phase === "transcribing") return;
169
+ if (typeof Recorder === "undefined" || !mediaDevices?.getUserMedia) {
88
170
  fail("voice_unsupported");
89
171
  return;
90
172
  }
91
173
  cancel = false;
174
+ released = false;
175
+ finished = false;
176
+ trace("open", {});
177
+ emit("preparing", { elapsed: 0, error: "" });
92
178
  let next;
93
179
  try {
94
- next = await navigator.mediaDevices.getUserMedia({ audio: RECORDING_AUDIO });
95
- } catch {
96
- fail("voice_permission_denied");
180
+ next = await openDeviceMic(mediaDevices);
181
+ } catch (err) {
182
+ const code = micFailure(err);
183
+ trace("mic-fail", { code, name: err?.name || "" });
184
+ if (!dead) fail(code);
97
185
  return;
98
186
  }
99
187
  if (dead || cancel) {
100
188
  next.getTracks().forEach((track) => track.stop());
189
+ if (!dead && phase === "preparing") emit("idle", { error: "", elapsed: 0 });
101
190
  return;
102
191
  }
103
192
  stream = next;
104
193
  chunks = [];
105
- const mime = pickRecordingMime();
106
- recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
194
+ const mime = pickRecordingMime(Recorder);
195
+ try {
196
+ recorder = new Recorder(stream, mime ? { mimeType: mime } : undefined);
197
+ } catch {
198
+ release();
199
+ fail("voice_unsupported");
200
+ return;
201
+ }
107
202
  recorder.ondataavailable = (event) => {
108
203
  if (event.data?.size) chunks.push(event.data);
204
+ if (recorder?.state === "inactive") scheduleFinish();
109
205
  };
110
- recorder.onstop = () => {
111
- void finish();
112
- };
113
- started = Date.now();
206
+ recorder.onstop = () => scheduleFinish();
207
+ started = now();
208
+ try {
209
+ recorder.start(CHUNK_MS);
210
+ } catch {
211
+ trace("record-fail", { mime });
212
+ release();
213
+ fail("voice_unsupported");
214
+ return;
215
+ }
216
+ trace("recording", { mime: recorder.mimeType || mime || "" });
217
+ if (released || cancel) {
218
+ endRecorder();
219
+ return;
220
+ }
114
221
  emit("recording", { elapsed: 0, error: "" });
115
- recorder.start();
116
- timer = setInterval(() => emit("recording", { elapsed: Date.now() - started }), 200);
222
+ timer = setInterval(() => emit("recording", { elapsed: now() - started, error: "" }), 200);
117
223
  },
224
+ /**
225
+ * Finger-up finishes the take. The mic opens asynchronously, so the finger
226
+ * often lifts while still preparing — that is not a cancel.
227
+ */
118
228
  stop(aborting = false) {
119
- cancel = Boolean(aborting);
120
- if (recorder && recorder.state !== "inactive") recorder.stop();
121
- else {
122
- stopTimer();
123
- release();
124
- emit("idle", { elapsed: 0 });
229
+ if (phase === "transcribing") {
230
+ if (aborting) {
231
+ cancel = true;
232
+ abort?.abort();
233
+ }
234
+ return;
235
+ }
236
+ if (phase !== "preparing" && phase !== "recording") {
237
+ trace("stop-ignored", { aborting: Boolean(aborting), phase });
238
+ return;
239
+ }
240
+ trace("stop", {
241
+ aborting: Boolean(aborting),
242
+ phase,
243
+ recorder: recorder?.state || "none",
244
+ });
245
+ if (aborting) {
246
+ cancel = true;
247
+ released = false;
248
+ if (recorder && recorder.state !== "inactive") recorder.stop();
249
+ return;
250
+ }
251
+ released = true;
252
+ stopTimer();
253
+ if (recorder && recorder.state !== "inactive") {
254
+ emit("transcribing", { elapsed: now() - started, error: "" });
255
+ endRecorder();
125
256
  }
126
- },
127
- toggle() {
128
- if (phase === "recording") this.stop(false);
129
- else if (phase === "idle" || phase === "error") void this.start();
130
257
  },
131
258
  dispose() {
132
259
  dead = true;
260
+ cancel = true;
261
+ if (errorTimer) clearTimeout(errorTimer);
262
+ if (finishTimer) clearTimeout(finishTimer);
133
263
  stopTimer();
134
264
  abort?.abort();
135
265
  if (recorder && recorder.state !== "inactive") recorder.stop();
@@ -1,7 +1,50 @@
1
1
  <template>
2
- <form class="desktop-composer" @submit.prevent>
2
+ <form class="desktop-composer" :data-hero="hero ? 'true' : undefined" @submit.prevent>
3
3
  <div class="desktop-composer__model-row">
4
- <LaresPcPopover v-model="modelOpen" :width="260" placement="top-end">
4
+ <LaresPcPopover
5
+ v-if="hero"
6
+ v-model="workspaceOpen"
7
+ class="desktop-composer__workspace-slot"
8
+ :width="240"
9
+ placement="top-start"
10
+ >
11
+ <template #trigger="{ toggle }">
12
+ <button
13
+ type="button"
14
+ class="desktop-composer__model desktop-composer__workspace"
15
+ :disabled="!state.workspacesReady"
16
+ aria-haspopup="menu"
17
+ :aria-expanded="workspaceOpen"
18
+ @mousedown.prevent
19
+ @click="toggle"
20
+ >
21
+ <svg viewBox="0 0 24 24" aria-hidden="true">
22
+ <path d="M3.5 7.5h6l1.6 2H20.5v9H3.5z" />
23
+ </svg>
24
+ <span>{{ workspaceLabel }}</span>
25
+ <ChevronIcon />
26
+ </button>
27
+ </template>
28
+ <div class="desktop-composer__menu">
29
+ <button
30
+ v-for="workspace in state.workspaces"
31
+ :key="workspace.workspaceId"
32
+ type="button"
33
+ class="desktop-composer__option"
34
+ :data-selected="workspace.workspaceId === state.currentWorkspaceId"
35
+ @click="pickWorkspace(workspace.workspaceId)"
36
+ >
37
+ <span>{{ workspace.title || defaultWorkspaceTitle }}</span>
38
+ <CheckIcon v-if="workspace.workspaceId === state.currentWorkspaceId" />
39
+ </button>
40
+ </div>
41
+ </LaresPcPopover>
42
+ <LaresPcPopover
43
+ v-model="modelOpen"
44
+ class="desktop-composer__model-slot"
45
+ :width="260"
46
+ placement="top-end"
47
+ >
5
48
  <template #trigger="{ toggle }">
6
49
  <button
7
50
  type="button"
@@ -397,6 +440,7 @@
397
440
  <script>
398
441
  import { h } from "vue";
399
442
  import { resolveSubmitMode } from "@olares/lares-core/larepass/submission-settings";
443
+ import { DEFAULT_WORKSPACE_TITLE } from "@olares/lares-core/workspace/constants";
400
444
  import { activeReferenceToken, insertReference } from "@olares/lares-core/larepass/references";
401
445
  import { draftImageStatus } from "@olares/lares-core/files/draft-images";
402
446
  import LaresDesktopQueue from "./DesktopQueue.vue";
@@ -456,16 +500,18 @@ export default {
456
500
  props: {
457
501
  state: { type: Object, required: true },
458
502
  t: { type: Function, required: true },
503
+ hero: { type: Boolean, default: false },
459
504
  modelKey: { type: Function, required: true },
460
505
  effortName: { type: Function, required: true },
461
506
  },
462
507
  emits: [
463
508
  "update-draft", "send", "stop", "steer-queue", "files", "choose-model", "choose-effort", "answer-approval", "answer-questions",
464
- "choose-permission", "disable-plan", "goal-action", "open-commands", "pick-command", "queue-action", "remove-image",
509
+ "choose-permission", "disable-plan", "goal-action", "open-commands", "pick-command", "queue-action", "remove-image", "pick-workspace",
465
510
  ],
466
511
  data() {
467
512
  return {
468
513
  modelOpen: false,
514
+ workspaceOpen: false,
469
515
  effortOpen: false,
470
516
  permissionOpen: false,
471
517
  permissionConfirm: false,
@@ -484,6 +530,16 @@ export default {
484
530
  };
485
531
  },
486
532
  computed: {
533
+ defaultWorkspaceTitle() {
534
+ return DEFAULT_WORKSPACE_TITLE;
535
+ },
536
+ workspaceLabel() {
537
+ if (!this.state.workspacesReady) return this.t("agent.loading");
538
+ const current = this.state.workspaces.find(
539
+ (row) => row.workspaceId === this.state.currentWorkspaceId,
540
+ );
541
+ return current?.title || this.state.workspaces[0]?.title || DEFAULT_WORKSPACE_TITLE;
542
+ },
487
543
  /** Held by id, so removing or sending the draft closes the dialog. */
488
544
  previewImage() {
489
545
  return this.state.draftImages.find((row) => row.id === this.previewImageId) ?? null;
@@ -524,6 +580,7 @@ export default {
524
580
  return this.t("chat.placeholderSteerQueue", { shortcut });
525
581
  }
526
582
  if (this.state.planActive) return this.t("chat.placeholderPlan");
583
+ if (this.hero) return this.t("chat.placeholderNewSession");
527
584
  return this.t("chat.placeholderDesktop");
528
585
  },
529
586
  },
@@ -544,6 +601,11 @@ export default {
544
601
  window.removeEventListener("mousedown", this.onComposerOverlayPointer, true);
545
602
  },
546
603
  methods: {
604
+ pickWorkspace(workspaceId) {
605
+ this.workspaceOpen = false;
606
+ if (!workspaceId || workspaceId === this.state.currentWorkspaceId) return;
607
+ this.$emit("pick-workspace", workspaceId);
608
+ },
547
609
  chooseModel(model) {
548
610
  this.modelOpen = false;
549
611
  this.$emit("choose-model", model);
@@ -768,12 +830,21 @@ export default {
768
830
  padding: 8px 0 20px;
769
831
  }
770
832
  .desktop-composer__model-row { display:flex; justify-content:flex-end; gap:2px; min-height:28px; margin-bottom:6px; }
833
+ .desktop-composer[data-hero="true"] .desktop-composer__model-row { justify-content:space-between; }
771
834
  .desktop-composer__model,
772
835
  .desktop-composer__circle,
773
836
  .desktop-composer__send {
774
837
  border:0; color:var(--q-ink-2); cursor:pointer;
775
838
  }
776
839
  .desktop-composer__model { display:flex; align-items:center; gap:4px; border-radius:999px; padding:4px 10px; background:transparent; font-size:14px; }
840
+ /* The model name is the long label of the pair, so it is the one that gives way;
841
+ without a floor of its own the workspace chip absorbs every pixel of shrink. */
842
+ .desktop-composer__workspace-slot { flex:0 0 auto; max-width:45%; }
843
+ .desktop-composer__model-slot { min-width:0; }
844
+ .desktop-composer__workspace,
845
+ .desktop-composer__model-slot .desktop-composer__model { min-width:0; max-width:100%; }
846
+ .desktop-composer__workspace span,
847
+ .desktop-composer__model-slot .desktop-composer__model span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
777
848
  .desktop-composer__model svg,
778
849
  .desktop-composer__permission svg,
779
850
  .desktop-composer__circle svg,
@@ -2,9 +2,16 @@
2
2
  <section class="desktop-preview" :aria-label="t('preview')">
3
3
  <header class="desktop-preview__bar">
4
4
  <span class="desktop-preview__path" :title="path">{{ path }}</span>
5
+ <a
6
+ v-if="filesHref"
7
+ class="desktop-preview__action"
8
+ :href="filesHref"
9
+ target="_blank"
10
+ rel="noopener noreferrer"
11
+ >{{ t("openInFiles") }}</a>
5
12
  <a
6
13
  v-if="downloadHref"
7
- class="desktop-preview__download"
14
+ class="desktop-preview__action"
8
15
  :href="downloadHref"
9
16
  download
10
17
  >{{ t("download") }}</a>
@@ -40,6 +47,7 @@ export default {
40
47
  error: { type: String, default: "" },
41
48
  mediaSrc: { type: String, default: "" },
42
49
  downloadHref: { type: String, default: "" },
50
+ filesHref: { type: String, default: "" },
43
51
  hrefFor: { type: Function, default: null },
44
52
  t: { type: Function, required: true },
45
53
  },
@@ -53,6 +61,6 @@ export default {
53
61
  .desktop-preview { position:absolute; z-index:8; display:flex; min-width:0; min-height:0; flex-direction:column; inset:0; padding:12px 20px 20px; background:var(--q-background-1); }
54
62
  .desktop-preview__bar { display:flex; min-width:0; flex:none; align-items:center; gap:12px; padding-bottom:10px; }
55
63
  .desktop-preview__path { min-width:0; flex:1; overflow:hidden; padding:0 4px; color:var(--q-ink-3); font-size:12px; line-height:18px; text-overflow:ellipsis; white-space:nowrap; }
56
- .desktop-preview__download { flex:none; color:var(--q-blue-default); font-size:12px; line-height:18px; text-decoration:none; }
64
+ .desktop-preview__action { flex:none; color:var(--q-blue-default); font-size:12px; line-height:18px; text-decoration:none; }
57
65
  .desktop-preview__content { display:flex; min-height:0; flex:1; flex-direction:column; overflow:hidden; border:1px solid var(--q-separator); border-radius:14px; background:var(--q-background-2); }
58
66
  </style>
@@ -16,7 +16,7 @@
16
16
  @archive-session="$emit('archive-session', $event)"
17
17
  />
18
18
  <main class="desktop-shell__main">
19
- <header class="desktop-shell__header">
19
+ <header v-if="!newSessionHero" class="desktop-shell__header">
20
20
  <h1>{{ state.sessionTitle }}</h1>
21
21
  <Transition name="lares-tabs">
22
22
  <LaresDesktopPreviewTabs
@@ -30,15 +30,21 @@
30
30
  />
31
31
  </Transition>
32
32
  </header>
33
- <section class="desktop-shell__conversation">
33
+ <section class="desktop-shell__conversation" :data-hero="newSessionHero ? 'true' : 'false'">
34
34
  <div v-if="state.failed" class="desktop-shell__failure">
35
35
  <span>{{ state.failText }}</span>
36
36
  <button type="button" :disabled="state.starting" @click="$emit('retry')">{{ t("probe.retry") }}</button>
37
37
  </div>
38
+ <div v-if="newSessionHero" class="desktop-shell__brand">
39
+ <div class="desktop-shell__logo" :style="logoStyle" aria-hidden="true" />
40
+ <h1>{{ t("shell.title") }}</h1>
41
+ </div>
38
42
  <LaresTranscript
43
+ v-if="!newSessionHero"
39
44
  ref="transcript"
40
45
  :items="state.viewItems"
41
46
  :running="state.running"
47
+ :running-since="state.runningSince"
42
48
  :loading="state.historyLoading || state.starting"
43
49
  :previews="state.previews"
44
50
  :media-url="mediaUrl"
@@ -58,6 +64,7 @@
58
64
  ref="composer"
59
65
  :state="state"
60
66
  :t="t"
67
+ :hero="newSessionHero"
61
68
  :model-key="modelKey"
62
69
  :effort-name="effortName"
63
70
  @update-draft="$emit('update-draft', $event)"
@@ -67,6 +74,7 @@
67
74
  @files="$emit('files', $event)"
68
75
  @remove-image="$emit('remove-image', $event)"
69
76
  @choose-model="$emit('choose-model', $event)"
77
+ @pick-workspace="$emit('pick-workspace', $event)"
70
78
  @choose-effort="$emit('choose-effort', $event)"
71
79
  @choose-permission="$emit('choose-permission', $event)"
72
80
  @disable-plan="$emit('disable-plan')"
@@ -88,6 +96,7 @@
88
96
  :error="state.preview.error"
89
97
  :media-src="state.previewMediaSrc"
90
98
  :download-href="state.previewDownloadHref"
99
+ :files-href="state.previewFilesHref"
91
100
  :href-for="previewHref"
92
101
  :t="t"
93
102
  @retry="$emit('open-file', state.preview.path)"
@@ -115,6 +124,7 @@ import LaresDesktopPreviewTabs from "./DesktopPreviewTabs.vue";
115
124
  import LaresDesktopSidebar from "./DesktopSidebar.vue";
116
125
  import LaresDesktopComposer from "./DesktopComposer.vue";
117
126
  import LaresDirectoryBrowser from "./DirectoryBrowser.vue";
127
+ import { MARK_DATA_URI } from "@olares/lares-core/icons/mark";
118
128
 
119
129
  export default {
120
130
  name: "LaresDesktopShell",
@@ -138,7 +148,7 @@ export default {
138
148
  effortName: { type: Function, required: true },
139
149
  },
140
150
  emits: [
141
- "create", "pick-session", "retry", "open-file", "answer", "update-draft",
151
+ "create", "pick-session", "pick-workspace", "retry", "open-file", "answer", "update-draft",
142
152
  "send", "stop", "steer-queue", "files", "remove-image", "choose-model", "choose-effort", "choose-permission", "disable-plan", "goal-action", "answer-approval", "answer-questions",
143
153
  "open-commands", "open-references", "pick-command", "queue-action", "show-chat", "close-preview-tab",
144
154
  "refresh-workspaces", "browse-workspaces", "browse-directory",
@@ -146,6 +156,20 @@ export default {
146
156
  "delete-workspace", "move-workspace", "move-session", "rename-session",
147
157
  "fork-session", "archive-session",
148
158
  ],
159
+ computed: {
160
+ newSessionHero() {
161
+ if (this.state.previewMode === "preview" && this.state.preview.path) return false;
162
+ return (
163
+ this.state.viewItems.length === 0
164
+ && !this.state.running
165
+ && !this.state.historyLoading
166
+ && !this.state.starting
167
+ );
168
+ },
169
+ logoStyle() {
170
+ return { backgroundImage: MARK_DATA_URI };
171
+ },
172
+ },
149
173
  methods: {
150
174
  focusComposer() {
151
175
  this.$refs.composer?.focus?.();
@@ -188,6 +212,10 @@ export default {
188
212
  .desktop-shell__header { display:flex; height:44px; flex-shrink:0; align-items:center; gap:16px; padding:0 18px; border-bottom:1px solid var(--q-separator); }
189
213
  .desktop-shell__header h1 { max-width:min(280px, 40%); overflow:hidden; margin:0; font-size:15px; font-weight:500; line-height:22px; text-overflow:ellipsis; white-space:nowrap; }
190
214
  .desktop-shell__conversation { position:relative; display:flex; min-width:0; min-height:0; flex:1; flex-direction:column; }
215
+ .desktop-shell__conversation[data-hero="true"] { justify-content:center; }
216
+ .desktop-shell__brand { display:flex; align-items:center; justify-content:center; gap:12px; margin-bottom:36px; }
217
+ .desktop-shell__brand h1 { margin:0; color:var(--q-ink-1); font-size:26px; font-weight:600; line-height:34px; letter-spacing:-.01em; }
218
+ .desktop-shell__logo { width:44px; height:44px; background-position:center; background-repeat:no-repeat; background-size:cover; box-shadow:inset 0 1.5px 0 rgb(255 255 255 / 50%); }
191
219
  .desktop-shell__failure { display:flex; width:calc(var(--lares-column) - 2 * var(--lares-gutter)); align-items:center; gap:10px; margin:12px auto 0; color:var(--q-orange-default); font-size:14px; }
192
220
  .desktop-shell__failure button { border:0; border-radius:6px; padding:5px 8px; background:var(--q-blue-alpha); color:var(--q-blue-default); }
193
221
  /* The scroller stays full width so the wheel works over the gutters; the rows carry the reading column. */