The Loyaly mark everywhere a person sees the product
Brand assets in brand/ (the 512px mark, sizes for each surface, a multi-size .ico). Windows executables carry it as a compiled-in resource (rsrc_windows_amd64.syso from go-winres) so Explorer, the taskbar and the installer show it; installer/build.ps1 therefore uses a plain go build rather than wails build, which would add a second copy and fail the link. The tray icon is the mark with a state dot over its corner - a plain coloured circle read as a generic status light among other icons - rendered from the embedded PNG at 32px so it survives 150% scaling. The desktop app's login, setup and sidebar marks, the head-office web app's mark and favicon, and the engine dashboard's favicon are the same file. Also found while packaging: no wheel so far shipped static/, so the engine's own dashboard at :8010 on a Windows source install would have failed with a missing file. package-data now includes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
BIN
agent/cmd/behavision-setup/rsrc_windows_amd64.syso
Normal file
BIN
agent/rsrc_windows_amd64.syso
Normal file
@@ -155,6 +155,15 @@ def create_app(engine: Engine) -> FastAPI:
|
|||||||
def dashboard() -> str:
|
def dashboard() -> str:
|
||||||
return (_STATIC / "dashboard.html").read_text(encoding="utf-8")
|
return (_STATIC / "dashboard.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
@app.get("/static/favicon.png")
|
||||||
|
def favicon() -> Response:
|
||||||
|
# The one static asset besides the page itself. Served explicitly
|
||||||
|
# rather than mounting the directory: nothing else in there is meant
|
||||||
|
# to be reachable, and a mount would make that a matter of what lands
|
||||||
|
# in the folder.
|
||||||
|
return Response((_STATIC / "favicon.png").read_bytes(), media_type="image/png",
|
||||||
|
headers={"cache-control": "public, max-age=86400"})
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
from .paths import describe
|
from .paths import describe
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Behavision</title>
|
<title>Behavision</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
* { box-sizing: border-box; margin: 0; }
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
|||||||
BIN
behavision/static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
brand/loyaly-icon-128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
brand/loyaly-icon-16.png
Normal file
|
After Width: | Height: | Size: 732 B |
BIN
brand/loyaly-icon-256.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
brand/loyaly-icon-32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
brand/loyaly-icon-48.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
brand/loyaly-icon-512.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
brand/loyaly-icon-64.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
brand/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
brand/loyaly.ico
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
desktop/build/appicon.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
68
desktop/build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>{{.Info.ProductName}}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.OutputFilename}}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.wails.{{safeBundleID .Name}}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>{{.Info.Comments}}</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>iconfile</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
{{if .Info.FileAssociations}}
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Ext}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
<key>CFBundleTypeIconFile</key>
|
||||||
|
<string>{{.IconName}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
{{if .Info.Protocols}}
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.wails.{{.Scheme}}</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Scheme}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
63
desktop/build/darwin/Info.plist
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>{{.Info.ProductName}}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.OutputFilename}}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.wails.{{safeBundleID .Name}}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>{{.Info.Comments}}</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>iconfile</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
{{if .Info.FileAssociations}}
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Ext}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
<key>CFBundleTypeIconFile</key>
|
||||||
|
<string>{{.IconName}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
{{if .Info.Protocols}}
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.wails.{{.Scheme}}</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Scheme}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
desktop/build/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 67 KiB |
15
desktop/build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "{{.Info.ProductVersion}}"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0000": {
|
||||||
|
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||||
|
"CompanyName": "{{.Info.CompanyName}}",
|
||||||
|
"FileDescription": "{{.Info.ProductName}}",
|
||||||
|
"LegalCopyright": "{{.Info.Copyright}}",
|
||||||
|
"ProductName": "{{.Info.ProductName}}",
|
||||||
|
"Comments": "{{.Info.Comments}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
desktop/build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
<asmv3:application>
|
||||||
|
<asmv3:windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||||
|
</asmv3:windowsSettings>
|
||||||
|
</asmv3:application>
|
||||||
|
</assembly>
|
||||||
1
desktop/frontend/dist/assets/index-W0yLGths.css
vendored
Normal file
BIN
desktop/frontend/dist/assets/loyaly-mark-Ch-P3VSF.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
4
desktop/frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Behavision</title>
|
<title>Behavision</title>
|
||||||
<script type="module" crossorigin src="./assets/index-Dxm9O68-.js"></script>
|
<script type="module" crossorigin src="./assets/index-MXXBc3qm.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-Bml5ogS2.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-W0yLGths.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { api, isDesktop, message } from './bridge.js'
|
import { api, isDesktop, message } from './bridge.js'
|
||||||
import { usePolled } from './hooks.js'
|
import { usePolled } from './hooks.js'
|
||||||
import * as Icon from './ui/icons.jsx'
|
import * as Icon from './ui/icons.jsx'
|
||||||
|
import logo from './assets/loyaly-mark.png'
|
||||||
import Login from './views/Login.jsx'
|
import Login from './views/Login.jsx'
|
||||||
import Setup from './views/Setup.jsx'
|
import Setup from './views/Setup.jsx'
|
||||||
import Live from './views/Live.jsx'
|
import Live from './views/Live.jsx'
|
||||||
@@ -49,7 +50,7 @@ export default function App() {
|
|||||||
// error nobody will read.
|
// error nobody will read.
|
||||||
return (
|
return (
|
||||||
<div className="login"><div className="box">
|
<div className="login"><div className="box">
|
||||||
<span className="mark"><Icon.Shield size={20} /></span>
|
<span className="mark"><img src={logo} alt="" /></span>
|
||||||
<h1>Behavision</h1>
|
<h1>Behavision</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
This is the Behavision window running outside the app, so it has no
|
This is the Behavision window running outside the app, so it has no
|
||||||
@@ -80,7 +81,7 @@ export default function App() {
|
|||||||
<div className="shell">
|
<div className="shell">
|
||||||
<aside className="side">
|
<aside className="side">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<span className="mark"><Icon.Shield size={17} /></span>
|
<span className="mark"><img src={logo} alt="" /></span>
|
||||||
<div className="id">
|
<div className="id">
|
||||||
<h1>Behavision</h1>
|
<h1>Behavision</h1>
|
||||||
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
|
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
|
||||||
|
|||||||
BIN
desktop/frontend/src/assets/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
@@ -105,11 +105,8 @@ input, textarea { user-select: text; }
|
|||||||
display: flex; flex-direction: column; min-height: 0;
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
}
|
}
|
||||||
.side .brand { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); }
|
.side .brand { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); }
|
||||||
.side .brand .mark {
|
.side .brand .mark { width: 30px; height: 30px; flex: none; display: grid; place-items: center; }
|
||||||
width: 30px; height: 30px; border-radius: 9px; flex: none; display: grid; place-items: center;
|
.side .brand .mark img, .login .mark img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||||
background: linear-gradient(160deg, var(--accent-2), var(--s2));
|
|
||||||
border: 1px solid #1B4C5A; color: var(--accent);
|
|
||||||
}
|
|
||||||
.side .brand .id { min-width: 0; }
|
.side .brand .id { min-width: 0; }
|
||||||
.side .brand h1 { font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -.012em; line-height: 1.2; }
|
.side .brand h1 { font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -.012em; line-height: 1.2; }
|
||||||
.side .brand p { font-size: 11.5px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.side .brand p { font-size: 11.5px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
@@ -411,11 +408,7 @@ tbody tr[role="button"]:hover, tbody tr.clickable:hover { background: var(--s2);
|
|||||||
width: 100%; max-width: 396px; background: var(--s1); border: 1px solid var(--line);
|
width: 100%; max-width: 396px; background: var(--s1); border: 1px solid var(--line);
|
||||||
border-radius: var(--r-lg); padding: var(--sp-7); box-shadow: var(--shadow-lg);
|
border-radius: var(--r-lg); padding: var(--sp-7); box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
.login .mark {
|
.login .mark { width: 44px; height: 44px; margin-bottom: var(--sp-4); display: grid; place-items: center; }
|
||||||
width: 38px; height: 38px; border-radius: 11px; margin-bottom: var(--sp-4);
|
|
||||||
display: grid; place-items: center; color: var(--accent);
|
|
||||||
background: linear-gradient(160deg, var(--accent-2), var(--s2)); border: 1px solid #1B4C5A;
|
|
||||||
}
|
|
||||||
.login h1 { font-family: var(--font-display); font-size: 21px; font-weight: 600; letter-spacing: -.022em; }
|
.login h1 { font-family: var(--font-display); font-size: 21px; font-weight: 600; letter-spacing: -.022em; }
|
||||||
.login .lead { margin: 6px 0 var(--sp-5); }
|
.login .lead { margin: 6px 0 var(--sp-5); }
|
||||||
.login .btn { width: 100%; justify-content: center; margin-top: var(--sp-1); }
|
.login .btn { width: 100%; justify-content: center; margin-top: var(--sp-1); }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { api, message } from '../bridge.js'
|
import { api, message } from '../bridge.js'
|
||||||
import * as Icon from '../ui/icons.jsx'
|
import * as Icon from '../ui/icons.jsx'
|
||||||
|
import logo from '../assets/loyaly-mark.png'
|
||||||
|
|
||||||
// The gate. Nothing else in the app is reachable until this succeeds, because
|
// The gate. Nothing else in the app is reachable until this succeeds, because
|
||||||
// the broker credentials and the customer database both live behind it.
|
// the broker credentials and the customer database both live behind it.
|
||||||
@@ -25,7 +26,7 @@ export default function Login({ onDone }) {
|
|||||||
return (
|
return (
|
||||||
<div className="login">
|
<div className="login">
|
||||||
<div className="box">
|
<div className="box">
|
||||||
<span className="mark"><Icon.Shield size={20} /></span>
|
<span className="mark"><img src={logo} alt="" /></span>
|
||||||
<h1>Behavision</h1>
|
<h1>Behavision</h1>
|
||||||
<p className="lead">Sign in to connect this PC to your store.</p>
|
<p className="lead">Sign in to connect this PC to your store.</p>
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { api, message } from '../bridge.js'
|
import { api, message } from '../bridge.js'
|
||||||
import * as Icon from '../ui/icons.jsx'
|
import * as Icon from '../ui/icons.jsx'
|
||||||
|
import logo from '../assets/loyaly-mark.png'
|
||||||
|
|
||||||
// Linking this PC to a shop — the first thing that happens on a new install,
|
// Linking this PC to a shop — the first thing that happens on a new install,
|
||||||
// and until now the one thing the app could not do.
|
// and until now the one thing the app could not do.
|
||||||
@@ -42,7 +43,7 @@ export default function Setup({ onDone, onCancel }) {
|
|||||||
return (
|
return (
|
||||||
<div className="login">
|
<div className="login">
|
||||||
<div className="box">
|
<div className="box">
|
||||||
<span className="mark"><Icon.Link size={20} /></span>
|
<span className="mark"><img src={logo} alt="" /></span>
|
||||||
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
|
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
Type the installation code for this shop. You only do this once.
|
Type the installation code for this shop. You only do this once.
|
||||||
|
|||||||
@@ -2,28 +2,32 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/png"
|
"image/png"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
// iconFor renders the tray icon at run time rather than embedding four PNGs.
|
// iconFor renders the tray icon at run time: the Loyaly mark with a state
|
||||||
|
// dot in the corner. Green, amber, red or grey is the only thing a taskbar
|
||||||
|
// conveys at this size, and the mark is what makes it OURS among a row of
|
||||||
|
// other icons - a plain coloured circle read as a generic status light.
|
||||||
//
|
//
|
||||||
// A 16x16 filled circle is all the taskbar shows at this size, and generating
|
// The mark is embedded once at 128px and scaled down here, so the four
|
||||||
// it means the four states cannot drift apart visually or have one file go
|
// states cannot drift apart and no file can go missing from a build.
|
||||||
// missing from a build.
|
|
||||||
//
|
//
|
||||||
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
|
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
|
||||||
// to a temp file and, on Windows, hands the path to LoadImageW with
|
// to a temp file and, on Windows, hands the path to LoadImageW with
|
||||||
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
|
// IMAGE_ICON|LR_LOADFROMFILE - which decodes .ico and nothing else. A PNG
|
||||||
// there returns 0, systray logs "unable to set icon", and the product ships
|
// there returns 0, systray logs "unable to set icon", and the product ships
|
||||||
// with no tray icon at all: the one control surface a shop manager has.
|
// with no tray icon at all: the one control surface a shop manager has.
|
||||||
func iconFor(state string) []byte {
|
func iconFor(state string) []byte {
|
||||||
img := circle(colorFor(state))
|
img := trayImage(colorFor(state))
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return encodeICO(img)
|
return encodeICO(img)
|
||||||
}
|
}
|
||||||
@@ -46,30 +50,93 @@ func colorFor(state string) color.RGBA {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconSize = 16
|
// 32px rather than 16: Windows shows 16 at 100% scaling and 24 at 150%, and
|
||||||
|
// scaling a 32 down looks right at both, where a 16 scaled up looks like 2005.
|
||||||
|
const iconSize = 32
|
||||||
|
|
||||||
func circle(c color.RGBA) *image.RGBA {
|
//go:embed tray-logo.png
|
||||||
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
var trayLogoPNG []byte
|
||||||
const r = 6.5
|
|
||||||
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
|
var (
|
||||||
|
trayLogoOnce sync.Once
|
||||||
|
trayLogo *image.RGBA
|
||||||
|
)
|
||||||
|
|
||||||
|
// logo is the embedded mark, decoded once and box-filtered down to iconSize.
|
||||||
|
// A box filter rather than nearest-neighbour: 128->32 is an exact 4x4 average
|
||||||
|
// and nearest would drop three pixels in four, which shreds the thin outline.
|
||||||
|
func logo() *image.RGBA {
|
||||||
|
trayLogoOnce.Do(func() {
|
||||||
|
src, err := png.Decode(bytes.NewReader(trayLogoPNG))
|
||||||
|
if err != nil {
|
||||||
|
trayLogo = image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := src.Bounds()
|
||||||
|
f := b.Dx() / iconSize
|
||||||
|
out := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||||
|
for y := 0; y < iconSize; y++ {
|
||||||
|
for x := 0; x < iconSize; x++ {
|
||||||
|
var r, g, bl, a uint64
|
||||||
|
for dy := 0; dy < f; dy++ {
|
||||||
|
for dx := 0; dx < f; dx++ {
|
||||||
|
// Premultiplied so transparent pixels do not drag the
|
||||||
|
// colour of the edge towards black.
|
||||||
|
pr, pg, pb, pa := src.At(b.Min.X+x*f+dx, b.Min.Y+y*f+dy).RGBA()
|
||||||
|
r += uint64(pr)
|
||||||
|
g += uint64(pg)
|
||||||
|
bl += uint64(pb)
|
||||||
|
a += uint64(pa)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := uint64(f * f)
|
||||||
|
out.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: uint8(r / n >> 8), G: uint8(g / n >> 8), B: uint8(bl / n >> 8), A: uint8(a / n >> 8),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trayLogo = out
|
||||||
|
})
|
||||||
|
return trayLogo
|
||||||
|
}
|
||||||
|
|
||||||
|
// trayImage is the mark with a state dot over its bottom-right corner, ringed
|
||||||
|
// so it reads against both the yellow of the mark and a dark taskbar.
|
||||||
|
func trayImage(c color.RGBA) *image.RGBA {
|
||||||
|
base := logo()
|
||||||
|
img := image.NewRGBA(base.Bounds())
|
||||||
|
copy(img.Pix, base.Pix)
|
||||||
|
const r = 6.0
|
||||||
|
cx, cy := float64(iconSize)-r-0.5, float64(iconSize)-r-0.5
|
||||||
|
ring := color.RGBA{R: 0x11, G: 0x14, B: 0x18, A: 0xFF}
|
||||||
for y := 0; y < iconSize; y++ {
|
for y := 0; y < iconSize; y++ {
|
||||||
for x := 0; x < iconSize; x++ {
|
for x := 0; x < iconSize; x++ {
|
||||||
dx, dy := float64(x)-cx, float64(y)-cy
|
dx, dy := float64(x)-cx, float64(y)-cy
|
||||||
d := dx*dx + dy*dy
|
d := dx*dx + dy*dy
|
||||||
switch {
|
switch {
|
||||||
case d <= (r-1)*(r-1):
|
case d <= (r-1.5)*(r-1.5):
|
||||||
img.SetRGBA(x, y, c)
|
img.SetRGBA(x, y, c)
|
||||||
case d <= r*r:
|
case d <= r*r:
|
||||||
|
img.SetRGBA(x, y, ring)
|
||||||
|
case d <= (r+1)*(r+1):
|
||||||
// One-pixel feathered edge; a hard-aliased circle looks broken
|
// One-pixel feathered edge; a hard-aliased circle looks broken
|
||||||
// next to every other icon in the tray.
|
// next to every other icon in the tray.
|
||||||
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
|
a := uint8(255 * ((r+1)*(r+1) - d) / ((r+1)*(r+1) - r*r))
|
||||||
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
|
bg := img.RGBAAt(x, y)
|
||||||
|
img.SetRGBA(x, y, blend(bg, ring, a))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func blend(under, over color.RGBA, a uint8) color.RGBA {
|
||||||
|
fa := float64(a) / 255
|
||||||
|
mix := func(u, o uint8) uint8 { return uint8(float64(u)*(1-fa) + float64(o)*fa) }
|
||||||
|
ua := float64(under.A)/255*(1-fa) + fa
|
||||||
|
return color.RGBA{R: mix(under.R, over.R), G: mix(under.G, over.G), B: mix(under.B, over.B), A: uint8(ua * 255)}
|
||||||
|
}
|
||||||
|
|
||||||
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
|
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
|
||||||
//
|
//
|
||||||
// Vista and later also accept a PNG stored inside the .ico container, which
|
// Vista and later also accept a PNG stored inside the .ico container, which
|
||||||
@@ -94,8 +161,8 @@ func encodeICO(img *image.RGBA) []byte {
|
|||||||
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
|
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
|
||||||
b.WriteByte(byte(w))
|
b.WriteByte(byte(w))
|
||||||
b.WriteByte(byte(h))
|
b.WriteByte(byte(h))
|
||||||
b.WriteByte(0) // palette size: none
|
b.WriteByte(0) // palette size: none
|
||||||
b.WriteByte(0) // reserved
|
b.WriteByte(0) // reserved
|
||||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
|
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
|
||||||
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
|
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
|
||||||
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
|
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// nothing — and nothing on a Mac could notice. These tests are the substitute
|
// nothing — and nothing on a Mac could notice. These tests are the substitute
|
||||||
// for the Windows box we do not have.
|
// for the Windows box we do not have.
|
||||||
func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||||
b := encodeICO(circle(colorFor("ok")))
|
b := encodeICO(trayImage(colorFor("ok")))
|
||||||
if len(b) < 22 {
|
if len(b) < 22 {
|
||||||
t.Fatalf("far too short: %d bytes", len(b))
|
t.Fatalf("far too short: %d bytes", len(b))
|
||||||
}
|
}
|
||||||
@@ -48,12 +48,13 @@ func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
|
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
|
||||||
want := colorFor("error") // red: distinguishable from B and G if swapped
|
want := colorFor("error") // red: distinguishable from B and G if swapped
|
||||||
img := circle(want)
|
img := trayImage(want)
|
||||||
b := encodeICO(img)
|
b := encodeICO(img)
|
||||||
// Centre of the circle, which is solid fill. Bottom-up means image row
|
// Centre of the state dot, which is solid fill. Bottom-up means image row
|
||||||
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
|
// y lands at DIB row iconSize-1-y counting from the start.
|
||||||
row := iconSize - 1 - iconSize/2
|
x, y := iconSize-6-1, iconSize-6-1
|
||||||
i := 22 + 40 + (row*iconSize+iconSize/2)*4
|
row := iconSize - 1 - y
|
||||||
|
i := 22 + 40 + (row*iconSize+x)*4
|
||||||
got := b[i : i+4]
|
got := b[i : i+4]
|
||||||
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
|
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
|
||||||
t.Errorf("centre pixel = % x, want % x (BGRA)",
|
t.Errorf("centre pixel = % x, want % x (BGRA)",
|
||||||
|
|||||||
BIN
desktop/rsrc_windows_amd64.syso
Normal file
BIN
desktop/tray-logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -31,6 +31,8 @@
|
|||||||
#define MyAppExeName "Behavision.exe"
|
#define MyAppExeName "Behavision.exe"
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
|
; The Loyaly mark, on the installer and in Add/Remove Programs.
|
||||||
|
SetupIconFile=..\brand\loyaly.ico
|
||||||
AppId={{7C4B9E2A-3F51-4C86-9D0A-B1E7A2F65D11}
|
AppId={{7C4B9E2A-3F51-4C86-9D0A-B1E7A2F65D11}
|
||||||
AppName={#MyAppName}
|
AppName={#MyAppName}
|
||||||
AppVersion={#MyAppVersion}
|
AppVersion={#MyAppVersion}
|
||||||
|
|||||||
@@ -66,15 +66,13 @@ npm run build
|
|||||||
Pop-Location
|
Pop-Location
|
||||||
Push-Location (Join-Path $root "desktop")
|
Push-Location (Join-Path $root "desktop")
|
||||||
# Wails v2 talks to WebView2 through pure-Go bindings, so no cgo and no
|
# Wails v2 talks to WebView2 through pure-Go bindings, so no cgo and no
|
||||||
# toolchain beyond Go itself. Verified by cross-compiling the same package from
|
# toolchain beyond Go itself. A plain go build is used on purpose: the icon
|
||||||
# a Mac with CGO_ENABLED=0.
|
# and the manifest are compiled in from rsrc_windows_amd64.syso (go-winres,
|
||||||
|
# from brand/loyaly-icon-512.png), and `wails build` would add a second copy
|
||||||
|
# of both and fail the link with duplicate resources.
|
||||||
$env:CGO_ENABLED = "0"
|
$env:CGO_ENABLED = "0"
|
||||||
if (Get-Command wails -ErrorAction SilentlyContinue) {
|
go build -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
|
||||||
wails build -platform windows/amd64 -clean -ldflags "-X main.version=$Version"
|
if ($LASTEXITCODE -ne 0) { throw "desktop build failed" }
|
||||||
} else {
|
|
||||||
Write-Warning "wails CLI not found - falling back to a plain go build (no icon, no manifest)."
|
|
||||||
go build -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
|
|
||||||
}
|
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|
||||||
Step "Headless agent"
|
Step "Headless agent"
|
||||||
|
|||||||
@@ -26,5 +26,8 @@ dev = ["pytest>=8.0"]
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["behavision*"]
|
include = ["behavision*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
behavision = ["static/*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
1
server/internal/web/dist/assets/index-YUqJozyx.css
vendored
Normal file
BIN
server/internal/web/dist/favicon.png
vendored
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
5
server/internal/web/dist/index.html
vendored
@@ -4,9 +4,10 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<title>Behavision</title>
|
<title>Behavision</title>
|
||||||
<script type="module" crossorigin src="/assets/index-xQ5C-n-U.js"></script>
|
<script type="module" crossorigin src="/assets/index-DR5k3RfF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bgt5SnW3.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-YUqJozyx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
server/internal/web/dist/loyaly-mark.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<title>Behavision</title>
|
<title>Behavision</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
web/public/favicon.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
web/public/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
@@ -61,12 +61,10 @@ h3 { font-size: 13px; font-weight: 600; text-transform: uppercase;
|
|||||||
.brand strong { display: block; font-size: 15px; font-weight: 620; letter-spacing: -.01em; }
|
.brand strong { display: block; font-size: 15px; font-weight: 620; letter-spacing: -.01em; }
|
||||||
.brand .org { display: block; font-size: 12px; color: var(--muted); }
|
.brand .org { display: block; font-size: 12px; color: var(--muted); }
|
||||||
.mark {
|
.mark {
|
||||||
width: 18px; height: 18px; border-radius: 50%;
|
width: 22px; height: 22px; flex: none;
|
||||||
border: 2.5px solid var(--accent);
|
background: url(/loyaly-mark.png) center / contain no-repeat;
|
||||||
box-shadow: inset 0 0 0 3px var(--ground);
|
|
||||||
flex: none;
|
|
||||||
}
|
}
|
||||||
.mark.big { width: 30px; height: 30px; border-width: 3px; margin-bottom: 14px; }
|
.mark.big { width: 40px; height: 40px; margin-bottom: 14px; }
|
||||||
|
|
||||||
.tabs { display: flex; gap: 2px; margin-right: auto; }
|
.tabs { display: flex; gap: 2px; margin-right: auto; }
|
||||||
.tabs button {
|
.tabs button {
|
||||||
|
|||||||