From 18802cb55dec5c1f1421410589f29f058cd36e61 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Sat, 12 Sep 2026 14:06:02 -0400 Subject: [PATCH] Branding --- README.md | 4 +- apps/control-center/README.md | 3 +- apps/control-center/main.js | 9 +- .../jarvis@qvac.local/brand.js | 33 ++++++++ .../jarvis@qvac.local/brand/gtk.css | 40 +++++++++ .../brand/icons/io.qvac.Jarvis.Control.svg | 17 ++++ .../brand/icons/jarvis-mark-symbolic.svg | 5 ++ .../brand/icons/jarvis-mark.svg | 6 ++ .../brand/icons/jarvis-wordmark.svg | 9 ++ .../scalable/apps/io.qvac.Jarvis.Control.svg | 17 ++++ .../jarvis@qvac.local/brand/tokens.json | 36 ++++++++ .../jarvis@qvac.local/extension.js | 31 +++++-- .../jarvis@qvac.local/metadata.json | 4 +- .../jarvis@qvac.local/settings-editor.js | 3 +- .../jarvis@qvac.local/settings-window.js | 61 ++++++++++++-- .../jarvis@qvac.local/stylesheet.css | 72 ++++++++++++---- apps/gnome-extension/jarvis@qvac.local/ui.js | 29 ++++++- docs/settings.md | 3 +- packaging/install.sh | 22 +++++ packaging/uninstall.sh | 2 + test/brand.test.js | 83 +++++++++++++++++++ test/gnome-extension.test.js | 5 +- test/install.test.js | 4 + 23 files changed, 459 insertions(+), 39 deletions(-) create mode 100644 apps/gnome-extension/jarvis@qvac.local/brand.js create mode 100644 apps/gnome-extension/jarvis@qvac.local/brand/gtk.css create mode 100644 apps/gnome-extension/jarvis@qvac.local/brand/icons/io.qvac.Jarvis.Control.svg create mode 100644 apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-mark-symbolic.svg create mode 100644 apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-mark.svg create mode 100644 apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-wordmark.svg create mode 100644 apps/gnome-extension/jarvis@qvac.local/brand/icons/scalable/apps/io.qvac.Jarvis.Control.svg create mode 100644 apps/gnome-extension/jarvis@qvac.local/brand/tokens.json create mode 100644 test/brand.test.js diff --git a/README.md b/README.md index 4b16c72..bd4c9e9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# JARVIS-QVAC +# Jarvis + +Local voice for your desktop. JARVIS-QVAC is a local-first voice assistant and computer-use layer for Ubuntu GNOME. It combines a wake-word loop, QVAC speech and language models, the diff --git a/apps/control-center/README.md b/apps/control-center/README.md index 0434958..3bb7ea8 100644 --- a/apps/control-center/README.md +++ b/apps/control-center/README.md @@ -3,7 +3,8 @@ Run `python3 apps/control-center/main.py` (or `gjs -m apps/control-center/main.js`). The Control Center and the GNOME extension's Settings button now open the same Libadwaita settings pages and use the same configuration catalog. Requires GJS, -GTK 4, and Libadwaita 1.4 or newer. +GTK 4, and Libadwaita 1.4 or newer. The window uses the Jarvis mark, Gold accent, +and tagline “Local voice for your desktop.” Voice, listening, and desktop settings are saved to `$XDG_CONFIG_HOME/jarvis/config.json` (default `~/.config/jarvis/config.json`). diff --git a/apps/control-center/main.js b/apps/control-center/main.js index 9ee1f68..5a6c577 100644 --- a/apps/control-center/main.js +++ b/apps/control-center/main.js @@ -1,16 +1,23 @@ #!/usr/bin/gjs -m import Adw from 'gi://Adw'; import Gio from 'gi://Gio'; +import Gtk from 'gi://Gtk'; +import Gdk from 'gi://Gdk'; import { fillSettingsWindow } from '../gnome-extension/jarvis@qvac.local/settings-window.js'; const directory = Gio.File.new_for_uri(import.meta.url).get_parent().get_parent().get_child('gnome-extension').get_child('jarvis@qvac.local').get_path(); const application = new Adw.Application({ application_id: 'io.qvac.Jarvis.Control', flags: Gio.ApplicationFlags.DEFAULT_FLAGS }); let window; +application.connect('startup', () => { + try { + Gtk.IconTheme.get_for_display(Gdk.Display.get_default()).add_search_path(`${directory}/brand/icons`); + } catch {} +}); application.connect('activate', () => { if (!window) { const source = Gio.SettingsSchemaSource.new_from_directory(`${directory}/schemas`, Gio.SettingsSchemaSource.get_default(), false); const settings = new Gio.Settings({ settings_schema: source.lookup('org.gnome.shell.extensions.jarvis', true) }); - window = new Adw.PreferencesWindow({ application }); + window = new Adw.PreferencesWindow({ application, icon_name: 'io.qvac.Jarvis.Control' }); fillSettingsWindow(window, settings, directory); window.connect('close-request', () => { window = null; return false; }); } diff --git a/apps/gnome-extension/jarvis@qvac.local/brand.js b/apps/gnome-extension/jarvis@qvac.local/brand.js new file mode 100644 index 0000000..bbe7d28 --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/brand.js @@ -0,0 +1,33 @@ +export const BRAND = { + name: 'Jarvis', + product: 'Jarvis QVAC', + tagline: 'Local voice for your desktop', + applicationId: 'io.qvac.Jarvis.Control', + uuid: 'jarvis@qvac.local', + colors: { + gold: '#F4B942', + goldBright: '#FFE09A', + goldDeep: '#C48A1A', + signal: '#4FD2FF', + ink: '#0B0E14', + slate: '#16191F', + paper: '#F6F7FB', + mute: '#AEB6C8', + dim: '#9AA8C0', + }, + accents: [ + { id: 'gold', label: 'Gold', value: '#F4B942' }, + { id: 'signal', label: 'Signal', value: '#4FD2FF' }, + { id: 'copper', label: 'Copper', value: '#E07A3D' }, + { id: 'jade', label: 'Jade', value: '#3DDC97' }, + { id: 'rose', label: 'Rose', value: '#F071A8' }, + ], +}; + +export const DEFAULT_ACCENT = BRAND.colors.gold; +export const SIGNAL = BRAND.colors.signal; + +export function normalizeAccent(value, fallback = DEFAULT_ACCENT) { + const color = String(value || '').trim(); + return /^#[0-9A-Fa-f]{6}$/.test(color) ? color : fallback; +} diff --git a/apps/gnome-extension/jarvis@qvac.local/brand/gtk.css b/apps/gnome-extension/jarvis@qvac.local/brand/gtk.css new file mode 100644 index 0000000..292f74c --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/brand/gtk.css @@ -0,0 +1,40 @@ +@define-color jarvis_gold #F4B942; +@define-color jarvis_gold_bright #FFE09A; +@define-color jarvis_signal #4FD2FF; +@define-color jarvis_ink #0B0E14; +@define-color jarvis_slate #16191F; +@define-color accent_color @jarvis_gold; +@define-color accent_bg_color @jarvis_gold; +@define-color accent_fg_color @jarvis_slate; + +.jarvis-brand-name { + font-weight: 800; + letter-spacing: 0.6px; +} + +.jarvis-brand-tagline { + font-size: 12px; +} + +button.jarvis-swatch { + min-width: 24px; + min-height: 24px; + padding: 0; + border-radius: 999px; + border: 1px solid alpha(#ffffff, 0.22); +} + +button.jarvis-swatch-gold { background: #F4B942; } +button.jarvis-swatch-signal { background: #4FD2FF; } +button.jarvis-swatch-copper { background: #E07A3D; } +button.jarvis-swatch-jade { background: #3DDC97; } +button.jarvis-swatch-rose { background: #F071A8; } + +button.jarvis-swatch:hover { + border-color: alpha(#ffffff, 0.55); +} + +button.jarvis-swatch:checked, +button.jarvis-swatch:active { + box-shadow: 0 0 0 2px @jarvis_ink, 0 0 0 4px @jarvis_gold; +} diff --git a/apps/gnome-extension/jarvis@qvac.local/brand/icons/io.qvac.Jarvis.Control.svg b/apps/gnome-extension/jarvis@qvac.local/brand/icons/io.qvac.Jarvis.Control.svg new file mode 100644 index 0000000..8bdac68 --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/brand/icons/io.qvac.Jarvis.Control.svg @@ -0,0 +1,17 @@ + + Jarvis + + + + + + + + + + + + + + + diff --git a/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-mark-symbolic.svg b/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-mark-symbolic.svg new file mode 100644 index 0000000..1d1cfa0 --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-mark-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-mark.svg b/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-mark.svg new file mode 100644 index 0000000..f8084da --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-mark.svg @@ -0,0 +1,6 @@ + + Jarvis + + + + diff --git a/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-wordmark.svg b/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-wordmark.svg new file mode 100644 index 0000000..3d7ce1e --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/brand/icons/jarvis-wordmark.svg @@ -0,0 +1,9 @@ + + Jarvis + + + + + + JARVIS + diff --git a/apps/gnome-extension/jarvis@qvac.local/brand/icons/scalable/apps/io.qvac.Jarvis.Control.svg b/apps/gnome-extension/jarvis@qvac.local/brand/icons/scalable/apps/io.qvac.Jarvis.Control.svg new file mode 100644 index 0000000..8bdac68 --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/brand/icons/scalable/apps/io.qvac.Jarvis.Control.svg @@ -0,0 +1,17 @@ + + Jarvis + + + + + + + + + + + + + + + diff --git a/apps/gnome-extension/jarvis@qvac.local/brand/tokens.json b/apps/gnome-extension/jarvis@qvac.local/brand/tokens.json new file mode 100644 index 0000000..cb997a7 --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/brand/tokens.json @@ -0,0 +1,36 @@ +{ + "name": "Jarvis", + "product": "Jarvis QVAC", + "tagline": "Local voice for your desktop", + "voice": "Precise, warm, local-first. Gold is presence. Cyan is thought.", + "applicationId": "io.qvac.Jarvis.Control", + "uuid": "jarvis@qvac.local", + "colors": { + "gold": "#F4B942", + "goldBright": "#FFE09A", + "goldDeep": "#C48A1A", + "signal": "#4FD2FF", + "ink": "#0B0E14", + "slate": "#16191F", + "paper": "#F6F7FB", + "mute": "#AEB6C8", + "dim": "#9AA8C0" + }, + "accents": [ + { "id": "gold", "label": "Gold", "value": "#F4B942" }, + { "id": "signal", "label": "Signal", "value": "#4FD2FF" }, + { "id": "copper", "label": "Copper", "value": "#E07A3D" }, + { "id": "jade", "label": "Jade", "value": "#3DDC97" }, + { "id": "rose", "label": "Rose", "value": "#F071A8" } + ], + "type": { + "ui": "Adwaita Sans / Cantarell", + "wordmarkTracking": "0.8px", + "wordmarkWeight": 700 + }, + "radius": { + "hud": 16, + "chip": 999, + "row": 8 + } +} diff --git a/apps/gnome-extension/jarvis@qvac.local/extension.js b/apps/gnome-extension/jarvis@qvac.local/extension.js index 6d11d75..65906df 100644 --- a/apps/gnome-extension/jarvis@qvac.local/extension.js +++ b/apps/gnome-extension/jarvis@qvac.local/extension.js @@ -14,7 +14,8 @@ import { ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel, coerceTex const BUS = 'io.qvac.Jarvis'; const PATH = '/io/qvac/Jarvis'; const IFACE = 'io.qvac.Jarvis.Session'; -const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' }; +const DEFAULT_ACCENT = '#F4B942'; +const SIGNAL = '#4FD2FF'; class JarvisProxy { async connect() { @@ -55,17 +56,23 @@ export default class JarvisExtension extends Extension { this._theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); this._stylesheet = Gio.File.new_for_path(`${this.path}/stylesheet.css`); try { this._theme.load_stylesheet(this._stylesheet); } catch (error) { log(`Jarvis stylesheet unavailable: ${error.message}`); } this.settings = this.getSettings(); this.proxy = new JarvisProxy(); - this.popup = new ConversationView({ compact: true }); + this.popup = new ConversationView({ compact: true, brandDir: this.path }); this.osd = new JarvisOsd(); this.cu = new ComputerUseChrome(); this.cu.attach(); - this.session = new SessionPanel(); this.session.attach(); + this.session = new SessionPanel({ brandDir: this.path }); this.session.attach(); this._bindSurface(this.popup); this._bindSurface(this.session.view); this._applyAccessibility(); this._removeShellService = installShellService(); this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant'; this._panelBox = new St.BoxLayout({ style_class: 'jarvis-panel-box', y_align: Clutter.ActorAlign.CENTER }); - this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; + this._mark = new St.Icon({ icon_name: 'audio-input-microphone-symbolic', icon_size: 16, style_class: 'jarvis-panel-mark', y_align: Clutter.ActorAlign.CENTER }); + try { + const file = Gio.File.new_for_path(`${this.path}/brand/icons/jarvis-mark.svg`); + if (file.query_exists(null)) this._mark.gicon = new Gio.FileIcon({ file }); + } catch (error) { log(`Jarvis mark unavailable: ${error.message}`); } + this._glyph = new St.Label({ text: 'Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; + this._panelBox.add_child(this._mark); this._panelBox.add_child(this._glyph); this.osd.attach(this._panelBox); this._indicator.add_child(this._panelBox); @@ -264,17 +271,27 @@ export default class JarvisExtension extends Extension { } _setState(state) { const value = safeText(state); - this._glyph.text = `${GLYPHS[value] || '◯'} Jarvis`; + this._glyph.text = 'Jarvis'; this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; + this._indicator.accessible_name = `Jarvis ${value.toLowerCase()}`; + if (this._panelBox) { + for (const name of ['armed', 'listening', 'speaking', 'thinking', 'sleeping']) { + this._panelBox.remove_style_class_name(`jarvis-state-${name}`); + } + this._panelBox.add_style_class_name(`jarvis-state-${value.toLowerCase()}`); + } this._eachView((view) => view.setState(value)); this.osd.setState(value); } _applyAccent() { - const style = `--jarvis-accent: ${this.settings.get_string('accent-color')};`; + const color = this.settings.get_string('accent-color'); + const accent = /^#[0-9A-Fa-f]{6}$/.test(String(color || '')) ? color : DEFAULT_ACCENT; + const style = `--jarvis-accent: ${accent}; --jarvis-signal: ${SIGNAL};`; this.popup.root.set_style(style); this.session.root.set_style(style); this.osd.root.set_style(style); this.cu.root.set_style(style); + this._panelBox?.set_style(style); } _applyAccessibility() { try { @@ -300,7 +317,7 @@ export default class JarvisExtension extends Extension { this.osd?.destroy(); this.cu?.destroy(); this.session?.destroy(); this.popup?.destroy(); this._indicator?.destroy(); if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} } - this.popup = this.session = this.osd = this.cu = this._indicator = this._glyph = this.proxy = null; + this.popup = this.session = this.osd = this.cu = this._indicator = this._glyph = this._mark = this.proxy = null; } } diff --git a/apps/gnome-extension/jarvis@qvac.local/metadata.json b/apps/gnome-extension/jarvis@qvac.local/metadata.json index 9c4db26..07b89d3 100644 --- a/apps/gnome-extension/jarvis@qvac.local/metadata.json +++ b/apps/gnome-extension/jarvis@qvac.local/metadata.json @@ -1,7 +1,7 @@ { "uuid": "jarvis@qvac.local", - "name": "Jarvis QVAC", - "description": "Local-first QVAC voice assistant in the GNOME top bar", + "name": "Jarvis", + "description": "Local voice for your desktop. Jarvis QVAC lives in the GNOME top bar.", "shell-version": ["46", "47", "48", "49", "50"], "version": 2, "session-modes": ["user"], diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-editor.js b/apps/gnome-extension/jarvis@qvac.local/settings-editor.js index 872cf75..26b66e2 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-editor.js +++ b/apps/gnome-extension/jarvis@qvac.local/settings-editor.js @@ -149,8 +149,9 @@ export class SettingsEditor { button.connect('clicked', () => { for (const field of fields) { this.setValue(field, field.default); this.rows.get(field.key)?._setValue(field.default); } }); reset.add_suffix(button); group.add(reset); page.add(group); } - page(parent, title, groups, iconName, preview = false) { + page(parent, title, groups, iconName, preview = false, intro = null) { const page = new Adw.PreferencesPage({ title, icon_name: iconName }); + if (intro) page.add(intro); const fields = this.fields.filter(field => groups.includes(field.group)); this.controls(page, fields, { preview }); for (const name of groups) { diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-window.js b/apps/gnome-extension/jarvis@qvac.local/settings-window.js index 0bc21e5..6140f71 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-window.js +++ b/apps/gnome-extension/jarvis@qvac.local/settings-window.js @@ -2,17 +2,61 @@ import Adw from 'gi://Adw'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Gtk from 'gi://Gtk'; +import Gdk from 'gi://Gdk'; import { SettingsEditor } from './settings-editor.js'; +import { BRAND, normalizeAccent } from './brand.js'; const BUS = 'io.qvac.Jarvis'; const PATH = '/io/qvac/Jarvis'; const IFACE = 'io.qvac.Jarvis.Session'; const OVERLAY_STYLES = ['tray', 'expanded']; -function entryRow(settings, title, key) { - const row = new Adw.EntryRow({ title }); - row.set_text(settings.get_string(key)); - row.connect('changed', () => settings.set_string(key, row.get_text())); +function applyBrandCss(directory) { + try { + const css = new Gtk.CssProvider(); + css.load_from_path(`${directory}/brand/gtk.css`); + Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); + } catch {} +} + +function brandBanner(directory) { + const row = new Adw.ActionRow({ title: BRAND.product, subtitle: BRAND.tagline }); + try { + const image = Gtk.Image.new_from_file(`${directory}/brand/icons/jarvis-mark.svg`); + image.set_pixel_size(36); + row.add_prefix(image); + } catch {} + row.activatable = false; + return row; +} + +function accentRow(settings) { + const current = normalizeAccent(settings.get_string('accent-color')); + const row = new Adw.ActionRow({ + title: 'Accent color', + subtitle: 'Gold is the Jarvis default. This tints the HUD immediately.', + }); + const box = new Gtk.Box({ spacing: 6, valign: Gtk.Align.CENTER }); + for (const accent of BRAND.accents) { + const button = new Gtk.Button({ tooltip_text: accent.label, valign: Gtk.Align.CENTER }); + button.add_css_class('circular'); + button.add_css_class('jarvis-swatch'); + button.add_css_class(`jarvis-swatch-${accent.id}`); + button.set_size_request(24, 24); + button.connect('clicked', () => settings.set_string('accent-color', accent.value)); + box.append(button); + } + const entry = new Gtk.Entry({ text: current, width_chars: 8, maxlength: 7, valign: Gtk.Align.CENTER }); + entry.connect('changed', () => { + const value = normalizeAccent(entry.get_text(), ''); + if (value) settings.set_string('accent-color', value); + }); + settings.connect('changed::accent-color', () => { + const value = normalizeAccent(settings.get_string('accent-color')); + if (entry.get_text() !== value) entry.set_text(value); + }); + row.add_suffix(box); + row.add_suffix(entry); return row; } @@ -107,11 +151,14 @@ function grantRow() { } export function fillSettingsWindow(window, settings, directory) { - window.set_title('Jarvis QVAC'); + window.set_title(BRAND.product); window.set_default_size(840, 780); window.search_enabled = true; + applyBrandCss(directory); const editor = new SettingsEditor(directory, settings); - const voice = editor.page(window, 'Voice', ['Speech', 'Voice design'], 'audio-speakers-symbolic', true); + const intro = new Adw.PreferencesGroup(); + intro.add(brandBanner(directory)); + const voice = editor.page(window, 'Voice', ['Speech', 'Voice design'], 'audio-speakers-symbolic', true, intro); window.add(voice); const listening = editor.page(window, 'Listening', ['Listening', 'Wake and privacy', 'Detection tuning', 'Audio routing'], 'audio-input-microphone-symbolic'); window.add(listening); @@ -119,7 +166,7 @@ export function fillSettingsWindow(window, settings, directory) { const desktop = editor.page(window, 'Desktop', ['Desktop access', 'Desktop images'], 'preferences-desktop-display-symbolic'); const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts', description: 'These appearance settings take effect immediately.' }); desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey')); - desktopGroup.add(entryRow(settings, 'Accent color', 'accent-color')); + desktopGroup.add(accentRow(settings)); desktopGroup.add(comboRow(settings, 'Desktop layout', 'Tray keeps Jarvis in the top bar; expanded opens a conversation panel', 'overlay-style', OVERLAY_STYLES)); desktop.add(desktopGroup); const grantGroup = new Adw.PreferencesGroup({ title: 'Temporary desktop access' }); diff --git a/apps/gnome-extension/jarvis@qvac.local/stylesheet.css b/apps/gnome-extension/jarvis@qvac.local/stylesheet.css index 7f80f36..2f81838 100644 --- a/apps/gnome-extension/jarvis@qvac.local/stylesheet.css +++ b/apps/gnome-extension/jarvis@qvac.local/stylesheet.css @@ -1,21 +1,61 @@ -.jarvis-panel-box { spacing: 6px; } -.jarvis-panel-glyph { color: var(--jarvis-accent, #F4B942); font-size: 14px; } -.jarvis-panel-state { padding: 1px 8px; border-radius: 999px; background-color: rgba(244, 185, 66, .22); } -.jarvis-panel-state-label { color: var(--jarvis-accent, #F4B942); font-size: 12px; font-weight: bold; } +/* Jarvis brand tokens. Gold is presence; cyan is thought. Accent is overridden live via --jarvis-accent. */ +.jarvis-panel-box { + spacing: 7px; + --jarvis-signal: #4FD2FF; + --jarvis-ink: #0B0E14; + --jarvis-slate: #16191F; + --jarvis-paper: #F6F7FB; + --jarvis-mute: #AEB6C8; + --jarvis-dim: #9AA8C0; +} +.jarvis-panel-mark { color: var(--jarvis-accent, #F4B942); icon-size: 16px; } +.jarvis-panel-glyph { + color: var(--jarvis-accent, #F4B942); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.8px; +} +.jarvis-panel-state { padding: 2px 9px; border-radius: 999px; background-color: alpha(var(--jarvis-accent, #F4B942), .22); } +.jarvis-panel-state-label { color: var(--jarvis-accent, #F4B942); font-size: 11px; font-weight: 700; letter-spacing: 0.3px; } +.jarvis-panel-box.jarvis-state-thinking .jarvis-panel-state, +.jarvis-panel-state.jarvis-state-thinking { background-color: alpha(#4FD2FF, .22); } +.jarvis-panel-box.jarvis-state-thinking .jarvis-panel-state-label, +.jarvis-panel-state.jarvis-state-thinking .jarvis-panel-state-label { color: #4FD2FF; } +.jarvis-panel-box.jarvis-state-sleeping .jarvis-panel-state, +.jarvis-panel-state.jarvis-state-sleeping { background-color: rgba(174, 182, 200, .18); } +.jarvis-panel-box.jarvis-state-sleeping .jarvis-panel-state-label, +.jarvis-panel-state.jarvis-state-sleeping .jarvis-panel-state-label { color: #AEB6C8; } .jarvis-menu { max-width: 360px; } .jarvis-menu-item { padding: 0; } -.jarvis-popup { width: 320px; padding: 12px 14px; spacing: 8px; color: #f6f7fb; } -.jarvis-session { width: 420px; padding: 14px 16px; spacing: 8px; border-radius: 16px; background-color: rgba(11, 14, 20, .94); border: 1px solid rgba(244, 185, 66, .42); color: #f6f7fb; box-shadow: 0 12px 40px rgba(0, 0, 0, .45); } +.jarvis-popup { + width: 320px; + padding: 12px 14px; + spacing: 8px; + color: #f6f7fb; + --jarvis-signal: #4FD2FF; +} +.jarvis-session { + width: 420px; + padding: 14px 16px; + spacing: 8px; + border-radius: 16px; + background-color: rgba(11, 14, 20, .94); + border: 1px solid alpha(var(--jarvis-accent, #F4B942), .42); + color: #f6f7fb; + box-shadow: 0 12px 40px rgba(0, 0, 0, .45); + --jarvis-signal: #4FD2FF; +} .jarvis-popup-header { spacing: 8px; } -.jarvis-title { font-weight: bold; letter-spacing: 0.4px; font-size: 14px; } -.jarvis-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; } +.jarvis-mark { icon-size: 18px; color: var(--jarvis-accent, #F4B942); } +.jarvis-title { font-weight: 700; letter-spacing: 0.6px; font-size: 15px; color: #f6f7fb; } +.jarvis-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; font-weight: 700; letter-spacing: 0.4px; } .jarvis-status-line { color: #aeb6c8; font-size: 12px; } .jarvis-tabs { spacing: 6px; } .jarvis-tab { padding: 4px 10px; border-radius: 999px; background-color: transparent; color: #9aa8c0; font-size: 12px; } .jarvis-tab:hover, .jarvis-tab:focus { color: #f6f7fb; background-color: rgba(255, 255, 255, .08); } -.jarvis-tab-active { background-color: var(--jarvis-accent, #F4B942); color: #16191f; font-weight: bold; } +.jarvis-tab-active { background-color: var(--jarvis-accent, #F4B942); color: #16191f; font-weight: 700; } .jarvis-thinking { color: #aeb6c8; font-size: 12px; padding: 8px 10px; border-left: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .06); border-radius: 8px; } -.jarvis-notice { color: #f4b942; font-size: 12px; } +.jarvis-notice { color: var(--jarvis-accent, #F4B942); font-size: 12px; } .jarvis-confirm { spacing: 8px; padding: 4px 0; } .jarvis-confirm-actions { spacing: 6px; } .jarvis-confirm-label { color: #f6f7fb; font-size: 13px; } @@ -26,22 +66,26 @@ .jarvis-session-thinking { height: 200px; } .jarvis-transcript { spacing: 6px; } .jarvis-row { padding: 6px 10px; border-radius: 8px; font-size: 13px; } -.jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); background-color: rgba(244, 185, 66, .08); } +.jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); background-color: alpha(var(--jarvis-accent, #F4B942), .08); } .jarvis-row-jarvis { border-right: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .05); } .jarvis-row-tool { background-color: transparent; color: #9aa8c0; font-size: 12px; padding: 2px 6px; } .jarvis-chip-scroll { height: 32px; } .jarvis-chips { spacing: 6px; } .jarvis-chip { padding: 5px 9px; border-radius: 999px; background-color: rgba(255, 255, 255, .08); } -.jarvis-chip:hover, .jarvis-chip:focus { background-color: rgba(244, 185, 66, .25); } +.jarvis-chip:hover, .jarvis-chip:focus { background-color: alpha(var(--jarvis-accent, #F4B942), .25); } .jarvis-chip-quiet { background-color: transparent; color: #aeb6c8; padding: 5px 8px; } .jarvis-chip-quiet:hover, .jarvis-chip-quiet:focus { color: #f6f7fb; background-color: rgba(255, 255, 255, .08); } -.jarvis-talk { background-color: #F4B942; color: #16191f; font-weight: bold; padding: 8px 14px; } +.jarvis-talk { background-color: var(--jarvis-accent, #F4B942); color: #16191f; font-weight: 700; padding: 8px 14px; } +.jarvis-talk:hover, .jarvis-talk:focus { background-color: #ffe09a; } .jarvis-talk:active { background-color: #ffe09a; } .jarvis-controls { spacing: 6px; } .jarvis-settings { font-size: 12px; padding: 4px 8px; } .jarvis-popup StEntry, .jarvis-session StEntry { border-radius: 10px; padding: 8px 10px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); } -.jarvis-popup StEntry:focus, .jarvis-session StEntry:focus { border-color: #F4B942; } +.jarvis-popup StEntry:focus, .jarvis-session StEntry:focus { border-color: var(--jarvis-accent, #F4B942); } .jarvis-cu { padding: 8px 12px; spacing: 4px; border-radius: 12px; background-color: rgba(11, 14, 20, .82); border: 1px solid rgba(79, 210, 255, .4); } .jarvis-job, .jarvis-target, .jarvis-cu-step { color: #4FD2FF; font-size: 12px; } .jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; } .jarvis-high-contrast { border-width: 2px; background-color: #000; } +.jarvis-high-contrast .jarvis-title, +.jarvis-high-contrast .jarvis-panel-glyph, +.jarvis-high-contrast .jarvis-status-line { color: #fff; } diff --git a/apps/gnome-extension/jarvis@qvac.local/ui.js b/apps/gnome-extension/jarvis@qvac.local/ui.js index c1ac7f7..ef80319 100644 --- a/apps/gnome-extension/jarvis@qvac.local/ui.js +++ b/apps/gnome-extension/jarvis@qvac.local/ui.js @@ -1,9 +1,26 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import St from 'gi://St'; import Clutter from 'gi://Clutter'; import Pango from 'gi://Pango'; +function brandMark(size = 16, directory = '') { + if (typeof Gio === 'undefined' || typeof St.Icon !== 'function' || !directory) return null; + try { + const file = Gio.File.new_for_path(`${directory}/brand/icons/jarvis-mark.svg`); + if (!file.query_exists(null)) return null; + return new St.Icon({ + gicon: new Gio.FileIcon({ file }), + icon_size: size, + style_class: 'jarvis-mark', + y_align: Clutter.ActorAlign.CENTER, + }); + } catch { + return null; + } +} + export const STATES = new Set(['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']); export const POPUP_ROWS = 8; export const SESSION_WIDTH = 420; @@ -56,17 +73,19 @@ function wrapLabel(label) { } export class ConversationView { - constructor({ compact = true, maxRows = compact ? POPUP_ROWS : 40 } = {}) { + constructor({ compact = true, maxRows = compact ? POPUP_ROWS : 40, brandDir = '' } = {}) { this.compact = compact; this.maxRows = maxRows; this.root = new St.BoxLayout({ style_class: compact ? 'jarvis-popup' : 'jarvis-session', vertical: true, reactive: true, can_focus: true, x_expand: true }); this.root.accessible_name = compact ? 'Jarvis voice assistant' : 'Jarvis conversation'; this.header = new St.BoxLayout({ style_class: 'jarvis-popup-header', x_expand: true }); + this.mark = brandMark(compact ? 16 : 20, brandDir); this.title = new St.Label({ text: 'Jarvis', style_class: 'jarvis-title', x_align: Clutter.ActorAlign.START }); this.title.accessible_name = 'Jarvis status'; this.status = new St.Label({ text: 'LOCAL', style_class: 'jarvis-local' }); this.status.accessible_name = 'Local model status'; if (this.status.clutter_text) this.status.clutter_text.ellipsize = Pango.EllipsizeMode.END; + if (this.mark) this.header.add_child(this.mark); this.header.add_child(this.title); this.header.add_child(new St.Widget({ x_expand: true })); this.header.add_child(this.status); @@ -418,6 +437,10 @@ export class JarvisOsd { this.label.text = text; this.root.visible = Boolean(text); this.root.accessible_name = text ? `Jarvis ${text}` : 'Jarvis status'; + for (const name of ['armed', 'listening', 'speaking', 'thinking', 'sleeping']) { + this.root.remove_style_class_name(`jarvis-state-${name}`); + } + if (this._state) this.root.add_style_class_name(`jarvis-state-${this._state.toLowerCase()}`); } _clearTimer() { if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } } hide() { this._wake = false; this._clearTimer(); this.root.visible = false; } @@ -463,8 +486,8 @@ export class ComputerUseChrome { } export class SessionPanel { - constructor() { - this.view = new ConversationView({ compact: false }); + constructor({ brandDir = '' } = {}) { + this.view = new ConversationView({ compact: false, brandDir }); this.root = this.view.root; this.minimized = true; } diff --git a/docs/settings.md b/docs/settings.md index 1d79053..1a02e17 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -157,7 +157,8 @@ and WebP quality tune observation detail and processing cost. The Desktop page also exposes the GNOME hotkey, accent color, and tray versus expanded layout. These retain their GSettings keys: `hotkey`, `accent-color`, -and `overlay-style`. +and `overlay-style`. Gold `#F4B942` is the brand default. Signal, Copper, Jade, +and Rose are presets; a custom hex value is also accepted. Previously displayed controls for chimes, legacy input fallback, enrollment, startup, memory ingestion, Lab toggles, transcript retention, and disabling diff --git a/packaging/install.sh b/packaging/install.sh index 42e30ed..e946183 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -194,6 +194,28 @@ Exec=/bin/false SystemdService=jarvisd.service EOF log "wrote ${DBUS_DIR}/io.qvac.Jarvis.service" +ICON_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor/scalable/apps" +APP_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/applications" +mkdir -p "${ICON_HOME}" "${APP_HOME}" +ICON_SRC="${ROOT_DIR}/apps/gnome-extension/jarvis@qvac.local/brand/icons/io.qvac.Jarvis.Control.svg" +if [[ -f "${ICON_SRC}" ]]; then + cp "${ICON_SRC}" "${ICON_HOME}/io.qvac.Jarvis.Control.svg" + log "installed application icon io.qvac.Jarvis.Control" +fi +cat > "${APP_HOME}/io.qvac.Jarvis.Control.desktop" </dev/null || true systemctl --user disable --now jarvisd.service 2>/dev/null || true rm -f "${HOME}/.config/systemd/user/jarvisd.service" rm -f "${XDG_DATA_HOME:-${HOME}/.local/share}/dbus-1/services/io.qvac.Jarvis.service" +rm -f "${XDG_DATA_HOME:-${HOME}/.local/share}/applications/io.qvac.Jarvis.Control.desktop" +rm -f "${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor/scalable/apps/io.qvac.Jarvis.Control.svg" rm -rf "${HOME}/.local/share/jarvis-qvac" "${HOME}/.local/share/gnome-shell/extensions/jarvis@qvac.local" systemctl --user daemon-reload 2>/dev/null || true if [[ "${2:-}" == "--purge-data" ]]; then diff --git a/test/brand.test.js b/test/brand.test.js new file mode 100644 index 0000000..00d7c4f --- /dev/null +++ b/test/brand.test.js @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; + +const root = new URL('../apps/gnome-extension/jarvis@qvac.local/', import.meta.url); +const tokens = JSON.parse(readFileSync(new URL('brand/tokens.json', root), 'utf8')); +const brandSource = readFileSync(new URL('brand.js', root), 'utf8'); +const stylesheet = readFileSync(new URL('stylesheet.css', root), 'utf8'); +const gtkCss = readFileSync(new URL('brand/gtk.css', root), 'utf8'); +const metadata = JSON.parse(readFileSync(new URL('metadata.json', root), 'utf8')); +const settingsWindow = readFileSync(new URL('settings-window.js', root), 'utf8'); +const extensionSource = readFileSync(new URL('extension.js', root), 'utf8'); +const install = readFileSync(new URL('../packaging/install.sh', import.meta.url), 'utf8'); +const uninstall = readFileSync(new URL('../packaging/uninstall.sh', import.meta.url), 'utf8'); + + const icons = [ + 'brand/icons/jarvis-mark.svg', + 'brand/icons/jarvis-mark-symbolic.svg', + 'brand/icons/jarvis-wordmark.svg', + 'brand/icons/io.qvac.Jarvis.Control.svg', + 'brand/icons/scalable/apps/io.qvac.Jarvis.Control.svg', + ]; + +test('brand tokens match the coded gold and signal palette', () => { + assert.equal(tokens.name, 'Jarvis'); + assert.equal(tokens.product, 'Jarvis QVAC'); + assert.equal(tokens.tagline, 'Local voice for your desktop'); + assert.equal(tokens.colors.gold, '#F4B942'); + assert.equal(tokens.colors.signal, '#4FD2FF'); + assert.equal(tokens.colors.ink, '#0B0E14'); + assert.equal(tokens.accents[0].id, 'gold'); + assert.match(brandSource, /gold: '#F4B942'/); + assert.match(brandSource, /signal: '#4FD2FF'/); + assert.match(brandSource, /tagline: 'Local voice for your desktop'/); +}); + +test('brand icons ship as SVG marks with gold visor and cyan core', () => { + for (const icon of icons) { + const path = new URL(icon, root); + assert.equal(existsSync(path), true, icon); + const svg = readFileSync(path, 'utf8'); + assert.match(svg, / { + assert.match(stylesheet, /--jarvis-accent/); + assert.match(stylesheet, /--jarvis-signal: #4FD2FF/); + assert.match(stylesheet, /\.jarvis-panel-mark/); + assert.match(stylesheet, /\.jarvis-talk \{ background-color: var\(--jarvis-accent/); + assert.match(gtkCss, /@define-color jarvis_gold #F4B942/); + assert.match(gtkCss, /button\.jarvis-swatch-gold/); + assert.equal(metadata.name, 'Jarvis'); + assert.match(metadata.description, /Local voice for your desktop/); + assert.match(settingsWindow, /from '\.\/brand\.js'/); + assert.match(settingsWindow, /brandBanner/); + assert.match(settingsWindow, /accentRow/); + assert.match(extensionSource, /brand\/icons\/jarvis-mark\.svg/); + assert.match(extensionSource, /text: 'Jarvis'/); + assert.match(install, /io\.qvac\.Jarvis\.Control\.desktop/); + assert.match(install, /Local voice for your desktop/); + assert.match(uninstall, /io\.qvac\.Jarvis\.Control\.desktop/); +}); + +test('accent helper rejects invalid hex and keeps Gold', async () => { + const { normalizeAccent, DEFAULT_ACCENT, BRAND } = await import(new URL('../apps/gnome-extension/jarvis@qvac.local/brand.js', import.meta.url)); + assert.equal(DEFAULT_ACCENT, '#F4B942'); + assert.equal(normalizeAccent('#4FD2FF'), '#4FD2FF'); + assert.equal(normalizeAccent('not-a-color'), '#F4B942'); + assert.equal(normalizeAccent(''), '#F4B942'); + assert.equal(BRAND.accents.length, 5); +}); diff --git a/test/gnome-extension.test.js b/test/gnome-extension.test.js index b17e59c..ec905e8 100644 --- a/test/gnome-extension.test.js +++ b/test/gnome-extension.test.js @@ -51,6 +51,7 @@ function harness() { grab_key_focus() {} set_style() {} add_style_class_name() {} + remove_style_class_name() {} get_text() { return this.text || ''; } set_text(value) { this.text = value; } } @@ -77,7 +78,7 @@ function harness() { const context = vm.createContext({ Extension: class {}, global: { stage: { get_key_focus() { return null; } } }, - St: { BoxLayout, Label, Widget: Actor, Button: Actor, Entry: Actor, ScrollView, PolicyType: { NEVER: 0, AUTOMATIC: 1 } }, + St: { BoxLayout, Label, Icon: Actor, Widget: Actor, Button: Actor, Entry: Actor, ScrollView, PolicyType: { NEVER: 0, AUTOMATIC: 1 } }, Clutter: { ActorAlign: { CENTER: 0, START: 1 }, EVENT_STOP: 1, EVENT_PROPAGATE: 0, KEY_space: 32, KEY_Return: 65293, KEY_Escape: 65307 }, Pango: { WrapMode: { WORD_CHAR: 2 }, EllipsizeMode: { NONE: 0, END: 3 } }, PopupMenu: { @@ -233,6 +234,8 @@ test('GNOME GI imports expose default namespaces and panel has a real menu', () assert.match(uiSource, /Open conversation/); assert.match(extensionSource, /PopupMenuSection/); assert.match(extensionSource, /osd\.attach\(this\._panelBox\)/); + assert.match(extensionSource, /jarvis-panel-mark/); + assert.match(extensionSource, /brand\/icons\/jarvis-mark\.svg/); assert.match(uiSource, /jarvis-panel-state/); assert.doesNotMatch(uiSource, /style_class: 'jarvis-osd'/); assert.match(extensionSource, /_openSettings/); diff --git a/test/install.test.js b/test/install.test.js index 4d28356..6f0427f 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -101,6 +101,10 @@ exit 0 assert.equal(existsSync(path.join(home, '.local/share/jarvis-qvac.staging.oldfail')), false); assert.equal(existsSync(path.join(extDir, 'old-extension.js')), false); assert.equal(existsSync(path.join(extDir, 'extension.js')), true); + assert.equal(existsSync(path.join(extDir, 'brand/icons/jarvis-mark.svg')), true); + assert.equal(existsSync(path.join(home, '.local/share/applications/io.qvac.Jarvis.Control.desktop')), true); + assert.equal(existsSync(path.join(home, '.local/share/icons/hicolor/scalable/apps/io.qvac.Jarvis.Control.svg')), true); + assert.match(await readFile(path.join(home, '.local/share/applications/io.qvac.Jarvis.Control.desktop'), 'utf8'), /Local voice for your desktop/); assert.equal(await readFile(path.join(appDir, 'daemon-marker'), 'utf8'), 'new-tree\n'); assert.equal(await readFile(path.join(configDir, 'config.json'), 'utf8'), '{"wakePhrase":"hey jarvis","ttsEnabled":true}\n'); const unit = await readFile(path.join(home, '.config/systemd/user/jarvisd.service'), 'utf8');