style: refine mac voice settings layout

This commit is contained in:
Peter Steinberger
2026-05-19 02:16:22 +02:00
parent b4fdd1470b
commit 8aff1807fa
2 changed files with 224 additions and 109 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving `/readyz` sidecar gating. (#83301) Thanks @samzong.
- Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
- Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
- Skills CLI: allow `openclaw skills install` and `openclaw skills update` to target shared managed skills with `--global`. (#74466) Thanks @Marvae.

View File

@@ -251,67 +251,67 @@ struct VoiceWakeSettings: View {
private var triggerTable: some View {
SettingsCardGroup("Trigger Words") {
HStack {
Text("Wake phrases")
.font(.callout.weight(.semibold))
Spacer()
Button {
self.addWord()
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
}
.padding(.horizontal, 14)
.padding(.top, 12)
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
HStack(spacing: 8) {
TextField("Wake word", text: $entry.value)
.textFieldStyle(.roundedBorder)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
SettingsCardRow(
title: "Wake phrases",
subtitle: "Short phrases that start voice wake detection.")
{
HStack(spacing: 8) {
Button {
self.addWord()
} label: {
Label("Add word", systemImage: "plus")
}
.padding(8)
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
if entry.id != self.triggerEntries.last?.id {
Divider()
Button("Reset") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.25), lineWidth: 1))
.padding(.horizontal, 14)
Text(
"OpenClaw reacts when any trigger appears in a transcription. "
+ "Keep them short to avoid false positives.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
self.triggerPhraseRows
TriggerPhraseHelpRow()
}
}
private var triggerPhraseRows: some View {
Group {
if self.triggerEntries.isEmpty {
HStack(spacing: 10) {
Image(systemName: "text.badge.plus")
.font(.callout)
.foregroundStyle(.secondary)
.frame(width: 22)
Text("No wake phrases configured")
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.vertical, 14)
} else {
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
TriggerPhraseRow(
value: $entry.value,
showsDivider: entry.id != self.triggerEntries.last?.id,
onSubmit: {
self.syncTriggerEntriesToState()
},
onRemove: {
self.removeWord(id: entry.id)
})
}
}
}
}
.overlay(alignment: .bottom) {
Divider()
.padding(.leading, 14)
}
}
@@ -535,67 +535,79 @@ struct VoiceWakeSettings: View {
.frame(width: self.controlWidth)
}
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Additional languages")
.font(.footnote.weight(.semibold))
ForEach(
Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()),
id: \.offset)
{ idx, localeID in
HStack(spacing: 8) {
Picker("Extra \(idx + 1)", selection: Binding(
get: { localeID },
set: { newValue in
guard self.state
.voiceWakeAdditionalLocaleIDs.indices
.contains(idx) else { return }
self.state
.voiceWakeAdditionalLocaleIDs[idx] =
newValue
})) {
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
}
}
.labelsHidden()
.frame(width: 220)
Button {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove language")
}
}
SettingsCardRow(
title: "Additional languages",
subtitle: self.additionalLanguagesSubtitle,
showsDivider: !self.state.voiceWakeAdditionalLocaleIDs.isEmpty)
{
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
Button {
if let first = availableLocales.first {
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
}
self.addAdditionalLocale()
} label: {
Label("Add language", systemImage: "plus")
Label("Add", systemImage: "plus")
}
.buttonStyle(.bordered)
.disabled(self.availableLocales.isEmpty)
}
.padding(.horizontal, 14)
.padding(.bottom, 10)
} else {
Button {
if let first = availableLocales.first {
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
}
} label: {
Label("Add additional language", systemImage: "plus")
}
.buttonStyle(.link)
.disabled(self.availableLocales.isEmpty)
.padding(.horizontal, 14)
.padding(.bottom, 10)
}
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
self.additionalLanguageRows
}
}
}
private var additionalLanguagesSubtitle: String {
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
return "None configured."
}
return "Tried after the primary language."
}
private var additionalLanguageRows: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
AdditionalLanguageRow(
index: idx,
selection: self.additionalLocaleBinding(index: idx, fallback: localeID),
localeIDs: self.availableLocales.map(\.identifier),
localeName: { id in self.friendlyName(for: Locale(identifier: id)) },
showsDivider: true,
onRemove: {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
})
}
SettingsCardRow(title: "Add another language", showsDivider: false) {
Button {
self.addAdditionalLocale()
} label: {
Label("Add", systemImage: "plus")
}
.buttonStyle(.bordered)
.disabled(self.availableLocales.isEmpty)
}
}
}
private func additionalLocaleBinding(index: Int, fallback: String) -> Binding<String> {
Binding(
get: {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return fallback }
return self.state.voiceWakeAdditionalLocaleIDs[index]
},
set: { newValue in
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return }
self.state.voiceWakeAdditionalLocaleIDs[index] = newValue
})
}
private func addAdditionalLocale() {
let selected = Set([self.state.voiceWakeLocaleID] + self.state.voiceWakeAdditionalLocaleIDs)
let next = self.availableLocales.first { !selected.contains($0.identifier) } ?? self.availableLocales.first
if let next {
self.state.voiceWakeAdditionalLocaleIDs.append(next.identifier)
}
}
@@ -716,6 +728,108 @@ struct VoiceWakeSettings: View {
}
}
private struct TriggerPhraseRow: View {
@Binding var value: String
let showsDivider: Bool
let onSubmit: () -> Void
let onRemove: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "quote.opening")
.font(.callout.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 24)
TextField("Wake phrase", text: self.$value)
.textFieldStyle(.roundedBorder)
.font(.callout.weight(.medium))
.frame(maxWidth: 420)
.onSubmit(self.onSubmit)
Spacer(minLength: 8)
Button(action: self.onRemove) {
Image(systemName: "trash")
.font(.callout)
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.frame(width: 26, height: 26)
.contentShape(Rectangle())
.help("Remove trigger word")
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.overlay(alignment: .bottom) {
if self.showsDivider {
Divider()
.padding(.leading, 50)
}
}
}
}
private struct AdditionalLanguageRow: View {
let index: Int
@Binding var selection: String
let localeIDs: [String]
let localeName: (String) -> String
let showsDivider: Bool
let onRemove: () -> Void
var body: some View {
SettingsCardRow(
title: "Language \(self.index + 2)",
subtitle: "Fallback recognition language.",
showsDivider: self.showsDivider)
{
HStack(spacing: 10) {
Picker("Language \(self.index + 2)", selection: self.$selection) {
ForEach(self.localeIDs, id: \.self) { id in
Text(self.localeName(id)).tag(id)
}
}
.labelsHidden()
.frame(width: 220)
Button(action: self.onRemove) {
Image(systemName: "trash")
.font(.callout)
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.frame(width: 26, height: 26)
.contentShape(Rectangle())
.help("Remove language")
}
}
}
}
private struct TriggerPhraseHelpRow: View {
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "info.circle")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 18)
.padding(.top, 1)
Text("OpenClaw reacts when any trigger appears in a transcription. Keep phrases short to avoid false positives.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
}
}
#if DEBUG
struct VoiceWakeSettings_Previews: PreviewProvider {
static var previews: some View {