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
This commit is contained in:
2026-09-19 12:54:19 +05:30
parent effa4f3d62
commit 4c62fc0ef3
47 changed files with 302 additions and 64 deletions

Binary file not shown.

Binary file not shown.

View 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

View File

@@ -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; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
brand/loyaly-icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
brand/loyaly-icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

BIN
brand/loyaly-icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
brand/loyaly-icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
brand/loyaly-icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
brand/loyaly-icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
brand/loyaly-icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
brand/loyaly-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
brand/loyaly.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
desktop/build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View 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}}"
}
}
}

View 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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -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); }

View File

@@ -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}>

View File

@@ -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.

View File

@@ -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

View File

@@ -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)",

Binary file not shown.

BIN
desktop/tray-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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}

View File

@@ -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"

View File

@@ -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"]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
server/internal/web/dist/favicon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
web/public/loyaly-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -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 {